mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-23 05:16:23 -07:00
feat(overlay): add subtitle selection modal and Jellyfin 12 fixes
- Add an optional subtitle selection modal for primary/secondary mpv tracks (subtitleSelection.enabled, g-s sequence shortcut) with key-sequence conflict handling - Authenticate Jellyfin URLs with the ApiKey query, answer remote keep-alives, clear now-playing on stop, and restore episode titles in Anki misc info - Honor the configured mpv executable when Jellyfin starts playback via a shared mpv-process launcher - Bump electron-builder to 26.16.1 - Condense and reconcile changelog fragments; update config example and docs
This commit is contained in:
@@ -7,6 +7,7 @@ import {
|
||||
createHandleJellyfinRemoteGeneralCommand,
|
||||
createHandleJellyfinRemotePlay,
|
||||
createHandleJellyfinRemotePlaystate,
|
||||
createJellyfinRemoteReportTracker,
|
||||
createReportJellyfinRemoteProgressHandler,
|
||||
createReportJellyfinRemoteStoppedHandler,
|
||||
} from '../domains/jellyfin';
|
||||
@@ -91,13 +92,17 @@ export function composeJellyfinRemoteHandlers(
|
||||
getNow: options.getNow,
|
||||
ticksPerSecond: options.ticksPerSecond,
|
||||
logDebug: options.logDebug,
|
||||
logWarn: options.logWarn,
|
||||
});
|
||||
const reportJellyfinRemoteProgress = createReportJellyfinRemoteProgressHandler(
|
||||
buildReportJellyfinRemoteProgressMainDepsHandler(),
|
||||
);
|
||||
const reportJellyfinRemoteStopped = createReportJellyfinRemoteStoppedHandler(
|
||||
buildReportJellyfinRemoteStoppedMainDepsHandler(),
|
||||
);
|
||||
const reportTracker = createJellyfinRemoteReportTracker();
|
||||
const reportJellyfinRemoteProgress = createReportJellyfinRemoteProgressHandler({
|
||||
...buildReportJellyfinRemoteProgressMainDepsHandler(),
|
||||
reportTracker,
|
||||
});
|
||||
const reportJellyfinRemoteStopped = createReportJellyfinRemoteStoppedHandler({
|
||||
...buildReportJellyfinRemoteStoppedMainDepsHandler(),
|
||||
reportTracker,
|
||||
});
|
||||
|
||||
const buildHandleJellyfinRemotePlayMainDepsHandler =
|
||||
createBuildHandleJellyfinRemotePlayMainDepsHandler({
|
||||
|
||||
@@ -54,6 +54,7 @@ test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers'
|
||||
sleep: async () => {},
|
||||
},
|
||||
launchMpvIdleForJellyfinPlaybackMainDeps: {
|
||||
getMpvExecutablePath: () => 'mpv',
|
||||
getSocketPath: () => '/tmp/test-mpv.sock',
|
||||
getLaunchMode: () => 'normal',
|
||||
platform: 'linux',
|
||||
|
||||
@@ -163,7 +163,10 @@ export function createConfigHotReloadAppliedHandler(deps: ConfigHotReloadApplied
|
||||
deps.setKeybindings(payload.keybindings);
|
||||
deps.setSessionBindings(payload.sessionBindings, payload.sessionBindingWarnings);
|
||||
|
||||
if (diff.hotReloadFields.includes('shortcuts')) {
|
||||
if (
|
||||
diff.hotReloadFields.includes('shortcuts') ||
|
||||
diff.hotReloadFields.includes('subtitleSelection')
|
||||
) {
|
||||
deps.refreshGlobalAndOverlayShortcuts();
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ function createShortcuts(): ConfiguredShortcuts {
|
||||
openRuntimeOptions: null,
|
||||
openJimaku: null,
|
||||
openTsukihime: null,
|
||||
openSubtitleSelection: null,
|
||||
openSubtitleGeneration: null,
|
||||
openSessionHelp: null,
|
||||
openControllerSelect: null,
|
||||
|
||||
@@ -24,6 +24,7 @@ function createShortcuts(): ConfiguredShortcuts {
|
||||
openRuntimeOptions: null,
|
||||
openJimaku: null,
|
||||
openTsukihime: null,
|
||||
openSubtitleSelection: null,
|
||||
openSubtitleGeneration: null,
|
||||
openSessionHelp: null,
|
||||
openControllerSelect: null,
|
||||
|
||||
@@ -165,7 +165,7 @@ export function createPlayJellyfinItemInMpvHandler(deps: {
|
||||
const mpvClient = deps.getMpvClient();
|
||||
if (!connected || !mpvClient) {
|
||||
throw new Error(
|
||||
'MPV not connected and auto-launch failed. Ensure mpv is installed and available in PATH.',
|
||||
'MPV not connected and auto-launch failed. Check mpv.executablePath or ensure mpv is available in PATH.',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ test('launch mpv for jellyfin main deps builder maps callbacks', () => {
|
||||
},
|
||||
};
|
||||
const deps = createBuildLaunchMpvIdleForJellyfinPlaybackMainDepsHandler({
|
||||
getMpvExecutablePath: () => '/usr/local/bin/mpv',
|
||||
getSocketPath: () => '/tmp/mpv.sock',
|
||||
getLaunchMode: () => 'fullscreen',
|
||||
platform: 'darwin',
|
||||
@@ -47,8 +48,8 @@ test('launch mpv for jellyfin main deps builder maps callbacks', () => {
|
||||
getDefaultMpvLogPath: () => '/tmp/mpv.log',
|
||||
defaultMpvArgs: ['--no-config'],
|
||||
removeSocketPath: (socketPath) => calls.push(`rm:${socketPath}`),
|
||||
spawnMpv: (args) => {
|
||||
calls.push(`spawn:${args.join(' ')}`);
|
||||
spawnMpv: (executablePath, args) => {
|
||||
calls.push(`spawn:${executablePath} ${args.join(' ')}`);
|
||||
return proc;
|
||||
},
|
||||
logWarn: (message) => calls.push(`warn:${message}`),
|
||||
@@ -60,14 +61,20 @@ test('launch mpv for jellyfin main deps builder maps callbacks', () => {
|
||||
assert.equal(deps.platform, 'darwin');
|
||||
assert.equal(deps.execPath, '/tmp/subminer');
|
||||
assert.equal(deps.getRuntimePluginEntrypoint?.(), '/tmp/plugin/subminer/main.lua');
|
||||
assert.equal(deps.getInstalledPluginDetection?.().installed, false);
|
||||
assert.equal(deps.getMpvExecutablePath(), '/usr/local/bin/mpv');
|
||||
assert.equal(deps.getInstalledPluginDetection?.('/usr/local/bin/mpv').installed, false);
|
||||
assert.equal(deps.getDefaultMpvLogPath(), '/tmp/mpv.log');
|
||||
assert.deepEqual(deps.defaultMpvArgs, ['--no-config']);
|
||||
deps.removeSocketPath('/tmp/mpv.sock');
|
||||
deps.spawnMpv(['--idle=yes']);
|
||||
deps.spawnMpv('/usr/local/bin/mpv', ['--idle=yes']);
|
||||
deps.logInfo('launched');
|
||||
deps.logWarn('bad', null);
|
||||
assert.deepEqual(calls, ['rm:/tmp/mpv.sock', 'spawn:--idle=yes', 'info:launched', 'warn:bad']);
|
||||
assert.deepEqual(calls, [
|
||||
'rm:/tmp/mpv.sock',
|
||||
'spawn:/usr/local/bin/mpv --idle=yes',
|
||||
'info:launched',
|
||||
'warn:bad',
|
||||
]);
|
||||
});
|
||||
|
||||
test('ensure mpv connected for jellyfin main deps builder maps callbacks', async () => {
|
||||
|
||||
@@ -16,6 +16,7 @@ export function createBuildLaunchMpvIdleForJellyfinPlaybackMainDepsHandler(
|
||||
deps: LaunchMpvForJellyfinDeps,
|
||||
) {
|
||||
return (): LaunchMpvForJellyfinDeps => ({
|
||||
getMpvExecutablePath: () => deps.getMpvExecutablePath(),
|
||||
getSocketPath: () => deps.getSocketPath(),
|
||||
getLaunchMode: () => deps.getLaunchMode(),
|
||||
platform: deps.platform,
|
||||
@@ -26,7 +27,7 @@ export function createBuildLaunchMpvIdleForJellyfinPlaybackMainDepsHandler(
|
||||
getDefaultMpvLogPath: () => deps.getDefaultMpvLogPath(),
|
||||
defaultMpvArgs: deps.defaultMpvArgs,
|
||||
removeSocketPath: (socketPath: string) => deps.removeSocketPath(socketPath),
|
||||
spawnMpv: (args: string[]) => deps.spawnMpv(args),
|
||||
spawnMpv: (executablePath, args) => deps.spawnMpv(executablePath, args),
|
||||
logWarn: (message: string, error: unknown) => deps.logWarn(message, error),
|
||||
logInfo: (message: string) => deps.logInfo(message),
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { detectInstalledMpvPlugin } from './first-run-setup-plugin';
|
||||
import { resolveWindowsMpvPath } from './mpv-process';
|
||||
import {
|
||||
createEnsureMpvConnectedForJellyfinPlaybackHandler,
|
||||
createLaunchMpvIdleForJellyfinPlaybackHandler,
|
||||
@@ -30,6 +32,7 @@ test('createLaunchMpvIdleForJellyfinPlaybackHandler builds expected mpv args', (
|
||||
const spawnedArgs: string[][] = [];
|
||||
const logs: string[] = [];
|
||||
const launch = createLaunchMpvIdleForJellyfinPlaybackHandler({
|
||||
getMpvExecutablePath: () => 'mpv',
|
||||
getSocketPath: () => '/tmp/subminer.sock',
|
||||
getLaunchMode: () => 'maximized',
|
||||
platform: 'darwin',
|
||||
@@ -39,7 +42,7 @@ test('createLaunchMpvIdleForJellyfinPlaybackHandler builds expected mpv args', (
|
||||
getDefaultMpvLogPath: () => ' /tmp/mp.log ',
|
||||
defaultMpvArgs: ['--sid=auto'],
|
||||
removeSocketPath: () => {},
|
||||
spawnMpv: (args) => {
|
||||
spawnMpv: (_executable, args) => {
|
||||
spawnedArgs.push(args);
|
||||
return {
|
||||
on: () => {},
|
||||
@@ -67,6 +70,7 @@ test('createLaunchMpvIdleForJellyfinPlaybackHandler builds expected mpv args', (
|
||||
test('createLaunchMpvIdleForJellyfinPlaybackHandler forwards runtime plugin config', () => {
|
||||
const spawnedArgs: string[][] = [];
|
||||
const launch = createLaunchMpvIdleForJellyfinPlaybackHandler({
|
||||
getMpvExecutablePath: () => 'mpv',
|
||||
getSocketPath: () => '/tmp/subminer.sock',
|
||||
getLaunchMode: () => 'normal',
|
||||
platform: 'linux',
|
||||
@@ -84,7 +88,7 @@ test('createLaunchMpvIdleForJellyfinPlaybackHandler forwards runtime plugin conf
|
||||
getDefaultMpvLogPath: () => '/tmp/mp.log',
|
||||
defaultMpvArgs: ['--sid=auto'],
|
||||
removeSocketPath: () => {},
|
||||
spawnMpv: (args) => {
|
||||
spawnMpv: (_executable, args) => {
|
||||
spawnedArgs.push(args);
|
||||
return {
|
||||
on: () => {},
|
||||
@@ -108,41 +112,53 @@ test('createLaunchMpvIdleForJellyfinPlaybackHandler forwards runtime plugin conf
|
||||
assert.doesNotMatch(scriptOpts ?? '', /subminer-aniskip_button_key=/);
|
||||
});
|
||||
|
||||
test('createLaunchMpvIdleForJellyfinPlaybackHandler skips bundled script when installed plugin exists', () => {
|
||||
const spawnedArgs: string[][] = [];
|
||||
const launch = createLaunchMpvIdleForJellyfinPlaybackHandler({
|
||||
getSocketPath: () => '/tmp/subminer.sock',
|
||||
getLaunchMode: () => 'normal',
|
||||
platform: 'linux',
|
||||
execPath: '/opt/SubMiner/SubMiner.AppImage',
|
||||
getRuntimePluginEntrypoint: () => '/opt/SubMiner/plugin/subminer/main.lua',
|
||||
getInstalledPluginDetection: () => ({
|
||||
installed: true,
|
||||
path: '/home/tester/.config/mpv/scripts/subminer/main.lua',
|
||||
version: '0.1.0',
|
||||
source: 'default-config',
|
||||
message: null,
|
||||
}),
|
||||
getDefaultMpvLogPath: () => '/tmp/mp.log',
|
||||
defaultMpvArgs: ['--sid=auto'],
|
||||
removeSocketPath: () => {},
|
||||
spawnMpv: (args) => {
|
||||
spawnedArgs.push(args);
|
||||
return {
|
||||
on: () => {},
|
||||
unref: () => {},
|
||||
};
|
||||
},
|
||||
logWarn: () => {},
|
||||
logInfo: () => {},
|
||||
});
|
||||
test('Jellyfin detects portable plugins beside the executable selected for launch', () => {
|
||||
const mpvPath = 'C:\\portable player\\mpv.exe';
|
||||
const pluginPath = 'C:\\portable player\\portable_config\\scripts\\subminer\\main.lua';
|
||||
for (const source of ['environment', 'PATH']) {
|
||||
let resolutions = 0;
|
||||
const spawned: Array<{ executable: string; args: string[] }> = [];
|
||||
const launch = createLaunchMpvIdleForJellyfinPlaybackHandler({
|
||||
getMpvExecutablePath: () => {
|
||||
resolutions += 1;
|
||||
return resolveWindowsMpvPath({
|
||||
getEnv: () => (source === 'environment' ? mpvPath : undefined),
|
||||
runWhere: () => ({ status: 0, stdout: mpvPath }),
|
||||
fileExists: (candidate) => candidate === mpvPath,
|
||||
});
|
||||
},
|
||||
getSocketPath: () => '\\\\.\\pipe\\subminer-test',
|
||||
getLaunchMode: () => 'normal',
|
||||
platform: 'win32',
|
||||
execPath: 'C:\\SubMiner\\SubMiner.exe',
|
||||
getRuntimePluginEntrypoint: () => 'C:\\SubMiner\\plugin\\subminer\\main.lua',
|
||||
getInstalledPluginDetection: (mpvExecutablePath) =>
|
||||
detectInstalledMpvPlugin({
|
||||
platform: 'win32',
|
||||
homeDir: 'C:\\Users\\test',
|
||||
mpvExecutablePath,
|
||||
existsSync: (candidate) => candidate === pluginPath,
|
||||
}),
|
||||
getDefaultMpvLogPath: () => '',
|
||||
defaultMpvArgs: [],
|
||||
removeSocketPath: () => {},
|
||||
spawnMpv: (executable, args) => {
|
||||
spawned.push({ executable, args });
|
||||
return { on: () => {}, unref: () => {} };
|
||||
},
|
||||
logWarn: () => {},
|
||||
logInfo: () => {},
|
||||
});
|
||||
|
||||
launch();
|
||||
assert.equal(
|
||||
spawnedArgs[0]?.some((arg) => arg.startsWith('--script=/opt/SubMiner/plugin/subminer')),
|
||||
false,
|
||||
);
|
||||
assert.ok(spawnedArgs[0]?.some((arg) => arg.startsWith('--script-opts=')));
|
||||
launch();
|
||||
assert.equal(resolutions, 1, source);
|
||||
assert.equal(spawned.length, 1);
|
||||
assert.equal(spawned[0]!.executable, mpvPath);
|
||||
assert.equal(
|
||||
spawned[0]!.args.some((arg) => arg.startsWith('--script=')),
|
||||
false,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('createEnsureMpvConnectedForJellyfinPlaybackHandler auto-launches once', async () => {
|
||||
|
||||
@@ -41,23 +41,25 @@ export function createWaitForMpvConnectedHandler(deps: WaitForMpvConnectedDeps)
|
||||
}
|
||||
|
||||
export type LaunchMpvForJellyfinDeps = {
|
||||
getMpvExecutablePath: () => string;
|
||||
getSocketPath: () => string;
|
||||
getLaunchMode: () => MpvLaunchMode;
|
||||
platform: NodeJS.Platform;
|
||||
execPath: string;
|
||||
getRuntimePluginEntrypoint?: () => string | null | undefined;
|
||||
getInstalledPluginDetection?: () => InstalledMpvPluginDetection;
|
||||
getInstalledPluginDetection?: (mpvExecutablePath: string) => InstalledMpvPluginDetection;
|
||||
getPluginRuntimeConfig?: () => SubminerPluginRuntimeScriptOptConfig;
|
||||
getDefaultMpvLogPath: () => string;
|
||||
defaultMpvArgs: readonly string[];
|
||||
removeSocketPath: (socketPath: string) => void;
|
||||
spawnMpv: (args: string[]) => SpawnedProcessLike;
|
||||
spawnMpv: (executablePath: string, args: string[]) => SpawnedProcessLike;
|
||||
logWarn: (message: string, error: unknown) => void;
|
||||
logInfo: (message: string) => void;
|
||||
};
|
||||
|
||||
export function createLaunchMpvIdleForJellyfinPlaybackHandler(deps: LaunchMpvForJellyfinDeps) {
|
||||
return (): void => {
|
||||
const executablePath = deps.getMpvExecutablePath();
|
||||
const socketPath = deps.getSocketPath();
|
||||
if (deps.platform !== 'win32') {
|
||||
try {
|
||||
@@ -78,7 +80,7 @@ export function createLaunchMpvIdleForJellyfinPlaybackHandler(deps: LaunchMpvFor
|
||||
)
|
||||
: [`subminer-binary_path=${deps.execPath}`, `subminer-socket_path=${socketPath}`];
|
||||
const scriptOpts = `--script-opts=${scriptOptParts.join(',')}`;
|
||||
const installedPlugin = deps.getInstalledPluginDetection?.();
|
||||
const installedPlugin = deps.getInstalledPluginDetection?.(executablePath);
|
||||
const runtimePluginEntrypoint = installedPlugin?.installed
|
||||
? ''
|
||||
: (deps.getRuntimePluginEntrypoint?.()?.trim() ?? '');
|
||||
@@ -95,7 +97,7 @@ export function createLaunchMpvIdleForJellyfinPlaybackHandler(deps: LaunchMpvFor
|
||||
...(defaultMpvLogPath ? [`--log-file=${defaultMpvLogPath}`] : []),
|
||||
`--input-ipc-server=${socketPath}`,
|
||||
];
|
||||
const proc = deps.spawnMpv(mpvArgs);
|
||||
const proc = deps.spawnMpv(executablePath, mpvArgs);
|
||||
proc.on('error', (error) => {
|
||||
deps.logWarn('Failed to launch mpv for Jellyfin remote playback', error);
|
||||
});
|
||||
|
||||
@@ -75,5 +75,6 @@ export function createBuildReportJellyfinRemoteStoppedMainDepsHandler(
|
||||
getNow: deps.getNow ? () => deps.getNow?.() ?? Date.now() : undefined,
|
||||
ticksPerSecond: deps.ticksPerSecond,
|
||||
logDebug: (message: string, error: unknown) => deps.logDebug(message, error),
|
||||
...(deps.logWarn ? { logWarn: (message: string) => deps.logWarn?.(message) } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
markJellyfinRemotePlaybackLoaded,
|
||||
createJellyfinRemoteReportTracker,
|
||||
createReportJellyfinRemoteProgressHandler,
|
||||
createReportJellyfinRemoteStoppedHandler,
|
||||
secondsToJellyfinTicks,
|
||||
@@ -528,3 +529,70 @@ test('createReportJellyfinRemoteStoppedHandler ignores startup stop churn before
|
||||
assert.equal(stopped, false);
|
||||
assert.equal(cleared, false);
|
||||
});
|
||||
|
||||
test('createReportJellyfinRemoteStoppedHandler clears playback before reporting and waits for in-flight progress', async () => {
|
||||
const tracker = createJellyfinRemoteReportTracker();
|
||||
let playback: { itemId: string; playMethod: 'DirectPlay'; loadedMediaPath: string } | null = {
|
||||
itemId: 'item-1',
|
||||
playMethod: 'DirectPlay',
|
||||
loadedMediaPath: 'http://pve-main:8096/Videos/item-1/stream',
|
||||
};
|
||||
const calls: string[] = [];
|
||||
let releaseProgress: () => void = () => undefined;
|
||||
const progressGate = new Promise<void>((resolve) => {
|
||||
releaseProgress = resolve;
|
||||
});
|
||||
const session = {
|
||||
isConnected: () => true,
|
||||
reportProgress: async ({ eventName }: { eventName: string }) => {
|
||||
calls.push(`progress:${eventName}:${playback ? 'active' : 'cleared'}`);
|
||||
if (calls.length === 1) await progressGate;
|
||||
return true;
|
||||
},
|
||||
reportStopped: async () => {
|
||||
calls.push(`stopped:${playback ? 'active' : 'cleared'}`);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
const shared = {
|
||||
getActivePlayback: () => playback,
|
||||
clearActivePlayback: () => {
|
||||
playback = null;
|
||||
},
|
||||
getSession: () => session,
|
||||
getMpvClient: () => ({ currentTimePos: 42 }),
|
||||
ticksPerSecond: 10_000_000,
|
||||
logDebug: () => undefined,
|
||||
reportTracker: tracker,
|
||||
};
|
||||
const reportProgress = createReportJellyfinRemoteProgressHandler({
|
||||
...shared,
|
||||
getNow: () => 10_000,
|
||||
getLastProgressAtMs: () => 0,
|
||||
setLastProgressAtMs: () => undefined,
|
||||
progressIntervalMs: 3000,
|
||||
});
|
||||
const reportStopped = createReportJellyfinRemoteStoppedHandler(shared);
|
||||
|
||||
// A periodic tick is mid-request when the stop starts.
|
||||
const tick = reportProgress(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.deepEqual(calls, ['progress:TimeUpdate:active']);
|
||||
const stop = reportStopped();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(playback, null);
|
||||
assert.deepEqual(calls, ['progress:TimeUpdate:active']);
|
||||
|
||||
// A tick fired after the stop began must not report anything.
|
||||
await reportProgress(true);
|
||||
assert.deepEqual(calls, ['progress:TimeUpdate:active']);
|
||||
|
||||
releaseProgress();
|
||||
await tick;
|
||||
await stop;
|
||||
assert.deepEqual(calls, [
|
||||
'progress:TimeUpdate:active',
|
||||
'progress:TimeUpdate:cleared',
|
||||
'stopped:cleared',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -134,6 +134,29 @@ function isSeekLikePositionJump(
|
||||
return Math.abs(nextPositionSeconds - previousPositionSeconds) >= thresholdSeconds;
|
||||
}
|
||||
|
||||
// Jellyfin re-creates a session's NowPlayingItem from any progress report, so a progress
|
||||
// tick that lands after the stop report leaves the server showing playback forever. The
|
||||
// tracker lets the stop handler wait for reports that are already in flight.
|
||||
export type JellyfinRemoteReportTracker = {
|
||||
track: (report: Promise<void>) => void;
|
||||
settled: () => Promise<void>;
|
||||
};
|
||||
|
||||
export function createJellyfinRemoteReportTracker(): JellyfinRemoteReportTracker {
|
||||
const active = new Set<Promise<void>>();
|
||||
return {
|
||||
track: (report) => {
|
||||
active.add(report);
|
||||
void report.finally(() => active.delete(report));
|
||||
},
|
||||
settled: async () => {
|
||||
while (active.size > 0) {
|
||||
await Promise.allSettled([...active]);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type JellyfinRemoteProgressReporterDeps = {
|
||||
getActivePlayback: () => ActiveJellyfinRemotePlaybackState | null;
|
||||
clearActivePlayback: () => void;
|
||||
@@ -145,6 +168,7 @@ export type JellyfinRemoteProgressReporterDeps = {
|
||||
progressIntervalMs: number;
|
||||
ticksPerSecond: number;
|
||||
logDebug: (message: string, error: unknown) => void;
|
||||
reportTracker?: JellyfinRemoteReportTracker;
|
||||
};
|
||||
|
||||
export function createReportJellyfinRemoteProgressHandler(
|
||||
@@ -152,7 +176,7 @@ export function createReportJellyfinRemoteProgressHandler(
|
||||
) {
|
||||
let lastReportedPositionSeconds: number | null = null;
|
||||
|
||||
return async (force = false): Promise<void> => {
|
||||
const report = async (force: boolean): Promise<void> => {
|
||||
const playback = deps.getActivePlayback();
|
||||
if (!playback) return;
|
||||
const session = deps.getSession();
|
||||
@@ -193,6 +217,12 @@ export function createReportJellyfinRemoteProgressHandler(
|
||||
deps.logDebug('Failed to report Jellyfin remote progress', error);
|
||||
}
|
||||
};
|
||||
|
||||
return async (force = false): Promise<void> => {
|
||||
const pending = report(force);
|
||||
deps.reportTracker?.track(pending);
|
||||
await pending;
|
||||
};
|
||||
}
|
||||
|
||||
export type JellyfinRemoteStoppedReporterDeps = {
|
||||
@@ -203,6 +233,8 @@ export type JellyfinRemoteStoppedReporterDeps = {
|
||||
getNow?: () => number;
|
||||
ticksPerSecond: number;
|
||||
logDebug: (message: string, error: unknown) => void;
|
||||
logWarn?: (message: string) => void;
|
||||
reportTracker?: JellyfinRemoteReportTracker;
|
||||
};
|
||||
|
||||
export function createReportJellyfinRemoteStoppedHandler(deps: JellyfinRemoteStoppedReporterDeps) {
|
||||
@@ -226,6 +258,10 @@ export function createReportJellyfinRemoteStoppedHandler(deps: JellyfinRemoteSto
|
||||
deps.clearActivePlayback();
|
||||
return;
|
||||
}
|
||||
// Clear before any network call so progress ticks fired during the stop find nothing to
|
||||
// report, then let reports already in flight finish so none can arrive after the stop.
|
||||
deps.clearActivePlayback();
|
||||
await deps.reportTracker?.settled();
|
||||
try {
|
||||
const observedPositionSeconds = await readMpvPositionSecondsOrFallback(deps.getMpvClient());
|
||||
const positionSeconds = resolveReportablePositionSeconds(playback, observedPositionSeconds);
|
||||
@@ -244,7 +280,7 @@ export function createReportJellyfinRemoteStoppedHandler(deps: JellyfinRemoteSto
|
||||
} catch (error) {
|
||||
deps.logDebug('Failed to report Jellyfin remote final progress', error);
|
||||
}
|
||||
await session.reportStopped({
|
||||
const reported = await session.reportStopped({
|
||||
itemId: playback.itemId,
|
||||
mediaSourceId: playback.mediaSourceId,
|
||||
positionTicks,
|
||||
@@ -254,10 +290,13 @@ export function createReportJellyfinRemoteStoppedHandler(deps: JellyfinRemoteSto
|
||||
subtitleStreamIndex: playback.subtitleStreamIndex,
|
||||
eventName: 'stop',
|
||||
});
|
||||
if (reported === false) {
|
||||
deps.logWarn?.(
|
||||
`Jellyfin did not accept the playback stop report for item ${playback.itemId}; the server may keep showing it as playing.`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
deps.logDebug('Failed to report Jellyfin remote stop', error);
|
||||
} finally {
|
||||
deps.clearActivePlayback();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ type JellyfinRemoteServiceOptions = {
|
||||
};
|
||||
onConnected: () => void;
|
||||
onDisconnected: () => void;
|
||||
logWarn?: (message: string, details?: unknown) => void;
|
||||
onPlay: (payload: JellyfinRemoteEventPayload) => void;
|
||||
onPlaystate: (payload: JellyfinRemoteEventPayload) => void;
|
||||
onGeneralCommand: (payload: JellyfinRemoteEventPayload) => void;
|
||||
@@ -110,6 +111,7 @@ export function createStartJellyfinRemoteSessionHandler(deps: {
|
||||
onDisconnected: () => {
|
||||
deps.logWarn('Jellyfin remote websocket disconnected; retrying.');
|
||||
},
|
||||
logWarn: (message, details) => deps.logWarn(message, details),
|
||||
onPlay: (payload) => {
|
||||
void deps.handlePlay(payload).catch((error) => {
|
||||
deps.logWarn('Failed handling Jellyfin remote Play event', error);
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { once } from 'node:events';
|
||||
import test from 'node:test';
|
||||
import { resolveMpvExecutablePath, spawnMpvProcess } from './mpv-process';
|
||||
|
||||
const testWindows = process.platform === 'win32' ? test : test.skip;
|
||||
|
||||
test('mpv process launcher forwards arguments and environment to the child', async () => {
|
||||
const child = spawnMpvProcess(
|
||||
process.execPath,
|
||||
['-e', 'process.exit(Number(process.env.SUBMINER_TEST_EXIT))'],
|
||||
{ ...process.env, DISPLAY: '', SUBMINER_TEST_EXIT: '17' },
|
||||
);
|
||||
try {
|
||||
const [code] = await once(child, 'exit');
|
||||
assert.equal(code, 17);
|
||||
} finally {
|
||||
if (child.exitCode === null) child.kill();
|
||||
}
|
||||
});
|
||||
|
||||
testWindows('managed Windows playback launches the configured executable', async () => {
|
||||
const executable = resolveMpvExecutablePath(` ${process.execPath} `);
|
||||
const child = spawnMpvProcess(executable, ['-e', 'process.exit(17)']);
|
||||
try {
|
||||
assert.equal(child.spawnfile, process.execPath);
|
||||
const [code] = await once(child, 'exit');
|
||||
assert.equal(code, 17);
|
||||
} finally {
|
||||
if (child.exitCode === null) child.kill();
|
||||
}
|
||||
});
|
||||
|
||||
testWindows('managed Windows playback rejects an invalid configured executable', () => {
|
||||
assert.throws(
|
||||
() => resolveMpvExecutablePath(`${process.execPath}/missing-mpv.exe`),
|
||||
/Could not find mpv.exe/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import fs from 'node:fs';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import {
|
||||
MPV_X11_BACKEND_ARGS,
|
||||
applyX11EnvOverrides,
|
||||
shouldForceX11WaylandSession,
|
||||
} from '../../shared/mpv-x11-backend';
|
||||
|
||||
export interface WindowsMpvPathDeps {
|
||||
getEnv: (name: string) => string | undefined;
|
||||
runWhere: () => { status: number | null; stdout: string; error?: Error };
|
||||
fileExists: (candidate: string) => boolean;
|
||||
}
|
||||
|
||||
export type ConfiguredWindowsMpvPathStatus = 'blank' | 'configured' | 'invalid';
|
||||
|
||||
function fileExists(candidate: string): boolean {
|
||||
try {
|
||||
return fs.statSync(candidate).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function getConfiguredWindowsMpvPathStatus(
|
||||
configuredMpvPath = '',
|
||||
exists: (candidate: string) => boolean = fileExists,
|
||||
): ConfiguredWindowsMpvPathStatus {
|
||||
const configPath = configuredMpvPath.trim();
|
||||
if (!configPath) {
|
||||
return 'blank';
|
||||
}
|
||||
return exists(configPath) ? 'configured' : 'invalid';
|
||||
}
|
||||
|
||||
export function createWindowsMpvPathDeps(
|
||||
overrides: Partial<WindowsMpvPathDeps> = {},
|
||||
): WindowsMpvPathDeps {
|
||||
return {
|
||||
getEnv: overrides.getEnv ?? ((name) => process.env[name]),
|
||||
fileExists: overrides.fileExists ?? fileExists,
|
||||
runWhere:
|
||||
overrides.runWhere ??
|
||||
(() => {
|
||||
const result = spawnSync('where.exe', ['mpv.exe'], {
|
||||
encoding: 'utf8',
|
||||
windowsHide: true,
|
||||
});
|
||||
return {
|
||||
status: result.status,
|
||||
stdout: result.stdout ?? '',
|
||||
error: result.error ?? undefined,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveWindowsMpvPath(deps: WindowsMpvPathDeps, configuredMpvPath = ''): string {
|
||||
const configPath = configuredMpvPath.trim();
|
||||
const configuredPathStatus = getConfiguredWindowsMpvPathStatus(configPath, deps.fileExists);
|
||||
if (configuredPathStatus === 'configured') {
|
||||
return configPath;
|
||||
}
|
||||
if (configuredPathStatus === 'invalid') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const envPath = deps.getEnv('SUBMINER_MPV_PATH')?.trim();
|
||||
if (envPath && deps.fileExists(envPath)) {
|
||||
return envPath;
|
||||
}
|
||||
|
||||
const whereResult = deps.runWhere();
|
||||
if (whereResult.status === 0) {
|
||||
const firstPath = whereResult.stdout
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find((line) => line.length > 0 && deps.fileExists(line));
|
||||
if (firstPath) {
|
||||
return firstPath;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
export function spawnMpvProcess(
|
||||
executablePath: string,
|
||||
args: string[],
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): ReturnType<typeof spawn> {
|
||||
const forceX11 = shouldForceX11WaylandSession(env);
|
||||
return spawn(executablePath, forceX11 ? [...args, ...MPV_X11_BACKEND_ARGS] : args, {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
windowsHide: true,
|
||||
env: forceX11 ? applyX11EnvOverrides({ ...env }) : env,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveMpvExecutablePath(configuredMpvPath = ''): string {
|
||||
const executablePath =
|
||||
process.platform === 'win32'
|
||||
? resolveWindowsMpvPath(createWindowsMpvPathDeps(), configuredMpvPath)
|
||||
: 'mpv';
|
||||
if (!executablePath) {
|
||||
throw new Error(
|
||||
'Could not find mpv.exe. Check mpv.executablePath, SUBMINER_MPV_PATH, or PATH.',
|
||||
);
|
||||
}
|
||||
return executablePath;
|
||||
}
|
||||
@@ -70,3 +70,69 @@ test('persistSessionBindings keeps saved bindings when mpv reload notification f
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('native prefix conflicts publish the same effective bindings to the overlay and plugin and recover', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-session-conflict-'));
|
||||
const sequence: CompiledSessionBinding = {
|
||||
sourcePath: 'shortcuts.openSubtitleSelection',
|
||||
originalKey: 'g-s',
|
||||
key: { code: 'KeyG-KeyS', modifiers: [] },
|
||||
actionType: 'session-action',
|
||||
actionId: 'openSubtitleSelection',
|
||||
};
|
||||
let nativeKeys: unknown = [];
|
||||
let failDiscovery = false;
|
||||
let published: CompiledSessionBinding[] = [];
|
||||
const events: CompiledSessionBinding[][] = [];
|
||||
const warnings: string[] = [];
|
||||
const client = {
|
||||
connected: true,
|
||||
send: () => {},
|
||||
requestProperty: async () => {
|
||||
if (failDiscovery) throw new Error('temporarily unavailable');
|
||||
return nativeKeys;
|
||||
},
|
||||
};
|
||||
const runtime = createSessionBindingsRuntime({
|
||||
configDir: root,
|
||||
getKeybindings: () => [],
|
||||
getConfiguredShortcuts: () => ({ multiCopyTimeoutMs: 1500 }) as never,
|
||||
getResolvedConfig: () => ({ stats: { toggleKey: 's', markWatchedKey: 'w' } }) as ResolvedConfig,
|
||||
getMpvClient: () => client,
|
||||
setSessionBindings: (bindings) => {
|
||||
published = bindings;
|
||||
},
|
||||
setSessionBindingsInitialized: () => {},
|
||||
logWarn: () => {},
|
||||
onBindingsChanged: (bindings) => events.push(bindings),
|
||||
onWarning: (warning) => warnings.push(warning.message),
|
||||
});
|
||||
const readArtifact = () =>
|
||||
JSON.parse(fs.readFileSync(path.join(root, 'session-bindings.json'), 'utf8'));
|
||||
try {
|
||||
runtime.persistSessionBindings([sequence]);
|
||||
nativeKeys = [{ key: 'g', cmd: 'show-text single', priority: 1 }];
|
||||
await runtime.refreshMpvSessionBindings();
|
||||
assert.deepEqual(published, []);
|
||||
assert.deepEqual(events.at(-1), readArtifact().bindings);
|
||||
assert.equal(warnings.length, 1);
|
||||
assert.match(warnings[0]!, /mpv input binding "g"/);
|
||||
await runtime.refreshMpvSessionBindings();
|
||||
assert.equal(events.length, 2, 'unchanged discovery must not create a reload loop');
|
||||
assert.equal(warnings.length, 1);
|
||||
failDiscovery = true;
|
||||
await runtime.refreshMpvSessionBindings();
|
||||
assert.deepEqual(published, [], 'failed discovery retains the known conflict');
|
||||
failDiscovery = false;
|
||||
nativeKeys = [{ key: 'Shift+g', cmd: 'show-text shifted', priority: 1 }];
|
||||
await runtime.refreshMpvSessionBindings();
|
||||
assert.deepEqual(published, [sequence]);
|
||||
assert.equal(readArtifact().bindings[0].key.code, 'KeyG-KeyS');
|
||||
assert.deepEqual(readArtifact().warnings, []);
|
||||
client.connected = false;
|
||||
await runtime.refreshMpvSessionBindings();
|
||||
assert.deepEqual(published, [sequence]);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -6,16 +6,26 @@ import {
|
||||
import type { ConfiguredShortcuts } from '../../core/utils/shortcut-config';
|
||||
import type { CompiledSessionBinding, Keybinding, ResolvedConfig } from '../../types';
|
||||
import { writeSessionBindingsArtifact } from './session-bindings-artifact';
|
||||
import { parseMpvInputBindingKeys } from '../../shared/mpv-input-bindings';
|
||||
import {
|
||||
reserveMpvSequencePrefixes,
|
||||
resolveSessionSequenceConflicts,
|
||||
} from '../../shared/session-key-sequences';
|
||||
import type { SessionBindingWarning } from '../../types/session-bindings';
|
||||
|
||||
export interface SessionBindingsRuntimeDeps {
|
||||
configDir: string;
|
||||
getKeybindings: () => Keybinding[];
|
||||
getConfiguredShortcuts: () => ConfiguredShortcuts;
|
||||
getResolvedConfig: () => ResolvedConfig;
|
||||
getMpvClient: () => MpvRuntimeClientLike | null;
|
||||
getMpvClient: () =>
|
||||
| (MpvRuntimeClientLike & { requestProperty: (name: string) => Promise<unknown> })
|
||||
| null;
|
||||
setSessionBindings: (bindings: CompiledSessionBinding[]) => void;
|
||||
setSessionBindingsInitialized: (initialized: boolean) => void;
|
||||
logWarn: (message: string, details?: unknown) => void;
|
||||
onBindingsChanged?: (bindings: CompiledSessionBinding[]) => void;
|
||||
onWarning?: (warning: SessionBindingWarning) => void;
|
||||
}
|
||||
|
||||
export function createSessionBindingsRuntime(deps: SessionBindingsRuntimeDeps): {
|
||||
@@ -24,7 +34,20 @@ export function createSessionBindingsRuntime(deps: SessionBindingsRuntimeDeps):
|
||||
warnings?: ReturnType<typeof compileSessionBindings>['warnings'],
|
||||
) => void;
|
||||
refreshCurrentSessionBindings: () => void;
|
||||
refreshMpvSessionBindings: () => Promise<void>;
|
||||
} {
|
||||
let sourceBindings: CompiledSessionBinding[] = [];
|
||||
let sourceWarnings: SessionBindingWarning[] = [];
|
||||
let nativeSnapshot: {
|
||||
client: ReturnType<SessionBindingsRuntimeDeps['getMpvClient']>;
|
||||
keys: string[];
|
||||
} | null = null;
|
||||
let pending: {
|
||||
client: ReturnType<SessionBindingsRuntimeDeps['getMpvClient']>;
|
||||
promise: Promise<void>;
|
||||
} | null = null;
|
||||
let publishedSignature: string | null = null;
|
||||
let reportedWarnings = new Set<string>();
|
||||
function resolveSessionBindingPlatform(): 'darwin' | 'win32' | 'linux' {
|
||||
if (process.platform === 'darwin') return 'darwin';
|
||||
if (process.platform === 'win32') return 'win32';
|
||||
@@ -49,8 +72,27 @@ export function createSessionBindingsRuntime(deps: SessionBindingsRuntimeDeps):
|
||||
bindings: CompiledSessionBinding[],
|
||||
warnings: ReturnType<typeof compileSessionBindings>['warnings'] = [],
|
||||
): void {
|
||||
sourceBindings = bindings;
|
||||
sourceWarnings = warnings;
|
||||
publishBindings();
|
||||
}
|
||||
|
||||
function publishBindings(): void {
|
||||
const client = deps.getMpvClient();
|
||||
const keys = client?.connected && nativeSnapshot?.client === client ? nativeSnapshot.keys : [];
|
||||
const result = resolveSessionSequenceConflicts(
|
||||
sourceBindings,
|
||||
reserveMpvSequencePrefixes(keys),
|
||||
);
|
||||
const warnings = [...sourceWarnings, ...result.warnings];
|
||||
const signature = JSON.stringify([
|
||||
result.bindings,
|
||||
warnings,
|
||||
deps.getConfiguredShortcuts().multiCopyTimeoutMs,
|
||||
]);
|
||||
if (signature === publishedSignature) return;
|
||||
const artifact = buildPluginSessionBindingsArtifact({
|
||||
bindings,
|
||||
bindings: result.bindings,
|
||||
warnings,
|
||||
numericSelectionTimeoutMs: deps.getConfiguredShortcuts().multiCopyTimeoutMs,
|
||||
});
|
||||
@@ -60,8 +102,16 @@ export function createSessionBindingsRuntime(deps: SessionBindingsRuntimeDeps):
|
||||
deps.logWarn('[session-bindings] Failed to write session bindings artifact');
|
||||
throw error;
|
||||
}
|
||||
deps.setSessionBindings(bindings);
|
||||
publishedSignature = signature;
|
||||
deps.setSessionBindings(result.bindings);
|
||||
deps.setSessionBindingsInitialized(true);
|
||||
const nextWarnings = new Set(warnings.map((warning) => warning.message));
|
||||
for (const warning of warnings) {
|
||||
if (reportedWarnings.has(warning.message)) continue;
|
||||
deps.logWarn(`[session-bindings] ${warning.message}`);
|
||||
deps.onWarning?.(warning);
|
||||
}
|
||||
reportedWarnings = nextWarnings;
|
||||
const mpvClient = deps.getMpvClient();
|
||||
if (mpvClient?.connected) {
|
||||
try {
|
||||
@@ -70,15 +120,41 @@ export function createSessionBindingsRuntime(deps: SessionBindingsRuntimeDeps):
|
||||
deps.logWarn('[session-bindings] Failed to notify mpv to reload session bindings', error);
|
||||
}
|
||||
}
|
||||
deps.onBindingsChanged?.(result.bindings);
|
||||
}
|
||||
|
||||
async function refreshMpvSessionBindings(): Promise<void> {
|
||||
const client = deps.getMpvClient();
|
||||
if (!client?.connected) {
|
||||
nativeSnapshot = null;
|
||||
publishBindings();
|
||||
return;
|
||||
}
|
||||
if (pending?.client === client) return pending.promise;
|
||||
const promise = (async () => {
|
||||
try {
|
||||
const raw = await client.requestProperty('input-bindings');
|
||||
if (client !== deps.getMpvClient() || !client.connected) return;
|
||||
nativeSnapshot = { client, keys: parseMpvInputBindingKeys(raw, { includeIgnored: false }) };
|
||||
publishBindings();
|
||||
} catch {
|
||||
// Keep the last successful snapshot if discovery is temporarily unavailable.
|
||||
}
|
||||
})();
|
||||
const request = { client, promise };
|
||||
pending = request;
|
||||
try {
|
||||
await promise;
|
||||
} finally {
|
||||
if (pending === request) pending = null;
|
||||
}
|
||||
}
|
||||
|
||||
function refreshCurrentSessionBindings(): void {
|
||||
const compiled = compileCurrentSessionBindings();
|
||||
for (const warning of compiled.warnings) {
|
||||
deps.logWarn(`[session-bindings] ${warning.message}`);
|
||||
}
|
||||
persistSessionBindings(compiled.bindings, compiled.warnings);
|
||||
void refreshMpvSessionBindings();
|
||||
}
|
||||
|
||||
return { persistSessionBindings, refreshCurrentSessionBindings };
|
||||
return { persistSessionBindings, refreshCurrentSessionBindings, refreshMpvSessionBindings };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createSubtitleSelectionRuntime } from './subtitle-selection';
|
||||
|
||||
function setup() {
|
||||
let enabled = true;
|
||||
const properties = new Map<string, unknown>([
|
||||
['path', '/video.mkv'],
|
||||
[
|
||||
'track-list',
|
||||
[
|
||||
{ id: 1, type: 'audio' },
|
||||
{ id: 2, type: 'sub', title: 'Japanese', lang: 'ja', codec: 'ass' },
|
||||
{ id: 3, type: 'sub', title: 'English', lang: 'en', external: true },
|
||||
{ id: '4', type: 'sub' },
|
||||
],
|
||||
],
|
||||
['sid', 2],
|
||||
['secondary-sid', 3],
|
||||
]);
|
||||
const commands: unknown[][] = [];
|
||||
const client = {
|
||||
connected: true,
|
||||
requestProperty: async (name: string) => properties.get(name),
|
||||
request: async (command: unknown[]) => {
|
||||
commands.push(command);
|
||||
return { error: 'success' };
|
||||
},
|
||||
};
|
||||
const runtime = createSubtitleSelectionRuntime({
|
||||
isEnabled: () => enabled,
|
||||
getMpvClient: () => client,
|
||||
});
|
||||
return {
|
||||
runtime,
|
||||
properties,
|
||||
commands,
|
||||
client,
|
||||
disable: () => {
|
||||
enabled = false;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('subtitle selector lists only valid subtitle tracks and current selections', async () => {
|
||||
const { runtime, properties } = setup();
|
||||
assert.deepEqual(await runtime.getState(), {
|
||||
mediaPath: '/video.mkv',
|
||||
primary: 2,
|
||||
secondary: 3,
|
||||
tracks: [
|
||||
{ id: 2, label: '#2 · Japanese · ja · ass' },
|
||||
{ id: 3, label: '#3 · English · en · external' },
|
||||
],
|
||||
});
|
||||
properties.set('sid', 'no');
|
||||
properties.set('secondary-sid', false);
|
||||
const state = await runtime.getState();
|
||||
assert.equal(state.primary, null);
|
||||
assert.equal(state.secondary, null);
|
||||
});
|
||||
|
||||
test('subtitle selector swaps tracks and supports disabling both tracks', async () => {
|
||||
const { runtime, commands } = setup();
|
||||
await runtime.apply({ mediaPath: '/video.mkv', primary: 3, secondary: 2 });
|
||||
assert.deepEqual(commands, [
|
||||
['set_property', 'secondary-sid', 'no'],
|
||||
['set_property', 'sid', 3],
|
||||
['set_property', 'secondary-sid', 2],
|
||||
]);
|
||||
commands.length = 0;
|
||||
await runtime.apply({ mediaPath: '/video.mkv', primary: null, secondary: null });
|
||||
assert.ok(commands.every((command) => command[2] === 'no'));
|
||||
});
|
||||
|
||||
test('subtitle selector rejects stale media, unavailable tracks, duplicate tracks and malformed requests without mutation', async () => {
|
||||
const { runtime, commands } = setup();
|
||||
for (const request of [
|
||||
{ mediaPath: '/other.mkv', primary: 2, secondary: 3 },
|
||||
{ mediaPath: '/video.mkv', primary: 99, secondary: null },
|
||||
{ mediaPath: '/video.mkv', primary: 2, secondary: 2 },
|
||||
{ mediaPath: '/video.mkv', primary: '2', secondary: null },
|
||||
{ mediaPath: '/video.mkv', primary: -1, secondary: null },
|
||||
null,
|
||||
])
|
||||
await assert.rejects(runtime.apply(request));
|
||||
assert.deepEqual(commands, []);
|
||||
});
|
||||
|
||||
test('subtitle selector gates access on config and connection and propagates mpv failures', async () => {
|
||||
const { runtime, client, disable } = setup();
|
||||
client.request = async () => ({ error: 'property unavailable' });
|
||||
await assert.rejects(
|
||||
runtime.apply({ mediaPath: '/video.mkv', primary: 3, secondary: 2 }),
|
||||
/property unavailable/,
|
||||
);
|
||||
client.connected = false;
|
||||
await assert.rejects(runtime.getState(), /Connect to mpv/);
|
||||
disable();
|
||||
await assert.rejects(runtime.getState(), /Enable subtitle selection/);
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { IpcMain, WebContents } from 'electron';
|
||||
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||
import {
|
||||
parseSubtitleSelectionRequest,
|
||||
type SubtitleSelectionState,
|
||||
} from '../../shared/subtitle-selection';
|
||||
import { openOverlayHostedModal, retryOverlayModalOpen } from './overlay-hosted-modal-open';
|
||||
|
||||
interface SelectionMpvClient {
|
||||
connected: boolean;
|
||||
requestProperty: (name: string) => Promise<unknown>;
|
||||
request: (command: unknown[]) => Promise<{ error?: string }>;
|
||||
}
|
||||
|
||||
export function openSubtitleSelectionModal(
|
||||
deps: Parameters<typeof openOverlayHostedModal>[0] & Parameters<typeof retryOverlayModalOpen>[0],
|
||||
): Promise<boolean> {
|
||||
return retryOverlayModalOpen(deps, {
|
||||
modal: 'subtitle-selection',
|
||||
timeoutMs: 1500,
|
||||
retryWarning: 'Subtitle selection modal did not acknowledge opening; retrying.',
|
||||
sendOpen: () =>
|
||||
openOverlayHostedModal(deps, {
|
||||
channel: IPC_CHANNELS.event.subtitleSelectionOpen,
|
||||
modal: 'subtitle-selection',
|
||||
preferModalWindow: true,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function createSubtitleSelectionRuntime(deps: {
|
||||
isEnabled: () => boolean;
|
||||
getMpvClient: () => SelectionMpvClient | null;
|
||||
}) {
|
||||
function getClient(): SelectionMpvClient {
|
||||
if (!deps.isEnabled()) throw new Error('Enable subtitle selection in Settings first.');
|
||||
const client = deps.getMpvClient();
|
||||
if (!client?.connected) throw new Error('Connect to mpv first.');
|
||||
return client;
|
||||
}
|
||||
|
||||
async function readState(client: SelectionMpvClient): Promise<SubtitleSelectionState> {
|
||||
const mediaPath = await client.requestProperty('path');
|
||||
if (typeof mediaPath !== 'string' || !mediaPath) throw new Error('Open a video first.');
|
||||
const [rawTracks, primary, secondary] = await Promise.all([
|
||||
client.requestProperty('track-list'),
|
||||
client.requestProperty('sid'),
|
||||
client.requestProperty('secondary-sid'),
|
||||
]);
|
||||
const tracks: SubtitleSelectionState['tracks'] = [];
|
||||
const candidates: unknown[] = Array.isArray(rawTracks) ? rawTracks : [];
|
||||
for (const track of candidates) {
|
||||
if (
|
||||
typeof track !== 'object' ||
|
||||
track === null ||
|
||||
!('type' in track) ||
|
||||
track.type !== 'sub' ||
|
||||
!('id' in track) ||
|
||||
typeof track.id !== 'number' ||
|
||||
!Number.isSafeInteger(track.id) ||
|
||||
track.id <= 0
|
||||
)
|
||||
continue;
|
||||
const details = [
|
||||
'title' in track ? track.title : undefined,
|
||||
'lang' in track ? track.lang : undefined,
|
||||
'codec' in track ? track.codec : undefined,
|
||||
].filter((value): value is string => typeof value === 'string' && value.length > 0);
|
||||
if ('external' in track && track.external === true) details.push('external');
|
||||
tracks.push({ id: track.id, label: `#${track.id} · ${details.join(' · ') || 'Subtitle'}` });
|
||||
}
|
||||
if ((await client.requestProperty('path')) !== mediaPath)
|
||||
throw new Error('The video changed. Reopen subtitle selection.');
|
||||
const selected = (value: unknown): number | null =>
|
||||
tracks.find((track) => track.id === value)?.id ?? null;
|
||||
return { mediaPath, tracks, primary: selected(primary), secondary: selected(secondary) };
|
||||
}
|
||||
|
||||
async function apply(value: unknown): Promise<void> {
|
||||
const selection = parseSubtitleSelectionRequest(value);
|
||||
const client = getClient();
|
||||
const current = await readState(client);
|
||||
if (current.mediaPath !== selection.mediaPath)
|
||||
throw new Error('The video changed. Reopen subtitle selection.');
|
||||
for (const id of [selection.primary, selection.secondary]) {
|
||||
if (id !== null && !current.tracks.some((track) => track.id === id))
|
||||
throw new Error('A selected track is no longer available. Reopen subtitle selection.');
|
||||
}
|
||||
const set = async (property: string, id: number | null): Promise<void> => {
|
||||
const response = await client.request(['set_property', property, id ?? 'no']);
|
||||
if (response.error && response.error !== 'success') throw new Error(response.error);
|
||||
};
|
||||
// Clear secondary first so swapping the two tracks works in mpv.
|
||||
await set('secondary-sid', null);
|
||||
await set('sid', selection.primary);
|
||||
await set('secondary-sid', selection.secondary);
|
||||
}
|
||||
|
||||
return { getState: async () => readState(getClient()), apply };
|
||||
}
|
||||
|
||||
export function registerSubtitleSelectionIpc(deps: {
|
||||
ipc: Pick<IpcMain, 'handle'>;
|
||||
isAllowedSender: (sender: WebContents) => boolean;
|
||||
runtime: ReturnType<typeof createSubtitleSelectionRuntime>;
|
||||
}): void {
|
||||
deps.ipc.handle(IPC_CHANNELS.request.getSubtitleSelection, (event) => {
|
||||
if (!deps.isAllowedSender(event.sender))
|
||||
throw new Error('Subtitle selection requires the overlay.');
|
||||
return deps.runtime.getState();
|
||||
});
|
||||
deps.ipc.handle(IPC_CHANNELS.request.applySubtitleSelection, (event, value: unknown) => {
|
||||
if (!deps.isAllowedSender(event.sender))
|
||||
throw new Error('Subtitle selection requires the overlay.');
|
||||
return deps.runtime.apply(value);
|
||||
});
|
||||
}
|
||||
@@ -29,12 +29,12 @@ test('resolveWindowsMpvPath prefers SUBMINER_MPV_PATH', () => {
|
||||
assert.equal(resolved, 'C:\\mpv\\mpv.exe');
|
||||
});
|
||||
|
||||
test('resolveWindowsMpvPath prefers configured executable path before PATH', () => {
|
||||
test('resolveWindowsMpvPath prefers configured executable path before environment and PATH', () => {
|
||||
const resolved = resolveWindowsMpvPath(
|
||||
createDeps({
|
||||
getEnv: () => undefined,
|
||||
getEnv: () => 'C:\\other\\mpv.exe',
|
||||
runWhere: () => ({ status: 0, stdout: 'C:\\tools\\mpv.exe\r\n' }),
|
||||
fileExists: (candidate) => candidate === 'C:\\mpv\\mpv.exe',
|
||||
fileExists: (candidate) => ['C:\\mpv\\mpv.exe', 'C:\\other\\mpv.exe'].includes(candidate),
|
||||
}),
|
||||
' C:\\mpv\\mpv.exe ',
|
||||
);
|
||||
@@ -53,6 +53,16 @@ test('resolveWindowsMpvPath falls back to where.exe output', () => {
|
||||
assert.equal(resolved, 'C:\\tools\\mpv.exe');
|
||||
});
|
||||
|
||||
test('resolveWindowsMpvPath ignores an invalid environment override but keeps config authoritative', () => {
|
||||
const deps = createDeps({
|
||||
getEnv: () => 'C:\\missing\\mpv.exe',
|
||||
runWhere: () => ({ status: 0, stdout: 'C:\\tools\\mpv.exe\r\n' }),
|
||||
fileExists: (candidate) => candidate === 'C:\\tools\\mpv.exe',
|
||||
});
|
||||
assert.equal(resolveWindowsMpvPath(deps), 'C:\\tools\\mpv.exe');
|
||||
assert.equal(resolveWindowsMpvPath(deps, 'C:\\missing\\mpv.exe'), '');
|
||||
});
|
||||
|
||||
test('buildWindowsMpvLaunchArgs uses explicit SubMiner defaults and targets', () => {
|
||||
assert.deepEqual(
|
||||
buildWindowsMpvLaunchArgs(
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import fs from 'node:fs';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { isLogFileEnabled } from '../../shared/log-files';
|
||||
import { canConnectSocket } from '../../shared/socket-probe';
|
||||
import { buildMpvLaunchModeArgs } from '../../shared/mpv-launch-mode';
|
||||
@@ -8,11 +6,19 @@ import { buildSubminerPluginRuntimeScriptOptParts } from '../../shared/subminer-
|
||||
import type { MpvLaunchMode } from '../../types/config';
|
||||
import type { SubminerPluginRuntimeScriptOptConfig } from '../../shared/subminer-plugin-script-opts';
|
||||
import type { InstalledMpvPluginDetection } from './first-run-setup-plugin';
|
||||
import {
|
||||
createWindowsMpvPathDeps,
|
||||
resolveWindowsMpvPath,
|
||||
spawnMpvProcess,
|
||||
type WindowsMpvPathDeps,
|
||||
} from './mpv-process';
|
||||
export {
|
||||
getConfiguredWindowsMpvPathStatus,
|
||||
resolveWindowsMpvPath,
|
||||
type ConfiguredWindowsMpvPathStatus,
|
||||
} from './mpv-process';
|
||||
|
||||
export interface WindowsMpvLaunchDeps {
|
||||
getEnv: (name: string) => string | undefined;
|
||||
runWhere: () => { status: number | null; stdout: string; error?: Error };
|
||||
fileExists: (candidate: string) => boolean;
|
||||
export interface WindowsMpvLaunchDeps extends WindowsMpvPathDeps {
|
||||
spawnDetached: (command: string, args: string[], env?: NodeJS.ProcessEnv) => Promise<void>;
|
||||
isAppControlServerAvailable?: () => Promise<boolean>;
|
||||
sendAppControlCommand?: (
|
||||
@@ -23,8 +29,6 @@ export interface WindowsMpvLaunchDeps {
|
||||
logInfo?: (message: string) => void;
|
||||
}
|
||||
|
||||
export type ConfiguredWindowsMpvPathStatus = 'blank' | 'configured' | 'invalid';
|
||||
|
||||
export interface WindowsMpvRuntimePluginPolicy {
|
||||
detectInstalledMpvPlugin?: (mpvPath: string) => InstalledMpvPluginDetection;
|
||||
notifyInstalledPluginDetected?: (detection: InstalledMpvPluginDetection) => void;
|
||||
@@ -38,54 +42,6 @@ function normalizeCandidate(candidate: string | undefined): string {
|
||||
return typeof candidate === 'string' ? candidate.trim() : '';
|
||||
}
|
||||
|
||||
function defaultWindowsMpvFileExists(candidate: string): boolean {
|
||||
try {
|
||||
return fs.statSync(candidate).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function getConfiguredWindowsMpvPathStatus(
|
||||
configuredMpvPath = '',
|
||||
fileExists: (candidate: string) => boolean = defaultWindowsMpvFileExists,
|
||||
): ConfiguredWindowsMpvPathStatus {
|
||||
const configPath = normalizeCandidate(configuredMpvPath);
|
||||
if (!configPath) {
|
||||
return 'blank';
|
||||
}
|
||||
return fileExists(configPath) ? 'configured' : 'invalid';
|
||||
}
|
||||
|
||||
export function resolveWindowsMpvPath(deps: WindowsMpvLaunchDeps, configuredMpvPath = ''): string {
|
||||
const configPath = normalizeCandidate(configuredMpvPath);
|
||||
const configuredPathStatus = getConfiguredWindowsMpvPathStatus(configPath, deps.fileExists);
|
||||
if (configuredPathStatus === 'configured') {
|
||||
return configPath;
|
||||
}
|
||||
if (configuredPathStatus === 'invalid') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const envPath = normalizeCandidate(deps.getEnv('SUBMINER_MPV_PATH'));
|
||||
if (envPath && deps.fileExists(envPath)) {
|
||||
return envPath;
|
||||
}
|
||||
|
||||
const whereResult = deps.runWhere();
|
||||
if (whereResult.status === 0) {
|
||||
const firstPath = whereResult.stdout
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find((line) => line.length > 0 && deps.fileExists(line));
|
||||
if (firstPath) {
|
||||
return firstPath;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
const DEFAULT_WINDOWS_MPV_SOCKET = '\\\\.\\pipe\\subminer-socket';
|
||||
const RUNNING_APP_ATTACH_SOCKET_WAIT_MS = 10000;
|
||||
|
||||
@@ -332,19 +288,7 @@ export function createWindowsMpvLaunchDeps(options: {
|
||||
logInfo?: (message: string) => void;
|
||||
}): WindowsMpvLaunchDeps {
|
||||
return {
|
||||
getEnv: options.getEnv ?? ((name) => process.env[name]),
|
||||
runWhere: () => {
|
||||
const result = spawnSync('where.exe', ['mpv.exe'], {
|
||||
encoding: 'utf8',
|
||||
windowsHide: true,
|
||||
});
|
||||
return {
|
||||
status: result.status,
|
||||
stdout: result.stdout ?? '',
|
||||
error: result.error ?? undefined,
|
||||
};
|
||||
},
|
||||
fileExists: options.fileExists ?? defaultWindowsMpvFileExists,
|
||||
...createWindowsMpvPathDeps(options),
|
||||
isAppControlServerAvailable: options.isAppControlServerAvailable,
|
||||
sendAppControlCommand: options.sendAppControlCommand,
|
||||
waitForSocketReady,
|
||||
@@ -352,12 +296,11 @@ export function createWindowsMpvLaunchDeps(options: {
|
||||
spawnDetached: (command, args, env) =>
|
||||
new Promise((resolve, reject) => {
|
||||
try {
|
||||
const child = spawn(command, args, {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
windowsHide: true,
|
||||
env: env ? { ...process.env, ...env } : process.env,
|
||||
});
|
||||
const child = spawnMpvProcess(
|
||||
command,
|
||||
args,
|
||||
env ? { ...process.env, ...env } : process.env,
|
||||
);
|
||||
let settled = false;
|
||||
child.once('error', (error) => {
|
||||
if (settled) return;
|
||||
|
||||
Reference in New Issue
Block a user