Fix Windows mpv shortcut launch and subtitle dedupe

This commit is contained in:
2026-04-02 23:28:43 -07:00
parent 640c8acd7c
commit 85e3aa4c6b
15 changed files with 480 additions and 22 deletions

View File

@@ -184,6 +184,39 @@ test('dispatchMpvProtocolMessage sets secondary subtitle track based on track li
assert.deepEqual(state.commands, [{ command: ['set_property', 'secondary-sid', 2] }]);
});
test('dispatchMpvProtocolMessage prefers the already selected matching secondary track', async () => {
const { deps, state } = createDeps();
await dispatchMpvProtocolMessage(
{
request_id: MPV_REQUEST_ID_TRACK_LIST_SECONDARY,
data: [
{
type: 'sub',
id: 2,
lang: 'ja',
title: 'ja.srt',
selected: false,
external: true,
'external-filename': '/tmp/dupe.srt',
},
{
type: 'sub',
id: 3,
lang: 'ja',
title: 'ja.srt',
selected: true,
external: true,
'external-filename': '/tmp/dupe.srt',
},
],
},
deps,
);
assert.deepEqual(state.commands, [{ command: ['set_property', 'secondary-sid', 3] }]);
});
test('dispatchMpvProtocolMessage restores secondary visibility on shutdown', async () => {
const { deps, state } = createDeps();

View File

@@ -93,6 +93,97 @@ export interface MpvProtocolHandleMessageDeps {
restorePreviousSecondarySubVisibility: () => void;
}
type SubtitleTrackCandidate = {
id: number;
lang: string;
title: string;
selected: boolean;
external: boolean;
externalFilename: string | null;
};
function normalizeSubtitleTrackCandidate(track: Record<string, unknown>): SubtitleTrackCandidate | null {
const id =
typeof track.id === 'number'
? track.id
: typeof track.id === 'string'
? Number(track.id.trim())
: Number.NaN;
if (!Number.isInteger(id)) {
return null;
}
const externalFilename =
typeof track['external-filename'] === 'string' && track['external-filename'].trim().length > 0
? track['external-filename'].trim()
: typeof track.external_filename === 'string' && track.external_filename.trim().length > 0
? track.external_filename.trim()
: null;
return {
id,
lang: String(track.lang || '').trim().toLowerCase(),
title: String(track.title || '').trim().toLowerCase(),
selected: track.selected === true,
external: track.external === true,
externalFilename,
};
}
function getSubtitleTrackIdentity(track: SubtitleTrackCandidate): string {
if (track.externalFilename) {
return `external:${track.externalFilename.toLowerCase()}`;
}
if (track.title.length > 0) {
return `title:${track.title}`;
}
return `id:${track.id}`;
}
function pickSecondarySubtitleTrackId(
tracks: Array<Record<string, unknown>>,
preferredLanguages: string[],
): number | null {
const normalizedLanguages = preferredLanguages
.map((language) => language.trim().toLowerCase())
.filter((language) => language.length > 0);
if (normalizedLanguages.length === 0) {
return null;
}
const subtitleTracks = tracks
.filter((track) => track.type === 'sub')
.map(normalizeSubtitleTrackCandidate)
.filter((track): track is SubtitleTrackCandidate => track !== null);
const dedupedTracks = new Map<string, SubtitleTrackCandidate>();
for (const track of subtitleTracks) {
const identity = getSubtitleTrackIdentity(track);
const existing = dedupedTracks.get(identity);
if (!existing || (track.selected && !existing.selected)) {
dedupedTracks.set(identity, track);
}
}
const uniqueTracks = [...dedupedTracks.values()];
for (const language of normalizedLanguages) {
const selectedMatch = uniqueTracks.find(
(track) => track.selected && track.lang === language,
);
if (selectedMatch) {
return selectedMatch.id;
}
const match = uniqueTracks.find((track) => track.lang === language);
if (match) {
return match.id;
}
}
return null;
}
export function splitMpvMessagesFromBuffer(
buffer: string,
onMessage?: MpvMessageParser,
@@ -283,15 +374,11 @@ export async function dispatchMpvProtocolMessage(
if (Array.isArray(tracks)) {
const config = deps.getResolvedConfig();
const languages = config.secondarySub?.secondarySubLanguages || [];
const subTracks = tracks.filter((track) => track.type === 'sub');
for (const language of languages) {
const match = subTracks.find((track) => track.lang === language);
if (match) {
deps.sendCommand({
command: ['set_property', 'secondary-sid', match.id],
});
break;
}
const secondaryTrackId = pickSecondarySubtitleTrackId(tracks, languages);
if (secondaryTrackId !== null) {
deps.sendCommand({
command: ['set_property', 'secondary-sid', secondaryTrackId],
});
}
}
} else if (msg.request_id === MPV_REQUEST_ID_TRACK_LIST_AUDIO) {

View File

@@ -92,6 +92,52 @@ test('triggerSubsyncFromConfig opens manual picker in manual mode', async () =>
assert.equal(inProgressState, false);
});
test('triggerSubsyncFromConfig dedupes repeated subtitle source tracks', async () => {
let payloadTrackCount = 0;
await triggerSubsyncFromConfig(
makeDeps({
getMpvClient: () => ({
connected: true,
currentAudioStreamIndex: null,
send: () => {},
requestProperty: async (name: string) => {
if (name === 'path') return '/tmp/video.mkv';
if (name === 'sid') return 1;
if (name === 'secondary-sid') return 2;
if (name === 'track-list') {
return [
{ id: 1, type: 'sub', selected: true, lang: 'jpn' },
{
id: 2,
type: 'sub',
selected: true,
external: true,
lang: 'eng',
'external-filename': '/tmp/ref.srt',
},
{
id: 3,
type: 'sub',
selected: false,
external: true,
lang: 'eng',
'external-filename': '/tmp/ref.srt',
},
];
}
return null;
},
}),
openManualPicker: (payload) => {
payloadTrackCount = payload.sourceTracks.length;
},
}),
);
assert.equal(payloadTrackCount, 1);
});
test('triggerSubsyncFromConfig reports failures to OSD', async () => {
const osd: string[] = [];
await triggerSubsyncFromConfig(

View File

@@ -76,6 +76,31 @@ function normalizeTrackIds(tracks: unknown[]): MpvTrack[] {
});
}
function getSourceTrackIdentity(track: MpvTrack): string {
if (track.external && typeof track['external-filename'] === 'string' && track['external-filename'].length > 0) {
return `external:${track['external-filename'].toLowerCase()}`;
}
if (typeof track.id === 'number') {
return `id:${track.id}`;
}
if (typeof track.title === 'string' && track.title.length > 0) {
return `title:${track.title.toLowerCase()}`;
}
return 'unknown';
}
function dedupeSourceTracks(tracks: MpvTrack[]): MpvTrack[] {
const deduped = new Map<string, MpvTrack>();
for (const track of tracks) {
const identity = getSourceTrackIdentity(track);
const existing = deduped.get(identity);
if (!existing || (track.selected && !existing.selected)) {
deduped.set(identity, track);
}
}
return [...deduped.values()];
}
export interface TriggerSubsyncFromConfigDeps extends SubsyncCoreDeps {
isSubsyncInProgress: () => boolean;
setSubsyncInProgress: (inProgress: boolean) => void;
@@ -123,12 +148,13 @@ async function gatherSubsyncContext(client: MpvClientLike): Promise<SubsyncConte
const filename = track['external-filename'];
return typeof filename === 'string' && filename.length > 0;
});
const uniqueSourceTracks = dedupeSourceTracks(sourceTracks);
return {
videoPath,
primaryTrack,
secondaryTrack,
sourceTracks,
sourceTracks: uniqueSourceTracks,
audioStreamIndex: client.currentAudioStreamIndex,
};
}

View File

@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import {
configureEarlyAppPaths,
normalizeLaunchMpvExtraArgs,
normalizeStartupArgv,
normalizeLaunchMpvTargets,
sanitizeHelpEnv,
@@ -70,6 +71,41 @@ test('launch-mpv entry helpers detect and normalize targets', () => {
assert.deepEqual(normalizeLaunchMpvTargets(['SubMiner.exe', '--launch-mpv', 'C:\\a.mkv']), [
'C:\\a.mkv',
]);
assert.deepEqual(
normalizeLaunchMpvExtraArgs([
'SubMiner.exe',
'--launch-mpv',
'--profile=subminer',
'--pause=yes',
'C:\\a.mkv',
]),
['--profile=subminer', '--pause=yes'],
);
assert.deepEqual(
normalizeLaunchMpvExtraArgs([
'SubMiner.exe',
'--launch-mpv',
'--input-ipc-server',
'\\\\.\\pipe\\custom-subminer-socket',
'--alang',
'ja,jpn',
'C:\\a.mkv',
]),
['--input-ipc-server', '\\\\.\\pipe\\custom-subminer-socket', '--alang', 'ja,jpn'],
);
assert.deepEqual(
normalizeLaunchMpvTargets([
'SubMiner.exe',
'--launch-mpv',
'--input-ipc-server',
'\\\\.\\pipe\\custom-subminer-socket',
'--alang',
'ja,jpn',
'C:\\a.mkv',
'C:\\b.mkv',
]),
['C:\\a.mkv', 'C:\\b.mkv'],
);
});
test('stats-daemon entry helper detects internal daemon commands', () => {

View File

@@ -121,7 +121,94 @@ export function shouldHandleStatsDaemonCommandAtEntry(
}
export function normalizeLaunchMpvTargets(argv: string[]): string[] {
return parseCliArgs(argv).launchMpvTargets;
const launchMpvIndex = argv.findIndex((arg) => arg === '--launch-mpv');
if (launchMpvIndex < 0) {
return [];
}
const targets: string[] = [];
const flagValueArgs = new Set([
'--alang',
'--input-ipc-server',
'--log-file',
'--profile',
'--script',
'--script-opts',
'--scripts',
'--slang',
'--sub-file-paths',
'--ytdl-format',
]);
let parsingTargets = false;
for (let i = launchMpvIndex + 1; i < argv.length; i += 1) {
const token = argv[i];
if (!token) continue;
if (parsingTargets) {
targets.push(token);
continue;
}
if (token === '--') {
parsingTargets = true;
continue;
}
if (token.startsWith('-')) {
if (!token.includes('=') && flagValueArgs.has(token)) {
i += 1;
}
continue;
}
parsingTargets = true;
targets.push(token);
}
return targets;
}
export function normalizeLaunchMpvExtraArgs(argv: string[]): string[] {
const launchMpvIndex = argv.findIndex((arg) => arg === '--launch-mpv');
if (launchMpvIndex < 0) {
return [];
}
const flagValueArgs = new Set([
'--alang',
'--input-ipc-server',
'--log-file',
'--profile',
'--script',
'--script-opts',
'--scripts',
'--slang',
'--sub-file-paths',
'--ytdl-format',
]);
const extraArgs: string[] = [];
for (let i = launchMpvIndex + 1; i < argv.length; i += 1) {
const token = argv[i];
if (!token) continue;
if (token === '--') {
break;
}
if (!token.startsWith('-')) {
break;
}
extraArgs.push(token);
if (!token.includes('=') && flagValueArgs.has(token)) {
const value = argv[i + 1];
if (value && value !== '--') {
extraArgs.push(value);
i += 1;
}
}
}
return extraArgs;
}
export function sanitizeStartupEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {

View File

@@ -1,8 +1,10 @@
import path from 'node:path';
import { spawn } from 'node:child_process';
import { app, dialog } from 'electron';
import { printHelp } from './cli/help';
import {
configureEarlyAppPaths,
normalizeLaunchMpvExtraArgs,
normalizeLaunchMpvTargets,
normalizeStartupArgv,
sanitizeStartupEnv,
@@ -15,6 +17,7 @@ import {
shouldHandleStatsDaemonCommandAtEntry,
} from './main-entry-runtime';
import { requestSingleInstanceLockEarly } from './main/early-single-instance';
import { resolvePackagedFirstRunPluginAssets } from './main/runtime/first-run-setup-plugin';
import { createWindowsMpvLaunchDeps, launchWindowsMpv } from './main/runtime/windows-mpv-launch';
import { runStatsDaemonControlFromProcess } from './stats-daemon-entry';
@@ -32,6 +35,19 @@ function applySanitizedEnv(sanitizedEnv: NodeJS.ProcessEnv): void {
}
}
function resolveBundledWindowsMpvPluginEntrypoint(): string | undefined {
const assets = resolvePackagedFirstRunPluginAssets({
dirname: __dirname,
appPath: app.getAppPath(),
resourcesPath: process.resourcesPath,
});
if (!assets) {
return undefined;
}
return path.join(assets.pluginDirSource, 'main.lua');
}
process.argv = normalizeStartupArgv(process.argv, process.env);
applySanitizedEnv(sanitizeStartupEnv(process.env));
configureEarlyAppPaths(app);
@@ -68,6 +84,9 @@ if (shouldHandleLaunchMpvAtEntry(process.argv, process.env)) {
dialog.showErrorBox(title, content);
},
}),
normalizeLaunchMpvExtraArgs(process.argv),
process.execPath,
resolveBundledWindowsMpvPluginEntrypoint(),
);
app.exit(result.ok ? 0 : 1);
});

View File

@@ -41,21 +41,57 @@ test('resolveWindowsMpvPath falls back to where.exe output', () => {
});
test('buildWindowsMpvLaunchArgs uses explicit SubMiner defaults and targets', () => {
assert.deepEqual(buildWindowsMpvLaunchArgs(['C:\\a.mkv', 'C:\\b.mkv']), [
assert.deepEqual(
buildWindowsMpvLaunchArgs(
['C:\\a.mkv', 'C:\\b.mkv'],
[],
'C:\\SubMiner\\SubMiner.exe',
'C:\\Program Files\\SubMiner\\resources\\plugin\\subminer\\main.lua',
),
[
'--player-operation-mode=pseudo-gui',
'--force-window=immediate',
'--script=C:\\Program Files\\SubMiner\\resources\\plugin\\subminer\\main.lua',
'--input-ipc-server=\\\\.\\pipe\\subminer-socket',
'--alang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
'--slang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
'--sub-auto=fuzzy',
'--sub-file-paths=.;subs;subtitles',
'--sub-file-paths=subs;subtitles',
'--sid=auto',
'--secondary-sid=auto',
'--secondary-sub-visibility=no',
'--script-opts=subminer-binary_path=C:\\SubMiner\\SubMiner.exe,subminer-socket_path=\\\\.\\pipe\\subminer-socket',
'C:\\a.mkv',
'C:\\b.mkv',
]);
});
test('buildWindowsMpvLaunchArgs keeps shortcut-only launches in idle mode', () => {
assert.deepEqual(
buildWindowsMpvLaunchArgs(
[],
[],
'C:\\SubMiner\\SubMiner.exe',
'C:\\Program Files\\SubMiner\\resources\\plugin\\subminer\\main.lua',
),
[
'--player-operation-mode=pseudo-gui',
'--force-window=immediate',
'--idle=yes',
'--script=C:\\Program Files\\SubMiner\\resources\\plugin\\subminer\\main.lua',
'--input-ipc-server=\\\\.\\pipe\\subminer-socket',
'--alang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
'--slang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
'--sub-auto=fuzzy',
'--sub-file-paths=subs;subtitles',
'--sid=auto',
'--secondary-sid=auto',
'--secondary-sub-visibility=no',
'--script-opts=subminer-binary_path=C:\\SubMiner\\SubMiner.exe,subminer-socket_path=\\\\.\\pipe\\subminer-socket',
],
);
});
test('launchWindowsMpv reports missing mpv path', () => {
const errors: string[] = [];
const result = launchWindowsMpv(
@@ -82,13 +118,16 @@ test('launchWindowsMpv spawns detached mpv with targets', () => {
calls.push(args.join('|'));
},
}),
[],
'C:\\SubMiner\\SubMiner.exe',
'C:\\Program Files\\SubMiner\\resources\\plugin\\subminer\\main.lua',
);
assert.equal(result.ok, true);
assert.equal(result.mpvPath, 'C:\\mpv\\mpv.exe');
assert.deepEqual(calls, [
'C:\\mpv\\mpv.exe',
'--player-operation-mode=pseudo-gui|--input-ipc-server=\\\\.\\pipe\\subminer-socket|--alang=ja,jp,jpn,japanese,en,eng,english,enus,en-us|--slang=ja,jp,jpn,japanese,en,eng,english,enus,en-us|--sub-auto=fuzzy|--sub-file-paths=.;subs;subtitles|--sid=auto|--secondary-sid=auto|--secondary-sub-visibility=no|C:\\video.mkv',
'--player-operation-mode=pseudo-gui|--force-window=immediate|--script=C:\\Program Files\\SubMiner\\resources\\plugin\\subminer\\main.lua|--input-ipc-server=\\\\.\\pipe\\subminer-socket|--alang=ja,jp,jpn,japanese,en,eng,english,enus,en-us|--slang=ja,jp,jpn,japanese,en,eng,english,enus,en-us|--sub-auto=fuzzy|--sub-file-paths=subs;subtitles|--sid=auto|--secondary-sid=auto|--secondary-sub-visibility=no|--script-opts=subminer-binary_path=C:\\SubMiner\\SubMiner.exe,subminer-socket_path=\\\\.\\pipe\\subminer-socket|C:\\video.mkv',
]);
});

View File

@@ -33,17 +33,36 @@ export function resolveWindowsMpvPath(deps: WindowsMpvLaunchDeps): string {
return '';
}
export function buildWindowsMpvLaunchArgs(targets: string[], extraArgs: string[] = []): string[] {
export function buildWindowsMpvLaunchArgs(
targets: string[],
extraArgs: string[] = [],
binaryPath?: string,
pluginEntrypointPath?: string,
): string[] {
const launchIdle = targets.length === 0;
const scriptOpts =
typeof binaryPath === 'string' && binaryPath.trim().length > 0
? `--script-opts=subminer-binary_path=${binaryPath.trim().replace(/,/g, '\\,')},subminer-socket_path=\\\\.\\pipe\\subminer-socket`
: null;
const scriptEntrypoint =
typeof pluginEntrypointPath === 'string' && pluginEntrypointPath.trim().length > 0
? `--script=${pluginEntrypointPath.trim()}`
: null;
return [
'--player-operation-mode=pseudo-gui',
'--force-window=immediate',
...(launchIdle ? ['--idle=yes'] : []),
...(scriptEntrypoint ? [scriptEntrypoint] : []),
'--input-ipc-server=\\\\.\\pipe\\subminer-socket',
'--alang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
'--slang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
'--sub-auto=fuzzy',
'--sub-file-paths=.;subs;subtitles',
'--sub-file-paths=subs;subtitles',
'--sid=auto',
'--secondary-sid=auto',
'--secondary-sub-visibility=no',
...(scriptOpts ? [scriptOpts] : []),
...extraArgs,
...targets,
];
@@ -53,6 +72,8 @@ export function launchWindowsMpv(
targets: string[],
deps: WindowsMpvLaunchDeps,
extraArgs: string[] = [],
binaryPath?: string,
pluginEntrypointPath?: string,
): { ok: boolean; mpvPath: string } {
const mpvPath = resolveWindowsMpvPath(deps);
if (!mpvPath) {
@@ -64,7 +85,10 @@ export function launchWindowsMpv(
}
try {
deps.spawnDetached(mpvPath, buildWindowsMpvLaunchArgs(targets, extraArgs));
deps.spawnDetached(
mpvPath,
buildWindowsMpvLaunchArgs(targets, extraArgs, binaryPath, pluginEntrypointPath),
);
return { ok: true, mpvPath };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);