[codex] Make Windows mpv shortcut self-contained (#40)

This commit is contained in:
2026-04-03 21:35:18 -07:00
committed by GitHub
parent d6c72806bb
commit 7514985feb
131 changed files with 3367 additions and 716 deletions

View File

@@ -24,7 +24,11 @@ test('createMainBootServices builds boot-phase service bundle', () => {
{ scope: string; warn: () => void; info: () => void; error: () => void },
{ registry: boolean },
{ getModalWindow: () => null },
{ inputState: boolean; getModalInputExclusive: () => boolean; handleModalInputStateChange: (isActive: boolean) => void },
{
inputState: boolean;
getModalInputExclusive: () => boolean;
handleModalInputStateChange: (isActive: boolean) => void;
},
{ measurementStore: boolean },
{ modalRuntime: boolean },
{ mpvSocketPath: string; texthookerPort: number },
@@ -80,7 +84,11 @@ test('createMainBootServices builds boot-phase service bundle', () => {
createOverlayManager: () => ({
getModalWindow: () => null,
}),
createOverlayModalInputState: () => ({ inputState: true, getModalInputExclusive: () => false, handleModalInputStateChange: () => {} }),
createOverlayModalInputState: () => ({
inputState: true,
getModalInputExclusive: () => false,
handleModalInputStateChange: () => {},
}),
createOverlayContentMeasurementStore: () => ({ measurementStore: true }),
getSyncOverlayShortcutsForModal: () => () => {},
getSyncOverlayVisibilityForModal: () => () => {},
@@ -106,8 +114,14 @@ test('createMainBootServices builds boot-phase service bundle', () => {
mpvSocketPath: '/tmp/subminer.sock',
texthookerPort: 5174,
});
assert.equal(services.appLifecycleApp.on('ready', () => {}), services.appLifecycleApp);
assert.equal(services.appLifecycleApp.on('second-instance', () => {}), services.appLifecycleApp);
assert.equal(
services.appLifecycleApp.on('ready', () => {}),
services.appLifecycleApp,
);
assert.equal(
services.appLifecycleApp.on('second-instance', () => {}),
services.appLifecycleApp,
);
assert.deepEqual(appOnCalls, ['ready']);
assert.equal(secondInstanceHandlerRegistered, true);
assert.deepEqual(calls, ['mkdir:/tmp/subminer-config']);

View File

@@ -56,9 +56,7 @@ export interface MainBootServicesParams<
};
shouldBypassSingleInstanceLock: () => boolean;
requestSingleInstanceLockEarly: () => boolean;
registerSecondInstanceHandlerEarly: (
listener: (_event: unknown, argv: string[]) => void,
) => void;
registerSecondInstanceHandlerEarly: (listener: (_event: unknown, argv: string[]) => void) => void;
onConfigStartupParseError: (error: ConfigStartupParseError) => void;
createConfigService: (configDir: string) => TConfigService;
createAnilistTokenStore: (targetPath: string) => TAnilistTokenStore;
@@ -87,10 +85,7 @@ export interface MainBootServicesParams<
overlayModalInputState: TOverlayModalInputState;
onModalStateChange: (isActive: boolean) => void;
}) => TOverlayModalRuntime;
createAppState: (input: {
mpvSocketPath: string;
texthookerPort: number;
}) => TAppState;
createAppState: (input: { mpvSocketPath: string; texthookerPort: number }) => TAppState;
}
export interface MainBootServicesResult<
@@ -239,9 +234,7 @@ export function createMainBootServices<
const appLifecycleApp = {
requestSingleInstanceLock: () =>
params.shouldBypassSingleInstanceLock()
? true
: params.requestSingleInstanceLockEarly(),
params.shouldBypassSingleInstanceLock() ? true : params.requestSingleInstanceLockEarly(),
quit: () => params.app.quit(),
on: (event: string, listener: (...args: unknown[]) => void) => {
if (event === 'second-instance') {

View File

@@ -31,9 +31,9 @@ function readStoredZipEntries(zipPath: string): Map<string, Buffer> {
const extraLength = archive.readUInt16LE(cursor + 28);
const fileNameStart = cursor + 30;
const dataStart = fileNameStart + fileNameLength + extraLength;
const fileName = archive.subarray(fileNameStart, fileNameStart + fileNameLength).toString(
'utf8',
);
const fileName = archive
.subarray(fileNameStart, fileNameStart + fileNameLength)
.toString('utf8');
const data = archive.subarray(dataStart, dataStart + compressedSize);
entries.set(fileName, Buffer.from(data));
cursor = dataStart + compressedSize;
@@ -57,7 +57,9 @@ test('buildDictionaryZip writes a valid stored zip without fs.writeFileSync', ()
}) as typeof fs.writeFileSync;
Buffer.concat = ((...args: Parameters<typeof Buffer.concat>) => {
throw new Error(`buildDictionaryZip should not Buffer.concat the full archive (${args[0].length} chunks)`);
throw new Error(
`buildDictionaryZip should not Buffer.concat the full archive (${args[0].length} chunks)`,
);
}) as typeof Buffer.concat;
const result = buildDictionaryZip(
@@ -91,8 +93,9 @@ test('buildDictionaryZip writes a valid stored zip without fs.writeFileSync', ()
assert.equal(indexJson.revision, '2026-03-27');
assert.equal(indexJson.format, 3);
const termBank = JSON.parse(entries.get('term_bank_1.json')!.toString('utf8')) as
CharacterDictionaryTermEntry[];
const termBank = JSON.parse(
entries.get('term_bank_1.json')!.toString('utf8'),
) as CharacterDictionaryTermEntry[];
assert.equal(termBank.length, 1);
assert.equal(termBank[0]?.[0], 'アルファ');
assert.deepEqual(entries.get('images/alpha.bin'), Buffer.from([1, 2, 3]));

View File

@@ -138,7 +138,11 @@ function createCentralDirectoryHeader(entry: ZipEntry): Buffer {
return central;
}
function createEndOfCentralDirectory(entriesLength: number, centralSize: number, centralStart: number): Buffer {
function createEndOfCentralDirectory(
entriesLength: number,
centralSize: number,
centralStart: number,
): Buffer {
const end = Buffer.alloc(22);
let cursor = 0;
writeUint32LE(end, 0x06054b50, cursor);

View File

@@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import { createAutoplayReadyGate } from './autoplay-ready-gate';
test('autoplay ready gate suppresses duplicate media signals unless forced while paused', async () => {
test('autoplay ready gate suppresses duplicate media signals for the same media', async () => {
const commands: Array<Array<string | boolean>> = [];
const scheduled: Array<() => void> = [];
@@ -31,20 +31,19 @@ test('autoplay ready gate suppresses duplicate media signals unless forced while
gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null });
gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null });
gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null }, { forceWhilePaused: true });
await new Promise((resolve) => setTimeout(resolve, 0));
const firstScheduled = scheduled.shift();
firstScheduled?.();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(commands.filter((command) => command[0] === 'script-message'), [
['script-message', 'subminer-autoplay-ready'],
]);
assert.deepEqual(
commands.filter((command) => command[0] === 'script-message'),
[['script-message', 'subminer-autoplay-ready']],
);
assert.ok(
commands.some(
(command) =>
command[0] === 'set_property' && command[1] === 'pause' && command[2] === false,
(command) => command[0] === 'set_property' && command[1] === 'pause' && command[2] === false,
),
);
assert.equal(scheduled.length > 0, true);
@@ -85,14 +84,62 @@ test('autoplay ready gate retry loop does not re-signal plugin readiness', async
await new Promise((resolve) => setTimeout(resolve, 0));
}
assert.deepEqual(commands.filter((command) => command[0] === 'script-message'), [
['script-message', 'subminer-autoplay-ready'],
]);
assert.deepEqual(
commands.filter((command) => command[0] === 'script-message'),
[['script-message', 'subminer-autoplay-ready']],
);
assert.equal(
commands.filter(
(command) =>
command[0] === 'set_property' && command[1] === 'pause' && command[2] === false,
(command) => command[0] === 'set_property' && command[1] === 'pause' && command[2] === false,
).length > 0,
true,
);
});
test('autoplay ready gate does not unpause again after a later manual pause on the same media', async () => {
const commands: Array<Array<string | boolean>> = [];
let playbackPaused = true;
const gate = createAutoplayReadyGate({
isAppOwnedFlowInFlight: () => false,
getCurrentMediaPath: () => '/media/video.mkv',
getCurrentVideoPath: () => null,
getPlaybackPaused: () => playbackPaused,
getMpvClient: () =>
({
connected: true,
requestProperty: async () => playbackPaused,
send: ({ command }: { command: Array<string | boolean> }) => {
commands.push(command);
if (command[0] === 'set_property' && command[1] === 'pause' && command[2] === false) {
playbackPaused = false;
}
},
}) as never,
signalPluginAutoplayReady: () => {
commands.push(['script-message', 'subminer-autoplay-ready']);
},
schedule: (callback) => {
queueMicrotask(callback);
return 1 as never;
},
logDebug: () => {},
});
gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null }, { forceWhilePaused: true });
await new Promise((resolve) => setTimeout(resolve, 0));
playbackPaused = true;
gate.maybeSignalPluginAutoplayReady(
{ text: '字幕その2', tokens: null },
{ forceWhilePaused: true },
);
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(
commands.filter(
(command) => command[0] === 'set_property' && command[1] === 'pause' && command[2] === false,
).length,
1,
);
});

View File

@@ -40,12 +40,8 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
}
const mediaPath =
deps.getCurrentMediaPath()?.trim() ||
deps.getCurrentVideoPath()?.trim() ||
'__unknown__';
deps.getCurrentMediaPath()?.trim() || deps.getCurrentVideoPath()?.trim() || '__unknown__';
const duplicateMediaSignal = autoPlayReadySignalMediaPath === mediaPath;
const allowDuplicateWhilePaused =
options?.forceWhilePaused === true && deps.getPlaybackPaused() !== false;
const releaseRetryDelayMs = 200;
const maxReleaseAttempts = resolveAutoplayReadyMaxReleaseAttempts({
forceWhilePaused: options?.forceWhilePaused === true,
@@ -87,7 +83,10 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
const mpvClient = deps.getMpvClient();
if (!mpvClient?.connected) {
if (attempt < maxReleaseAttempts) {
deps.schedule(() => attemptRelease(playbackGeneration, attempt + 1), releaseRetryDelayMs);
deps.schedule(
() => attemptRelease(playbackGeneration, attempt + 1),
releaseRetryDelayMs,
);
}
return;
}
@@ -104,19 +103,13 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
})();
};
if (duplicateMediaSignal && !allowDuplicateWhilePaused) {
return;
}
if (!duplicateMediaSignal) {
autoPlayReadySignalMediaPath = mediaPath;
const playbackGeneration = ++autoPlayReadySignalGeneration;
deps.signalPluginAutoplayReady();
attemptRelease(playbackGeneration, 0);
if (duplicateMediaSignal) {
return;
}
autoPlayReadySignalMediaPath = mediaPath;
const playbackGeneration = ++autoPlayReadySignalGeneration;
deps.signalPluginAutoplayReady();
attemptRelease(playbackGeneration, 0);
};

View File

@@ -50,6 +50,8 @@ test('composeAnilistSetupHandlers returns callable setup handlers', () => {
assert.equal(handled, false);
// handleAnilistSetupProtocolUrl returns true for subminer:// URLs
const handledProtocol = composed.handleAnilistSetupProtocolUrl('subminer://anilist-setup?code=abc');
const handledProtocol = composed.handleAnilistSetupProtocolUrl(
'subminer://anilist-setup?code=abc',
);
assert.equal(handledProtocol, true);
});

View File

@@ -36,8 +36,13 @@ test('composeCliStartupHandlers returns callable CLI startup handlers', () => {
openJellyfinSetupWindow: () => {},
getAnilistQueueStatus: () => ({}) as never,
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'done' }),
generateCharacterDictionary: async () =>
({ zipPath: '/tmp/test.zip', fromCache: false, mediaId: 1, mediaTitle: 'Test', entryCount: 1 }),
generateCharacterDictionary: async () => ({
zipPath: '/tmp/test.zip',
fromCache: false,
mediaId: 1,
mediaTitle: 'Test',
entryCount: 1,
}),
runJellyfinCommand: async () => {},
runStatsCommand: async () => {},
runYoutubePlaybackFlow: async () => {},

View File

@@ -30,9 +30,7 @@ export type CliStartupComposerResult = ComposerOutputs<{
export function composeCliStartupHandlers(
options: CliStartupComposerOptions,
): CliStartupComposerResult {
const createCliCommandContext = createCliCommandContextFactory(
options.cliCommandContextMainDeps,
);
const createCliCommandContext = createCliCommandContextFactory(options.cliCommandContextMainDeps);
const handleCliCommand = createCliCommandRuntimeHandler({
...options.cliCommandRuntimeHandlerMainDeps,
createCliCommandContext: () => createCliCommandContext(),

View File

@@ -8,28 +8,22 @@ type StartupRuntimeHandlers<TCliArgs, TStartupState, TStartupBootstrapRuntimeDep
typeof createStartupRuntimeHandlers<TCliArgs, TStartupState, TStartupBootstrapRuntimeDeps>
>;
export type HeadlessStartupComposerOptions<
TCliArgs,
TStartupState,
TStartupBootstrapRuntimeDeps,
> = ComposerInputs<{
startupRuntimeHandlersDeps: StartupRuntimeHandlersDeps<
TCliArgs,
TStartupState,
TStartupBootstrapRuntimeDeps
>;
}>;
export type HeadlessStartupComposerOptions<TCliArgs, TStartupState, TStartupBootstrapRuntimeDeps> =
ComposerInputs<{
startupRuntimeHandlersDeps: StartupRuntimeHandlersDeps<
TCliArgs,
TStartupState,
TStartupBootstrapRuntimeDeps
>;
}>;
export type HeadlessStartupComposerResult<
TCliArgs,
TStartupState,
TStartupBootstrapRuntimeDeps,
> = ComposerOutputs<
Pick<
StartupRuntimeHandlers<TCliArgs, TStartupState, TStartupBootstrapRuntimeDeps>,
'appLifecycleRuntimeRunner' | 'runAndApplyStartupState'
>
>;
export type HeadlessStartupComposerResult<TCliArgs, TStartupState, TStartupBootstrapRuntimeDeps> =
ComposerOutputs<
Pick<
StartupRuntimeHandlers<TCliArgs, TStartupState, TStartupBootstrapRuntimeDeps>,
'appLifecycleRuntimeRunner' | 'runAndApplyStartupState'
>
>;
export function composeHeadlessStartupHandlers<
TCliArgs,

View File

@@ -4,7 +4,13 @@ import { composeJellyfinRemoteHandlers } from './jellyfin-remote-composer';
test('composeJellyfinRemoteHandlers returns callable jellyfin remote handlers', async () => {
let lastProgressAt = 0;
let activePlayback: unknown = { itemId: 'item-1', mediaSourceId: 'src-1', playMethod: 'DirectPlay', audioStreamIndex: null, subtitleStreamIndex: null };
let activePlayback: unknown = {
itemId: 'item-1',
mediaSourceId: 'src-1',
playMethod: 'DirectPlay',
audioStreamIndex: null,
subtitleStreamIndex: null,
};
const calls: string[] = [];
const composed = composeJellyfinRemoteHandlers({

View File

@@ -85,7 +85,10 @@ function createDefaultMpvFixture() {
updateMpvSubtitleRenderMetricsMainDeps: {
getCurrentMetrics: () => BASE_METRICS,
setCurrentMetrics: () => {},
applyPatch: (current: MpvSubtitleRenderMetrics, patch: Partial<MpvSubtitleRenderMetrics>) => ({
applyPatch: (
current: MpvSubtitleRenderMetrics,
patch: Partial<MpvSubtitleRenderMetrics>,
) => ({
next: { ...current, ...patch },
changed: true,
}),

View File

@@ -58,7 +58,8 @@ export function composeOverlayVisibilityRuntime(
options: OverlayVisibilityRuntimeComposerOptions,
): OverlayVisibilityRuntimeComposerResult {
return {
updateVisibleOverlayVisibility: () => options.overlayVisibilityRuntime.updateVisibleOverlayVisibility(),
updateVisibleOverlayVisibility: () =>
options.overlayVisibilityRuntime.updateVisibleOverlayVisibility(),
restorePreviousSecondarySubVisibility: createRestorePreviousSecondarySubVisibilityHandler(
createBuildRestorePreviousSecondarySubVisibilityMainDepsHandler(
options.restorePreviousSecondarySubVisibilityMainDeps,

View File

@@ -31,7 +31,10 @@ function requireUser(client: DiscordRpcRawClient): DiscordRpcClientUserLike {
export function wrapDiscordRpcClient(client: DiscordRpcRawClient): DiscordRpcClient {
return {
login: () => client.login(),
setActivity: (activity) => requireUser(client).setActivity(activity).then(() => undefined),
setActivity: (activity) =>
requireUser(client)
.setActivity(activity)
.then(() => undefined),
clearActivity: () => requireUser(client).clearActivity(),
destroy: () => client.destroy(),
};
@@ -39,7 +42,12 @@ export function wrapDiscordRpcClient(client: DiscordRpcRawClient): DiscordRpcCli
export function createDiscordRpcClient(
clientId: string,
deps?: { createClient?: (options: { clientId: string; transport: { type: 'ipc' } }) => DiscordRpcRawClient },
deps?: {
createClient?: (options: {
clientId: string;
transport: { type: 'ipc' };
}) => DiscordRpcRawClient;
},
): DiscordRpcClient {
const client =
deps?.createClient?.({ clientId, transport: { type: 'ipc' } }) ??

View File

@@ -173,7 +173,10 @@ test('syncInstalledFirstRunPluginBinaryPath fills blank binary_path for existing
const installPaths = resolveDefaultMpvInstallPaths('linux', homeDir, xdgConfigHome);
fs.mkdirSync(path.dirname(installPaths.pluginConfigPath), { recursive: true });
fs.writeFileSync(installPaths.pluginConfigPath, 'binary_path=\nsocket_path=/tmp/subminer-socket\n');
fs.writeFileSync(
installPaths.pluginConfigPath,
'binary_path=\nsocket_path=/tmp/subminer-socket\n',
);
const result = syncInstalledFirstRunPluginBinaryPath({
platform: 'linux',
@@ -222,3 +225,72 @@ test('syncInstalledFirstRunPluginBinaryPath preserves explicit binary_path overr
);
});
});
test('detectInstalledFirstRunPlugin detects plugin installed in canonical mpv config location on macOS', () => {
withTempDir((root) => {
const homeDir = path.join(root, 'home');
const installPaths = resolveDefaultMpvInstallPaths('darwin', homeDir);
const pluginDir = path.join(homeDir, '.config', 'mpv', 'scripts', 'subminer');
const pluginEntrypointPath = path.join(pluginDir, 'main.lua');
fs.mkdirSync(pluginDir, { recursive: true });
fs.mkdirSync(path.dirname(pluginEntrypointPath), { recursive: true });
fs.writeFileSync(pluginEntrypointPath, '-- plugin');
assert.equal(
detectInstalledFirstRunPlugin(installPaths),
true,
);
});
});
test('detectInstalledFirstRunPlugin ignores scoped plugin layout path', () => {
withTempDir((root) => {
const homeDir = path.join(root, 'home');
const xdgConfigHome = path.join(root, 'xdg');
const installPaths = resolveDefaultMpvInstallPaths('darwin', homeDir, xdgConfigHome);
const pluginDir = path.join(xdgConfigHome, 'mpv', 'scripts', '@plugin', 'subminer');
const pluginEntrypointPath = path.join(pluginDir, 'main.lua');
fs.mkdirSync(pluginDir, { recursive: true });
fs.mkdirSync(path.dirname(pluginEntrypointPath), { recursive: true });
fs.writeFileSync(pluginEntrypointPath, '-- plugin');
assert.equal(
detectInstalledFirstRunPlugin(installPaths),
false,
);
});
});
test('detectInstalledFirstRunPlugin ignores legacy loader file', () => {
withTempDir((root) => {
const homeDir = path.join(root, 'home');
const installPaths = resolveDefaultMpvInstallPaths('darwin', homeDir);
const legacyLoaderPath = path.join(installPaths.scriptsDir, 'subminer.lua');
fs.mkdirSync(path.dirname(legacyLoaderPath), { recursive: true });
fs.writeFileSync(legacyLoaderPath, '-- plugin');
assert.equal(
detectInstalledFirstRunPlugin(installPaths),
false,
);
});
});
test('detectInstalledFirstRunPlugin requires main.lua in subminer directory', () => {
withTempDir((root) => {
const homeDir = path.join(root, 'home');
const installPaths = resolveDefaultMpvInstallPaths('darwin', homeDir);
const pluginDir = path.join(installPaths.scriptsDir, 'subminer');
fs.mkdirSync(pluginDir, { recursive: true });
fs.writeFileSync(path.join(pluginDir, 'not_main.lua'), '-- plugin');
assert.equal(
detectInstalledFirstRunPlugin(installPaths),
false,
);
});
});

View File

@@ -12,14 +12,6 @@ function backupExistingPath(targetPath: string): void {
fs.renameSync(targetPath, `${targetPath}.bak.${timestamp()}`);
}
function resolveLegacyPluginLoaderPath(installPaths: MpvInstallPaths): string {
return path.join(installPaths.scriptsDir, 'subminer.lua');
}
function resolveLegacyPluginDebugLoaderPath(installPaths: MpvInstallPaths): string {
return path.join(installPaths.scriptsDir, 'subminer-loader.lua');
}
function rewriteInstalledWindowsPluginConfig(configPath: string): void {
const content = fs.readFileSync(configPath, 'utf8');
const updated = content.replace(/^socket_path=.*$/m, 'socket_path=\\\\.\\pipe\\subminer-socket');
@@ -99,14 +91,13 @@ export function resolvePackagedFirstRunPluginAssets(deps: {
export function detectInstalledFirstRunPlugin(
installPaths: MpvInstallPaths,
deps?: { existsSync?: (candidate: string) => boolean },
deps?: {
existsSync?: (candidate: string) => boolean;
},
): boolean {
const existsSync = deps?.existsSync ?? fs.existsSync;
return (
existsSync(installPaths.pluginEntrypointPath) &&
existsSync(installPaths.pluginDir) &&
existsSync(installPaths.pluginConfigPath)
);
const pluginEntrypointPath = path.join(installPaths.scriptsDir, 'subminer', 'main.lua');
return existsSync(pluginEntrypointPath);
}
export function installFirstRunPluginToDefaultLocation(options: {
@@ -148,8 +139,8 @@ export function installFirstRunPluginToDefaultLocation(options: {
fs.mkdirSync(installPaths.scriptsDir, { recursive: true });
fs.mkdirSync(installPaths.scriptOptsDir, { recursive: true });
backupExistingPath(resolveLegacyPluginLoaderPath(installPaths));
backupExistingPath(resolveLegacyPluginDebugLoaderPath(installPaths));
backupExistingPath(path.join(installPaths.scriptsDir, 'subminer.lua'));
backupExistingPath(path.join(installPaths.scriptsDir, 'subminer-loader.lua'));
backupExistingPath(installPaths.pluginDir);
backupExistingPath(installPaths.pluginConfigPath);
fs.cpSync(assets.pluginDirSource, installPaths.pluginDir, { recursive: true });
@@ -187,7 +178,10 @@ export function syncInstalledFirstRunPluginBinaryPath(options: {
return { updated: false, configPath: installPaths.pluginConfigPath };
}
const updated = rewriteInstalledPluginBinaryPath(installPaths.pluginConfigPath, options.binaryPath);
const updated = rewriteInstalledPluginBinaryPath(
installPaths.pluginConfigPath,
options.binaryPath,
);
if (options.platform === 'win32') {
rewriteInstalledWindowsPluginConfig(installPaths.pluginConfigPath);
}

View File

@@ -88,7 +88,7 @@ test('setup service auto-completes legacy installs with config and dictionaries'
const service = createFirstRunSetupService({
configDir,
getYomitanDictionaryCount: async () => 2,
detectPluginInstalled: () => false,
detectPluginInstalled: () => true,
installPlugin: async () => ({
ok: true,
pluginInstallStatus: 'installed',
@@ -106,17 +106,18 @@ test('setup service auto-completes legacy installs with config and dictionaries'
});
});
test('setup service requires explicit finish for incomplete installs and supports plugin skip/install', async () => {
test('setup service requires mpv plugin install before finish', async () => {
await withTempDir(async (root) => {
const configDir = path.join(root, 'SubMiner');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
let dictionaryCount = 0;
let pluginInstalled = false;
const service = createFirstRunSetupService({
configDir,
getYomitanDictionaryCount: async () => dictionaryCount,
detectPluginInstalled: () => false,
detectPluginInstalled: () => pluginInstalled,
installPlugin: async () => ({
ok: true,
pluginInstallStatus: 'installed',
@@ -130,13 +131,11 @@ test('setup service requires explicit finish for incomplete installs and support
assert.equal(initial.state.status, 'incomplete');
assert.equal(initial.canFinish, false);
const skipped = await service.skipPluginInstall();
assert.equal(skipped.state.pluginInstallStatus, 'skipped');
const installed = await service.installMpvPlugin();
assert.equal(installed.state.pluginInstallStatus, 'installed');
assert.equal(installed.pluginInstallPathSummary, '/tmp/mpv');
pluginInstalled = true;
dictionaryCount = 1;
const refreshed = await service.refreshStatus();
assert.equal(refreshed.canFinish, true);
@@ -158,7 +157,7 @@ test('setup service allows completion without internal dictionaries when externa
configDir,
getYomitanDictionaryCount: async () => 0,
isExternalYomitanConfigured: () => true,
detectPluginInstalled: () => false,
detectPluginInstalled: () => true,
installPlugin: async () => ({
ok: true,
pluginInstallStatus: 'installed',
@@ -190,7 +189,7 @@ test('setup service does not probe internal dictionaries when external yomitan i
throw new Error('should not probe internal dictionaries in external mode');
},
isExternalYomitanConfigured: () => true,
detectPluginInstalled: () => false,
detectPluginInstalled: () => true,
installPlugin: async () => ({
ok: true,
pluginInstallStatus: 'installed',
@@ -218,7 +217,7 @@ test('setup service reopens when external-yomitan completion later has no extern
configDir,
getYomitanDictionaryCount: async () => 0,
isExternalYomitanConfigured: () => true,
detectPluginInstalled: () => false,
detectPluginInstalled: () => true,
installPlugin: async () => ({
ok: true,
pluginInstallStatus: 'installed',
@@ -235,7 +234,7 @@ test('setup service reopens when external-yomitan completion later has no extern
configDir,
getYomitanDictionaryCount: async () => 0,
isExternalYomitanConfigured: () => false,
detectPluginInstalled: () => false,
detectPluginInstalled: () => true,
installPlugin: async () => ({
ok: true,
pluginInstallStatus: 'installed',
@@ -252,6 +251,48 @@ test('setup service reopens when external-yomitan completion later has no extern
});
});
test('setup service reopens when a completed setup no longer has the mpv plugin installed', async () => {
await withTempDir(async (root) => {
const configDir = path.join(root, 'SubMiner');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
const completedService = createFirstRunSetupService({
configDir,
getYomitanDictionaryCount: async () => 2,
detectPluginInstalled: () => true,
installPlugin: async () => ({
ok: true,
pluginInstallStatus: 'installed',
pluginInstallPathSummary: '/tmp/mpv',
message: 'ok',
}),
onStateChanged: () => undefined,
});
await completedService.ensureSetupStateInitialized();
await completedService.markSetupCompleted();
const service = createFirstRunSetupService({
configDir,
getYomitanDictionaryCount: async () => 2,
detectPluginInstalled: () => false,
installPlugin: async () => ({
ok: true,
pluginInstallStatus: 'installed',
pluginInstallPathSummary: null,
message: 'ok',
}),
onStateChanged: () => undefined,
});
const snapshot = await service.ensureSetupStateInitialized();
assert.equal(snapshot.state.status, 'incomplete');
assert.equal(snapshot.canFinish, false);
assert.equal(snapshot.pluginStatus, 'required');
});
});
test('setup service keeps completed when external-yomitan completion later has internal dictionaries available', async () => {
await withTempDir(async (root) => {
const configDir = path.join(root, 'SubMiner');
@@ -262,7 +303,7 @@ test('setup service keeps completed when external-yomitan completion later has i
configDir,
getYomitanDictionaryCount: async () => 0,
isExternalYomitanConfigured: () => true,
detectPluginInstalled: () => false,
detectPluginInstalled: () => true,
installPlugin: async () => ({
ok: true,
pluginInstallStatus: 'installed',
@@ -279,7 +320,7 @@ test('setup service keeps completed when external-yomitan completion later has i
configDir,
getYomitanDictionaryCount: async () => 2,
isExternalYomitanConfigured: () => false,
detectPluginInstalled: () => false,
detectPluginInstalled: () => true,
installPlugin: async () => ({
ok: true,
pluginInstallStatus: 'installed',
@@ -304,7 +345,7 @@ test('setup service marks cancelled when popup closes before completion', async
const service = createFirstRunSetupService({
configDir,
getYomitanDictionaryCount: async () => 0,
detectPluginInstalled: () => false,
detectPluginInstalled: () => true,
installPlugin: async () => ({
ok: true,
pluginInstallStatus: 'installed',
@@ -331,7 +372,7 @@ test('setup service reflects detected Windows mpv shortcuts before preferences a
platform: 'win32',
configDir,
getYomitanDictionaryCount: async () => 0,
detectPluginInstalled: () => false,
detectPluginInstalled: () => true,
installPlugin: async () => ({
ok: true,
pluginInstallStatus: 'installed',
@@ -364,7 +405,7 @@ test('setup service persists Windows mpv shortcut preferences and status with on
platform: 'win32',
configDir,
getYomitanDictionaryCount: async () => 0,
detectPluginInstalled: () => false,
detectPluginInstalled: () => true,
installPlugin: async () => ({
ok: true,
pluginInstallStatus: 'installed',

View File

@@ -27,7 +27,7 @@ export interface SetupStatusSnapshot {
dictionaryCount: number;
canFinish: boolean;
externalYomitanConfigured: boolean;
pluginStatus: 'installed' | 'optional' | 'skipped' | 'failed';
pluginStatus: 'installed' | 'required' | 'failed';
pluginInstallPathSummary: string | null;
windowsMpvShortcuts: SetupWindowsMpvShortcutSnapshot;
message: string | null;
@@ -48,7 +48,6 @@ export interface FirstRunSetupService {
markSetupInProgress: () => Promise<SetupStatusSnapshot>;
markSetupCancelled: () => Promise<SetupStatusSnapshot>;
markSetupCompleted: () => Promise<SetupStatusSnapshot>;
skipPluginInstall: () => Promise<SetupStatusSnapshot>;
installMpvPlugin: () => Promise<SetupStatusSnapshot>;
configureWindowsMpvShortcuts: (preferences: {
startMenuEnabled: boolean;
@@ -108,9 +107,8 @@ function getPluginStatus(
pluginInstalled: boolean,
): SetupStatusSnapshot['pluginStatus'] {
if (pluginInstalled) return 'installed';
if (state.pluginInstallStatus === 'skipped') return 'skipped';
if (state.pluginInstallStatus === 'failed') return 'failed';
return 'optional';
return 'required';
}
function getWindowsMpvShortcutStatus(
@@ -151,6 +149,24 @@ function isYomitanSetupSatisfied(options: {
return options.externalYomitanConfigured || options.dictionaryCount >= 1;
}
export function getFirstRunSetupCompletionMessage(snapshot: {
configReady: boolean;
dictionaryCount: number;
externalYomitanConfigured: boolean;
pluginStatus: SetupStatusSnapshot['pluginStatus'];
}): string | null {
if (!snapshot.configReady) {
return 'Create or provide the config file before finishing setup.';
}
if (snapshot.pluginStatus !== 'installed') {
return 'Install the mpv plugin before finishing setup.';
}
if (!snapshot.externalYomitanConfigured && snapshot.dictionaryCount < 1) {
return 'Install at least one Yomitan dictionary before finishing setup.';
}
return null;
}
async function resolveYomitanSetupStatus(deps: {
configFilePaths: { jsoncPath: string; jsonPath: string };
getYomitanDictionaryCount: () => Promise<number>;
@@ -230,11 +246,13 @@ export function createFirstRunSetupService(deps: {
return {
configReady,
dictionaryCount,
canFinish: isYomitanSetupSatisfied({
configReady,
dictionaryCount,
externalYomitanConfigured,
}),
canFinish:
pluginInstalled &&
isYomitanSetupSatisfied({
configReady,
dictionaryCount,
externalYomitanConfigured,
}),
externalYomitanConfigured,
pluginStatus: getPluginStatus(state, pluginInstalled),
pluginInstallPathSummary: state.pluginInstallPathSummary,
@@ -272,24 +290,20 @@ export function createFirstRunSetupService(deps: {
getYomitanDictionaryCount: deps.getYomitanDictionaryCount,
isExternalYomitanConfigured: deps.isExternalYomitanConfigured,
});
const yomitanSetupSatisfied = isYomitanSetupSatisfied({
configReady,
dictionaryCount,
externalYomitanConfigured,
});
if (
isSetupCompleted(state) &&
!(
state.yomitanSetupMode === 'external' &&
!externalYomitanConfigured &&
!yomitanSetupSatisfied
)
) {
const pluginInstalled = await deps.detectPluginInstalled();
const canFinish =
pluginInstalled &&
isYomitanSetupSatisfied({
configReady,
dictionaryCount,
externalYomitanConfigured,
});
if (isSetupCompleted(state) && canFinish) {
completed = true;
return refreshWithState(state);
}
if (yomitanSetupSatisfied) {
if (canFinish) {
const completedState = writeState({
...state,
status: 'completed',
@@ -347,8 +361,6 @@ export function createFirstRunSetupService(deps: {
}),
);
},
skipPluginInstall: async () =>
refreshWithState(writeState({ ...readState(), pluginInstallStatus: 'skipped' })),
installMpvPlugin: async () => {
const result = await deps.installPlugin();
return refreshWithState(

View File

@@ -14,8 +14,10 @@ test('buildFirstRunSetupHtml renders macchiato setup actions and disabled finish
dictionaryCount: 0,
canFinish: false,
externalYomitanConfigured: false,
pluginStatus: 'optional',
pluginStatus: 'required',
pluginInstallPathSummary: null,
mpvExecutablePath: '',
mpvExecutablePathStatus: 'blank',
windowsMpvShortcuts: {
supported: false,
startMenuEnabled: true,
@@ -29,6 +31,7 @@ test('buildFirstRunSetupHtml renders macchiato setup actions and disabled finish
assert.match(html, /SubMiner setup/);
assert.match(html, /Install mpv plugin/);
assert.match(html, /Required before SubMiner setup can finish/);
assert.match(html, /Open Yomitan Settings/);
assert.match(html, /Finish setup/);
assert.match(html, /disabled/);
@@ -42,6 +45,8 @@ test('buildFirstRunSetupHtml switches plugin action to reinstall when already in
externalYomitanConfigured: false,
pluginStatus: 'installed',
pluginInstallPathSummary: '/tmp/mpv',
mpvExecutablePath: 'C:\\Program Files\\mpv\\mpv.exe',
mpvExecutablePathStatus: 'configured',
windowsMpvShortcuts: {
supported: true,
startMenuEnabled: true,
@@ -54,6 +59,62 @@ test('buildFirstRunSetupHtml switches plugin action to reinstall when already in
});
assert.match(html, /Reinstall mpv plugin/);
assert.match(html, /mpv executable path/);
assert.match(html, /Leave blank to auto-discover mpv\.exe from PATH\./);
assert.match(html, /aria-label="Path to mpv\.exe"/);
assert.match(
html,
/Finish stays unlocked once the mpv plugin is installed and Yomitan reports at least one installed dictionary\./,
);
});
test('buildFirstRunSetupHtml marks an invalid configured mpv path as invalid', () => {
const html = buildFirstRunSetupHtml({
configReady: true,
dictionaryCount: 1,
canFinish: true,
externalYomitanConfigured: false,
pluginStatus: 'installed',
pluginInstallPathSummary: '/tmp/mpv',
mpvExecutablePath: 'C:\\Broken\\mpv.exe',
mpvExecutablePathStatus: 'invalid',
windowsMpvShortcuts: {
supported: true,
startMenuEnabled: true,
desktopEnabled: true,
startMenuInstalled: false,
desktopInstalled: false,
status: 'optional',
},
message: null,
});
assert.match(html, />Invalid</);
assert.match(html, /Current: C:\\Broken\\mpv\.exe \(invalid; file not found\)/);
});
test('buildFirstRunSetupHtml explains the config blocker when setup is missing config', () => {
const html = buildFirstRunSetupHtml({
configReady: false,
dictionaryCount: 0,
canFinish: false,
externalYomitanConfigured: false,
pluginStatus: 'required',
pluginInstallPathSummary: null,
mpvExecutablePath: '',
mpvExecutablePathStatus: 'blank',
windowsMpvShortcuts: {
supported: false,
startMenuEnabled: true,
desktopEnabled: true,
startMenuInstalled: false,
desktopInstalled: false,
status: 'optional',
},
message: null,
});
assert.match(html, /Create or provide the config file before finishing setup\./);
});
test('buildFirstRunSetupHtml explains external yomitan mode and keeps finish enabled', () => {
@@ -62,8 +123,10 @@ test('buildFirstRunSetupHtml explains external yomitan mode and keeps finish ena
dictionaryCount: 0,
canFinish: true,
externalYomitanConfigured: true,
pluginStatus: 'optional',
pluginStatus: 'installed',
pluginInstallPathSummary: null,
mpvExecutablePath: '',
mpvExecutablePathStatus: 'blank',
windowsMpvShortcuts: {
supported: false,
startMenuEnabled: true,
@@ -83,9 +146,22 @@ test('buildFirstRunSetupHtml explains external yomitan mode and keeps finish ena
});
test('parseFirstRunSetupSubmissionUrl parses supported custom actions', () => {
assert.deepEqual(
parseFirstRunSetupSubmissionUrl(
'subminer://first-run-setup?action=configure-mpv-executable-path&mpvExecutablePath=C%3A%5CApps%5Cmpv%5Cmpv.exe',
),
{
action: 'configure-mpv-executable-path',
mpvExecutablePath: 'C:\\Apps\\mpv\\mpv.exe',
},
);
assert.deepEqual(parseFirstRunSetupSubmissionUrl('subminer://first-run-setup?action=refresh'), {
action: 'refresh',
});
assert.equal(
parseFirstRunSetupSubmissionUrl('subminer://first-run-setup?action=skip-plugin'),
null,
);
assert.equal(parseFirstRunSetupSubmissionUrl('https://example.com'), null);
});
@@ -121,6 +197,25 @@ test('first-run setup navigation handler prevents default and dispatches action'
assert.deepEqual(calls, ['preventDefault', 'install-plugin']);
});
test('first-run setup navigation handler swallows stale custom-scheme actions', () => {
const calls: string[] = [];
const handleNavigation = createHandleFirstRunSetupNavigationHandler({
parseSubmissionUrl: (url) => parseFirstRunSetupSubmissionUrl(url),
handleAction: async (submission) => {
calls.push(submission.action);
},
logError: (message) => calls.push(message),
});
const prevented = handleNavigation({
url: 'subminer://first-run-setup?action=skip-plugin',
preventDefault: () => calls.push('preventDefault'),
});
assert.equal(prevented, true);
assert.deepEqual(calls, ['preventDefault']);
});
test('closing incomplete first-run setup quits app outside background mode', async () => {
const calls: string[] = [];
let closedHandler: (() => void) | undefined;
@@ -146,8 +241,10 @@ test('closing incomplete first-run setup quits app outside background mode', asy
dictionaryCount: 0,
canFinish: false,
externalYomitanConfigured: false,
pluginStatus: 'optional',
pluginStatus: 'required',
pluginInstallPathSummary: null,
mpvExecutablePath: '',
mpvExecutablePathStatus: 'blank',
windowsMpvShortcuts: {
supported: false,
startMenuEnabled: true,

View File

@@ -1,3 +1,5 @@
import { getFirstRunSetupCompletionMessage } from './first-run-setup-service';
type FocusableWindowLike = {
focus: () => void;
};
@@ -15,15 +17,16 @@ type FirstRunSetupWindowLike = FocusableWindowLike & {
};
export type FirstRunSetupAction =
| 'configure-mpv-executable-path'
| 'install-plugin'
| 'configure-windows-mpv-shortcuts'
| 'open-yomitan-settings'
| 'refresh'
| 'skip-plugin'
| 'finish';
export interface FirstRunSetupSubmission {
action: FirstRunSetupAction;
mpvExecutablePath?: string;
startMenuEnabled?: boolean;
desktopEnabled?: boolean;
}
@@ -33,8 +36,10 @@ export interface FirstRunSetupHtmlModel {
dictionaryCount: number;
canFinish: boolean;
externalYomitanConfigured: boolean;
pluginStatus: 'installed' | 'optional' | 'skipped' | 'failed';
pluginStatus: 'installed' | 'required' | 'failed';
pluginInstallPathSummary: string | null;
mpvExecutablePath: string;
mpvExecutablePathStatus: 'blank' | 'configured' | 'invalid';
windowsMpvShortcuts: {
supported: boolean;
startMenuEnabled: boolean;
@@ -64,19 +69,15 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
const pluginLabel =
model.pluginStatus === 'installed'
? 'Installed'
: model.pluginStatus === 'skipped'
? 'Skipped'
: model.pluginStatus === 'failed'
? 'Failed'
: 'Optional';
: model.pluginStatus === 'failed'
? 'Failed'
: 'Required';
const pluginTone =
model.pluginStatus === 'installed'
? 'ready'
: model.pluginStatus === 'failed'
? 'danger'
: model.pluginStatus === 'skipped'
? 'muted'
: 'warn';
: 'warn';
const windowsShortcutLabel =
model.windowsMpvShortcuts.status === 'installed'
? 'Installed'
@@ -93,6 +94,50 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
: model.windowsMpvShortcuts.status === 'skipped'
? 'muted'
: 'warn';
const mpvExecutablePathLabel =
model.mpvExecutablePathStatus === 'configured'
? 'Configured'
: model.mpvExecutablePathStatus === 'invalid'
? 'Invalid'
: 'Blank';
const mpvExecutablePathTone =
model.mpvExecutablePathStatus === 'configured'
? 'ready'
: model.mpvExecutablePathStatus === 'invalid'
? 'danger'
: 'muted';
const mpvExecutablePathCurrent =
model.mpvExecutablePathStatus === 'blank'
? 'blank (PATH discovery)'
: model.mpvExecutablePathStatus === 'invalid'
? `${model.mpvExecutablePath} (invalid; file not found)`
: model.mpvExecutablePath;
const mpvExecutablePathCard = model.windowsMpvShortcuts.supported
? `
<div class="card block">
<div class="card-head">
<div>
<strong>mpv executable path</strong>
<div class="meta">Leave blank to auto-discover mpv.exe from PATH.</div>
<div class="meta">Current: ${escapeHtml(mpvExecutablePathCurrent)}</div>
</div>
${renderStatusBadge(mpvExecutablePathLabel, mpvExecutablePathTone)}
</div>
<form
class="path-form"
onsubmit="event.preventDefault(); const params = new URLSearchParams({ action: 'configure-mpv-executable-path', mpvExecutablePath: document.getElementById('mpv-executable-path').value }); window.location.href = 'subminer://first-run-setup?' + params.toString();"
>
<input
id="mpv-executable-path"
type="text"
aria-label="Path to mpv.exe"
value="${escapeHtml(model.mpvExecutablePath)}"
placeholder="C:\\Program Files\\mpv\\mpv.exe"
/>
<button type="submit">Save mpv executable path</button>
</form>
</div>`
: '';
const windowsShortcutCard = model.windowsMpvShortcuts.supported
? `
<div class="card block">
@@ -128,9 +173,14 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
: model.dictionaryCount >= 1
? 'ready'
: 'warn';
const footerMessage = model.externalYomitanConfigured
? 'Finish stays unlocked while SubMiner is reusing an external Yomitan profile. If you later launch without yomitan.externalProfilePath, setup will require at least one internal dictionary.'
: 'Finish stays locked until Yomitan reports at least one installed dictionary.';
const blockerMessage = getFirstRunSetupCompletionMessage(model);
const footerMessage = blockerMessage
? blockerMessage
: model.canFinish
? model.externalYomitanConfigured
? 'Finish stays unlocked while SubMiner is reusing an external Yomitan profile. If you later launch without yomitan.externalProfilePath, setup will require at least one internal dictionary.'
: 'Finish stays unlocked once the mpv plugin is installed and Yomitan reports at least one installed dictionary.'
: 'Finish stays locked until the mpv plugin is installed and Yomitan reports at least one installed dictionary.';
return `<!doctype html>
<html>
@@ -216,6 +266,24 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
.badge.warn { background: rgba(238, 212, 159, 0.18); color: var(--yellow); }
.badge.muted { background: rgba(184, 192, 224, 0.12); color: var(--muted); }
.badge.danger { background: rgba(237, 135, 150, 0.16); color: var(--red); }
.path-form {
display: grid;
gap: 8px;
margin-top: 12px;
}
.path-form input[type='text'] {
width: 100%;
box-sizing: border-box;
border: 1px solid rgba(202, 211, 245, 0.12);
border-radius: 10px;
padding: 9px 10px;
color: var(--text);
background: rgba(30, 32, 48, 0.72);
font: inherit;
}
.path-form input[type='text']::placeholder {
color: rgba(184, 192, 224, 0.65);
}
.actions {
display: grid;
grid-template-columns: 1fr 1fr;
@@ -269,6 +337,7 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
<div>
<strong>mpv plugin</strong>
<div class="meta">${escapeHtml(model.pluginInstallPathSummary ?? 'Default mpv scripts location')}</div>
<div class="meta">Required before SubMiner setup can finish.</div>
</div>
${renderStatusBadge(pluginLabel, pluginTone)}
</div>
@@ -279,12 +348,12 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
</div>
${renderStatusBadge(yomitanBadgeLabel, yomitanBadgeTone)}
</div>
${mpvExecutablePathCard}
${windowsShortcutCard}
<div class="actions">
<button onclick="window.location.href='subminer://first-run-setup?action=install-plugin'">${pluginActionLabel}</button>
<button onclick="window.location.href='subminer://first-run-setup?action=open-yomitan-settings'">Open Yomitan Settings</button>
<button class="ghost" onclick="window.location.href='subminer://first-run-setup?action=refresh'">Refresh status</button>
<button class="ghost" onclick="window.location.href='subminer://first-run-setup?action=skip-plugin'">Skip plugin</button>
<button class="primary" ${model.canFinish ? '' : 'disabled'} onclick="window.location.href='subminer://first-run-setup?action=finish'">Finish setup</button>
</div>
<div class="message">${model.message ? escapeHtml(model.message) : ''}</div>
@@ -301,15 +370,21 @@ export function parseFirstRunSetupSubmissionUrl(rawUrl: string): FirstRunSetupSu
const parsed = new URL(rawUrl);
const action = parsed.searchParams.get('action');
if (
action !== 'configure-mpv-executable-path' &&
action !== 'install-plugin' &&
action !== 'configure-windows-mpv-shortcuts' &&
action !== 'open-yomitan-settings' &&
action !== 'refresh' &&
action !== 'skip-plugin' &&
action !== 'finish'
) {
return null;
}
if (action === 'configure-mpv-executable-path') {
return {
action,
mpvExecutablePath: parsed.searchParams.get('mpvExecutablePath') ?? '',
};
}
if (action === 'configure-windows-mpv-shortcuts') {
return {
action,
@@ -337,9 +412,18 @@ export function createHandleFirstRunSetupNavigationHandler(deps: {
logError: (message: string, error: unknown) => void;
}) {
return (params: { url: string; preventDefault: () => void }): boolean => {
const submission = deps.parseSubmissionUrl(params.url);
if (!submission) return false;
if (!params.url.startsWith('subminer://first-run-setup')) {
params.preventDefault();
return true;
}
params.preventDefault();
let submission: FirstRunSetupSubmission | null;
try {
submission = deps.parseSubmissionUrl(params.url);
} catch {
return true;
}
if (!submission) return true;
void deps.handleAction(submission).catch((error) => {
deps.logError('Failed handling first-run setup action', error);
});

View File

@@ -0,0 +1,77 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createManagedLocalSubtitleSelectionRuntime,
resolveManagedLocalSubtitleSelection,
} from './local-subtitle-selection';
const mixedLanguageTrackList = [
{ type: 'sub', id: 1, lang: 'pt', title: '[Infinite]', external: false, selected: true },
{ type: 'sub', id: 2, lang: 'pt', title: '[Moshi Moshi]', external: false },
{ type: 'sub', id: 3, lang: 'en', title: '(Vivid)', external: false },
{ type: 'sub', id: 9, lang: 'en', title: 'English(US)', external: false },
{ type: 'sub', id: 11, lang: 'en', title: 'en.srt', external: true },
{ type: 'sub', id: 12, lang: 'ja', title: 'ja.srt', external: true },
];
test('resolveManagedLocalSubtitleSelection prefers default Japanese primary and English secondary tracks', () => {
const result = resolveManagedLocalSubtitleSelection({
trackList: mixedLanguageTrackList,
primaryLanguages: [],
secondaryLanguages: [],
});
assert.equal(result.primaryTrackId, 12);
assert.equal(result.secondaryTrackId, 11);
});
test('resolveManagedLocalSubtitleSelection respects configured language overrides', () => {
const result = resolveManagedLocalSubtitleSelection({
trackList: mixedLanguageTrackList,
primaryLanguages: ['pt'],
secondaryLanguages: ['ja'],
});
assert.equal(result.primaryTrackId, 1);
assert.equal(result.secondaryTrackId, 12);
});
test('managed local subtitle selection runtime applies preferred tracks once for a local media path', async () => {
const commands: Array<Array<string | number>> = [];
const scheduled: Array<() => void> = [];
const runtime = createManagedLocalSubtitleSelectionRuntime({
getCurrentMediaPath: () => '/videos/example.mkv',
getMpvClient: () =>
({
connected: true,
requestProperty: async (name: string) => {
if (name === 'track-list') {
return mixedLanguageTrackList;
}
throw new Error(`Unexpected property: ${name}`);
},
}) as never,
getPrimarySubtitleLanguages: () => [],
getSecondarySubtitleLanguages: () => [],
sendMpvCommand: (command) => {
commands.push(command);
},
schedule: (callback) => {
scheduled.push(callback);
return 1 as never;
},
clearScheduled: () => {},
});
runtime.handleMediaPathChange('/videos/example.mkv');
scheduled.shift()?.();
await new Promise((resolve) => setTimeout(resolve, 0));
runtime.handleSubtitleTrackListChange(mixedLanguageTrackList);
assert.deepEqual(commands, [
['set_property', 'sid', 12],
['set_property', 'secondary-sid', 11],
]);
});

View File

@@ -0,0 +1,263 @@
import path from 'node:path';
import { isRemoteMediaPath } from '../../jimaku/utils';
import { normalizeYoutubeLangCode } from '../../core/services/youtube/labels';
const DEFAULT_PRIMARY_SUBTITLE_LANGUAGES = ['ja', 'jpn'];
const DEFAULT_SECONDARY_SUBTITLE_LANGUAGES = ['en', 'eng', 'english', 'enus', 'en-us'];
const HEARING_IMPAIRED_PATTERN = /\b(hearing impaired|sdh|closed captions?|cc)\b/i;
type SubtitleTrackLike = {
type?: unknown;
id?: unknown;
lang?: unknown;
title?: unknown;
external?: unknown;
selected?: unknown;
};
type NormalizedSubtitleTrack = {
id: number;
lang: string;
title: string;
external: boolean;
selected: boolean;
};
export type ManagedLocalSubtitleSelection = {
primaryTrackId: number | null;
secondaryTrackId: number | null;
hasPrimaryMatch: boolean;
hasSecondaryMatch: boolean;
};
function parseTrackId(value: unknown): number | null {
if (typeof value === 'number' && Number.isInteger(value)) {
return value;
}
if (typeof value === 'string') {
const parsed = Number(value.trim());
return Number.isInteger(parsed) ? parsed : null;
}
return null;
}
function normalizeTrack(entry: unknown): NormalizedSubtitleTrack | null {
if (!entry || typeof entry !== 'object') {
return null;
}
const track = entry as SubtitleTrackLike;
const id = parseTrackId(track.id);
if (id === null || (track.type !== undefined && track.type !== 'sub')) {
return null;
}
return {
id,
lang: String(track.lang || '').trim(),
title: String(track.title || '').trim(),
external: track.external === true,
selected: track.selected === true,
};
}
function normalizeLanguageList(values: string[], fallback: string[]): string[] {
const normalized = values
.map((value) => normalizeYoutubeLangCode(value))
.filter((value, index, items) => value.length > 0 && items.indexOf(value) === index);
if (normalized.length > 0) {
return normalized;
}
return fallback
.map((value) => normalizeYoutubeLangCode(value))
.filter((value, index, items) => value.length > 0 && items.indexOf(value) === index);
}
function resolveLanguageRank(language: string, preferredLanguages: string[]): number {
const normalized = normalizeYoutubeLangCode(language);
if (!normalized) {
return Number.POSITIVE_INFINITY;
}
const directIndex = preferredLanguages.indexOf(normalized);
if (directIndex >= 0) {
return directIndex;
}
const base = normalized.split('-')[0] || normalized;
const baseIndex = preferredLanguages.indexOf(base);
return baseIndex >= 0 ? baseIndex : Number.POSITIVE_INFINITY;
}
function isLikelyHearingImpaired(title: string): boolean {
return HEARING_IMPAIRED_PATTERN.test(title);
}
function pickBestTrackId(
tracks: NormalizedSubtitleTrack[],
preferredLanguages: string[],
excludeId: number | null = null,
): { trackId: number | null; hasMatch: boolean } {
const ranked = tracks
.filter((track) => track.id !== excludeId)
.map((track) => ({
track,
languageRank: resolveLanguageRank(track.lang, preferredLanguages),
}))
.filter(({ languageRank }) => Number.isFinite(languageRank))
.sort((left, right) => {
if (left.languageRank !== right.languageRank) {
return left.languageRank - right.languageRank;
}
if (left.track.external !== right.track.external) {
return left.track.external ? -1 : 1;
}
if (
isLikelyHearingImpaired(left.track.title) !== isLikelyHearingImpaired(right.track.title)
) {
return isLikelyHearingImpaired(left.track.title) ? 1 : -1;
}
if (/\bdefault\b/i.test(left.track.title) !== /\bdefault\b/i.test(right.track.title)) {
return /\bdefault\b/i.test(left.track.title) ? -1 : 1;
}
return left.track.id - right.track.id;
});
return {
trackId: ranked[0]?.track.id ?? null,
hasMatch: ranked.length > 0,
};
}
export function resolveManagedLocalSubtitleSelection(input: {
trackList: unknown[] | null;
primaryLanguages: string[];
secondaryLanguages: string[];
}): ManagedLocalSubtitleSelection {
const tracks = Array.isArray(input.trackList)
? input.trackList
.map(normalizeTrack)
.filter((track): track is NormalizedSubtitleTrack => track !== null)
: [];
const preferredPrimaryLanguages = normalizeLanguageList(
input.primaryLanguages,
DEFAULT_PRIMARY_SUBTITLE_LANGUAGES,
);
const preferredSecondaryLanguages = normalizeLanguageList(
input.secondaryLanguages,
DEFAULT_SECONDARY_SUBTITLE_LANGUAGES,
);
const primary = pickBestTrackId(tracks, preferredPrimaryLanguages);
const secondary = pickBestTrackId(tracks, preferredSecondaryLanguages, primary.trackId);
return {
primaryTrackId: primary.trackId,
secondaryTrackId: secondary.trackId,
hasPrimaryMatch: primary.hasMatch,
hasSecondaryMatch: secondary.hasMatch,
};
}
function normalizeLocalMediaPath(mediaPath: string | null | undefined): string | null {
if (typeof mediaPath !== 'string') {
return null;
}
const trimmed = mediaPath.trim();
if (!trimmed || isRemoteMediaPath(trimmed)) {
return null;
}
return path.resolve(trimmed);
}
export function createManagedLocalSubtitleSelectionRuntime(deps: {
getCurrentMediaPath: () => string | null;
getMpvClient: () => {
connected?: boolean;
requestProperty?: (name: string) => Promise<unknown>;
} | null;
getPrimarySubtitleLanguages: () => string[];
getSecondarySubtitleLanguages: () => string[];
sendMpvCommand: (command: ['set_property', 'sid' | 'secondary-sid', number]) => void;
schedule: (callback: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
clearScheduled: (timer: ReturnType<typeof setTimeout>) => void;
delayMs?: number;
}) {
const delayMs = deps.delayMs ?? 400;
let currentMediaPath: string | null = null;
let appliedMediaPath: string | null = null;
let pendingTimer: ReturnType<typeof setTimeout> | null = null;
const clearPendingTimer = (): void => {
if (!pendingTimer) {
return;
}
deps.clearScheduled(pendingTimer);
pendingTimer = null;
};
const maybeApplySelection = (trackList: unknown[] | null): void => {
if (!currentMediaPath || appliedMediaPath === currentMediaPath) {
return;
}
const selection = resolveManagedLocalSubtitleSelection({
trackList,
primaryLanguages: deps.getPrimarySubtitleLanguages(),
secondaryLanguages: deps.getSecondarySubtitleLanguages(),
});
if (!selection.hasPrimaryMatch && !selection.hasSecondaryMatch) {
return;
}
if (selection.primaryTrackId !== null) {
deps.sendMpvCommand(['set_property', 'sid', selection.primaryTrackId]);
}
if (selection.secondaryTrackId !== null) {
deps.sendMpvCommand(['set_property', 'secondary-sid', selection.secondaryTrackId]);
}
appliedMediaPath = currentMediaPath;
clearPendingTimer();
};
const refreshFromMpv = async (): Promise<void> => {
const client = deps.getMpvClient();
if (!client?.connected || !client.requestProperty) {
return;
}
const mediaPath = normalizeLocalMediaPath(deps.getCurrentMediaPath());
if (!mediaPath || mediaPath !== currentMediaPath) {
return;
}
try {
const trackList = await client.requestProperty('track-list');
maybeApplySelection(Array.isArray(trackList) ? trackList : null);
} catch {
// Skip selection when mpv track inspection fails.
}
};
const scheduleRefresh = (): void => {
clearPendingTimer();
if (!currentMediaPath || appliedMediaPath === currentMediaPath) {
return;
}
pendingTimer = deps.schedule(() => {
pendingTimer = null;
void refreshFromMpv();
}, delayMs);
};
return {
handleMediaPathChange: (mediaPath: string | null | undefined): void => {
const normalizedPath = normalizeLocalMediaPath(mediaPath);
if (normalizedPath !== currentMediaPath) {
appliedMediaPath = null;
}
currentMediaPath = normalizedPath;
if (!currentMediaPath) {
clearPendingTimer();
return;
}
scheduleRefresh();
},
handleSubtitleTrackListChange: (trackList: unknown[] | null): void => {
maybeApplySelection(trackList);
},
};
}

View File

@@ -24,15 +24,22 @@ export type PlaylistBrowserIpcRuntime = {
export function createPlaylistBrowserIpcRuntime(
getMpvClient: PlaylistBrowserRuntimeDeps['getMpvClient'],
options?: Pick<
PlaylistBrowserRuntimeDeps,
'getPrimarySubtitleLanguages' | 'getSecondarySubtitleLanguages'
>,
): PlaylistBrowserIpcRuntime {
const playlistBrowserRuntimeDeps: PlaylistBrowserRuntimeDeps = {
getMpvClient,
getPrimarySubtitleLanguages: options?.getPrimarySubtitleLanguages,
getSecondarySubtitleLanguages: options?.getSecondarySubtitleLanguages,
};
return {
playlistBrowserRuntimeDeps,
playlistBrowserMainDeps: {
getPlaylistBrowserSnapshot: () => getPlaylistBrowserSnapshotRuntime(playlistBrowserRuntimeDeps),
getPlaylistBrowserSnapshot: () =>
getPlaylistBrowserSnapshotRuntime(playlistBrowserRuntimeDeps),
appendPlaylistBrowserFile: (filePath: string) =>
appendPlaylistBrowserFileRuntime(playlistBrowserRuntimeDeps, filePath),
playPlaylistBrowserIndex: (index: number) =>

View File

@@ -102,7 +102,8 @@ function createFakeMpvClient(options: {
if (removingCurrent) {
syncFlags();
this.currentVideoPath =
playlist.find((item) => item.current || item.playing)?.filename ?? this.currentVideoPath;
playlist.find((item) => item.current || item.playing)?.filename ??
this.currentVideoPath;
}
return true;
}
@@ -125,6 +126,17 @@ function createFakeMpvClient(options: {
};
}
function createDeferred<T>(): {
promise: Promise<T>;
resolve: (value: T) => void;
} {
let resolve!: (value: T) => void;
const promise = new Promise<T>((settle) => {
resolve = settle;
});
return { promise, resolve };
}
test('getPlaylistBrowserSnapshotRuntime lists sibling videos in best-effort episode order', async (t) => {
const dir = createTempVideoDir(t);
const episode2 = path.join(dir, 'Show - S01E02.mkv');
@@ -265,8 +277,12 @@ test('playlist-browser mutation runtimes mutate queue and return refreshed snaps
['set_property', 'sub-auto', 'fuzzy'],
['playlist-play-index', 1],
]);
assert.deepEqual(scheduled.map((entry) => entry.delayMs), [400]);
assert.deepEqual(
scheduled.map((entry) => entry.delayMs),
[400],
);
scheduled[0]?.callback();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(mpvClient.getCommands().slice(-2), [
['set_property', 'sid', 'auto'],
['set_property', 'secondary-sid', 'auto'],
@@ -370,10 +386,7 @@ test('movePlaylistBrowserIndexRuntime rejects top and bottom boundary moves', as
const mpvClient = createFakeMpvClient({
currentVideoPath: episode1,
playlist: [
{ filename: episode1, current: true },
{ filename: episode2 },
],
playlist: [{ filename: episode1, current: true }, { filename: episode2 }],
});
const deps = {
@@ -472,16 +485,130 @@ test('playPlaylistBrowserIndexRuntime ignores superseded local subtitle rearm ca
scheduled[0]?.();
scheduled[1]?.();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(
mpvClient.getCommands().slice(-6),
[
['set_property', 'sub-auto', 'fuzzy'],
['playlist-play-index', 1],
['set_property', 'sub-auto', 'fuzzy'],
['playlist-play-index', 2],
['set_property', 'sid', 'auto'],
['set_property', 'secondary-sid', 'auto'],
],
);
assert.deepEqual(mpvClient.getCommands().slice(-6), [
['set_property', 'sub-auto', 'fuzzy'],
['playlist-play-index', 1],
['set_property', 'sub-auto', 'fuzzy'],
['playlist-play-index', 2],
['set_property', 'sid', 'auto'],
['set_property', 'secondary-sid', 'auto'],
]);
});
test('playPlaylistBrowserIndexRuntime aborts stale async subtitle rearm work', async (t) => {
const dir = createTempVideoDir(t);
const episode1 = path.join(dir, 'Show - S01E01.mkv');
const episode2 = path.join(dir, 'Show - S01E02.mkv');
fs.writeFileSync(episode1, '');
fs.writeFileSync(episode2, '');
const firstTrackList = createDeferred<unknown>();
const secondTrackList = createDeferred<unknown>();
let trackListRequestCount = 0;
const mpvClient = createFakeMpvClient({
currentVideoPath: episode1,
playlist: [
{ filename: episode1, current: true, title: 'Episode 1' },
{ filename: episode2, title: 'Episode 2' },
],
});
const requestProperty = mpvClient.requestProperty.bind(mpvClient);
mpvClient.requestProperty = async (name: string): Promise<unknown> => {
if (name === 'track-list') {
trackListRequestCount += 1;
return trackListRequestCount === 1 ? firstTrackList.promise : secondTrackList.promise;
}
return requestProperty(name);
};
const scheduled: Array<() => void> = [];
const deps = {
getMpvClient: () => mpvClient,
schedule: (callback: () => void) => {
scheduled.push(callback);
},
};
const firstPlay = await playPlaylistBrowserIndexRuntime(deps, 1);
assert.equal(firstPlay.ok, true);
scheduled[0]?.();
const secondPlay = await playPlaylistBrowserIndexRuntime(deps, 1);
assert.equal(secondPlay.ok, true);
scheduled[1]?.();
secondTrackList.resolve([
{ type: 'sub', id: 21, lang: 'ja', title: 'Japanese', external: false, selected: true },
{ type: 'sub', id: 22, lang: 'en', title: 'English', external: false },
]);
await new Promise((resolve) => setTimeout(resolve, 0));
firstTrackList.resolve([
{ type: 'sub', id: 11, lang: 'ja', title: 'Japanese', external: false, selected: true },
{ type: 'sub', id: 12, lang: 'en', title: 'English', external: false },
]);
await new Promise((resolve) => setTimeout(resolve, 0));
const subtitleCommands = mpvClient
.getCommands()
.filter(
(command) =>
command[0] === 'set_property' && (command[1] === 'sid' || command[1] === 'secondary-sid'),
);
assert.deepEqual(subtitleCommands, [
['set_property', 'sid', 21],
['set_property', 'secondary-sid', 22],
]);
});
test('playlist-browser playback reapplies configured preferred subtitle tracks when track metadata is available', async (t) => {
const dir = createTempVideoDir(t);
const episode1 = path.join(dir, 'Show - S01E01.mkv');
const episode2 = path.join(dir, 'Show - S01E02.mkv');
fs.writeFileSync(episode1, '');
fs.writeFileSync(episode2, '');
const mpvClient = createFakeMpvClient({
currentVideoPath: episode1,
playlist: [
{ filename: episode1, current: true, title: 'Episode 1' },
{ filename: episode2, title: 'Episode 2' },
],
});
const requestProperty = mpvClient.requestProperty.bind(mpvClient);
mpvClient.requestProperty = async (name: string): Promise<unknown> => {
if (name === 'track-list') {
return [
{ type: 'sub', id: 1, lang: 'pt', title: '[Infinite]', external: false, selected: true },
{ type: 'sub', id: 3, lang: 'en', title: 'English', external: false },
{ type: 'sub', id: 11, lang: 'en', title: 'en.srt', external: true },
{ type: 'sub', id: 12, lang: 'ja', title: 'ja.srt', external: true },
];
}
return requestProperty(name);
};
const scheduled: Array<() => void> = [];
const deps = {
getMpvClient: () => mpvClient,
getPrimarySubtitleLanguages: () => [],
getSecondarySubtitleLanguages: () => [],
schedule: (callback: () => void) => {
scheduled.push(callback);
},
};
const result = await playPlaylistBrowserIndexRuntime(deps, 1);
assert.equal(result.ok, true);
scheduled[0]?.();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(mpvClient.getCommands().slice(-2), [
['set_property', 'sid', 12],
['set_property', 'secondary-sid', 11],
]);
});

View File

@@ -8,6 +8,7 @@ import type {
} from '../../types';
import { isRemoteMediaPath } from '../../jimaku/utils';
import { hasVideoExtension } from '../../shared/video-extensions';
import { resolveManagedLocalSubtitleSelection } from './local-subtitle-selection';
import { sortPlaylistBrowserDirectoryItems } from './playlist-browser-sort';
type PlaylistLike = {
@@ -28,6 +29,8 @@ type MpvPlaylistBrowserClientLike = {
export type PlaylistBrowserRuntimeDeps = {
getMpvClient: () => MpvPlaylistBrowserClientLike | null;
schedule?: (callback: () => void, delayMs: number) => void;
getPrimarySubtitleLanguages?: () => string[];
getSecondarySubtitleLanguages?: () => string[];
};
const pendingLocalSubtitleSelectionRearms = new WeakMap<MpvPlaylistBrowserClientLike, number>();
@@ -60,7 +63,10 @@ async function resolveCurrentFilePath(
function resolveDirectorySnapshot(
currentFilePath: string | null,
): Pick<PlaylistBrowserSnapshot, 'directoryAvailable' | 'directoryItems' | 'directoryPath' | 'directoryStatus'> {
): Pick<
PlaylistBrowserSnapshot,
'directoryAvailable' | 'directoryItems' | 'directoryPath' | 'directoryStatus'
> {
if (!currentFilePath) {
return {
directoryAvailable: false,
@@ -229,9 +235,30 @@ async function buildMutationResult(
};
}
function rearmLocalSubtitleSelection(client: MpvPlaylistBrowserClientLike): void {
client.send({ command: ['set_property', 'sid', 'auto'] });
client.send({ command: ['set_property', 'secondary-sid', 'auto'] });
async function rearmLocalSubtitleSelection(
client: MpvPlaylistBrowserClientLike,
deps: PlaylistBrowserRuntimeDeps,
expectedPath: string,
token: number,
): Promise<void> {
const trackList = await readProperty(client, 'track-list');
if (pendingLocalSubtitleSelectionRearms.get(client) !== token) {
return;
}
const currentPath = trimToNull(client.currentVideoPath);
if (currentPath && path.resolve(currentPath) !== expectedPath) {
return;
}
pendingLocalSubtitleSelectionRearms.delete(client);
const selection = resolveManagedLocalSubtitleSelection({
trackList: Array.isArray(trackList) ? trackList : null,
primaryLanguages: deps.getPrimarySubtitleLanguages?.() ?? [],
secondaryLanguages: deps.getSecondarySubtitleLanguages?.() ?? [],
});
client.send({ command: ['set_property', 'sid', selection.primaryTrackId ?? 'auto'] });
client.send({
command: ['set_property', 'secondary-sid', selection.secondaryTrackId ?? 'auto'],
});
}
function prepareLocalSubtitleAutoload(client: MpvPlaylistBrowserClientLike): void {
@@ -253,12 +280,7 @@ function scheduleLocalSubtitleSelectionRearm(
pendingLocalSubtitleSelectionRearms.set(client, nextToken);
(deps.schedule ?? setTimeout)(() => {
if (pendingLocalSubtitleSelectionRearms.get(client) !== nextToken) return;
pendingLocalSubtitleSelectionRearms.delete(client);
const currentPath = trimToNull(client.currentVideoPath);
if (currentPath && path.resolve(currentPath) !== expectedPath) {
return;
}
rearmLocalSubtitleSelection(client);
void rearmLocalSubtitleSelection(client, deps, expectedPath, nextToken);
}, 400);
}

View File

@@ -12,7 +12,7 @@ function createDeps(overrides: Partial<WindowsMpvLaunchDeps> = {}): WindowsMpvLa
getEnv: () => undefined,
runWhere: () => ({ status: 1, stdout: '' }),
fileExists: () => false,
spawnDetached: () => undefined,
spawnDetached: async () => undefined,
showError: () => undefined,
...overrides,
};
@@ -29,6 +29,19 @@ test('resolveWindowsMpvPath prefers SUBMINER_MPV_PATH', () => {
assert.equal(resolved, 'C:\\mpv\\mpv.exe');
});
test('resolveWindowsMpvPath prefers configured executable path before PATH', () => {
const resolved = resolveWindowsMpvPath(
createDeps({
getEnv: () => undefined,
runWhere: () => ({ status: 0, stdout: 'C:\\tools\\mpv.exe\r\n' }),
fileExists: (candidate) => candidate === 'C:\\mpv\\mpv.exe',
}),
' C:\\mpv\\mpv.exe ',
);
assert.equal(resolved, 'C:\\mpv\\mpv.exe');
});
test('resolveWindowsMpvPath falls back to where.exe output', () => {
const resolved = resolveWindowsMpvPath(
createDeps({
@@ -40,18 +53,118 @@ test('resolveWindowsMpvPath falls back to where.exe output', () => {
assert.equal(resolved, 'C:\\tools\\mpv.exe');
});
test('buildWindowsMpvLaunchArgs keeps pseudo-gui profile and targets', () => {
assert.deepEqual(buildWindowsMpvLaunchArgs(['C:\\a.mkv', 'C:\\b.mkv']), [
'--player-operation-mode=pseudo-gui',
'--profile=subminer',
'C:\\a.mkv',
'C:\\b.mkv',
]);
test('buildWindowsMpvLaunchArgs uses explicit SubMiner defaults and targets', () => {
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',
'--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('launchWindowsMpv reports missing mpv path', () => {
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('buildWindowsMpvLaunchArgs mirrors a custom input-ipc-server into script opts', () => {
assert.deepEqual(
buildWindowsMpvLaunchArgs(
['C:\\video.mkv'],
['--input-ipc-server', '\\\\.\\pipe\\custom-subminer-socket'],
'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\\custom-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\\custom-subminer-socket',
'--input-ipc-server',
'\\\\.\\pipe\\custom-subminer-socket',
'C:\\video.mkv',
],
);
});
test('buildWindowsMpvLaunchArgs includes socket script opts when plugin entrypoint is present without binary path', () => {
assert.deepEqual(
buildWindowsMpvLaunchArgs(
['C:\\video.mkv'],
['--input-ipc-server', '\\\\.\\pipe\\custom-subminer-socket'],
undefined,
'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\\custom-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-socket_path=\\\\.\\pipe\\custom-subminer-socket',
'--input-ipc-server',
'\\\\.\\pipe\\custom-subminer-socket',
'C:\\video.mkv',
],
);
});
test('launchWindowsMpv reports missing mpv path', async () => {
const errors: string[] = [];
const result = launchWindowsMpv(
const result = await launchWindowsMpv(
[],
createDeps({
showError: (_title, content) => errors.push(content),
@@ -60,39 +173,42 @@ test('launchWindowsMpv reports missing mpv path', () => {
assert.equal(result.ok, false);
assert.equal(result.mpvPath, '');
assert.match(errors[0] ?? '', /Could not find mpv\.exe/i);
assert.match(errors[0] ?? '', /mpv\.executablePath/i);
});
test('launchWindowsMpv spawns detached mpv with targets', () => {
test('launchWindowsMpv spawns detached mpv with targets', async () => {
const calls: string[] = [];
const result = launchWindowsMpv(
const result = await launchWindowsMpv(
['C:\\video.mkv'],
createDeps({
getEnv: (name) => (name === 'SUBMINER_MPV_PATH' ? 'C:\\mpv\\mpv.exe' : undefined),
fileExists: (candidate) => candidate === 'C:\\mpv\\mpv.exe',
spawnDetached: (command, args) => {
spawnDetached: async (command, args) => {
calls.push(command);
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|--profile=subminer|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',
]);
});
test('launchWindowsMpv reports spawn failures with path context', () => {
test('launchWindowsMpv reports spawn failures with path context', async () => {
const errors: string[] = [];
const result = launchWindowsMpv(
const result = await launchWindowsMpv(
[],
createDeps({
getEnv: (name) => (name === 'SUBMINER_MPV_PATH' ? 'C:\\mpv\\mpv.exe' : undefined),
fileExists: (candidate) => candidate === 'C:\\mpv\\mpv.exe',
spawnDetached: () => {
spawnDetached: async () => {
throw new Error('spawn failed');
},
showError: (_title, content) => errors.push(content),
@@ -104,3 +220,21 @@ test('launchWindowsMpv reports spawn failures with path context', () => {
assert.match(errors[0] ?? '', /Failed to launch mpv/i);
assert.match(errors[0] ?? '', /C:\\mpv\\mpv\.exe/i);
});
test('launchWindowsMpv reports async spawn failures with path context', async () => {
const errors: string[] = [];
const result = await launchWindowsMpv(
[],
createDeps({
getEnv: (name) => (name === 'SUBMINER_MPV_PATH' ? 'C:\\mpv\\mpv.exe' : undefined),
fileExists: (candidate) => candidate === 'C:\\mpv\\mpv.exe',
spawnDetached: () => Promise.reject(new Error('async spawn failed')),
showError: (_title, content) => errors.push(content),
}),
);
assert.equal(result.ok, false);
assert.equal(result.mpvPath, 'C:\\mpv\\mpv.exe');
assert.match(errors[0] ?? '', /Failed to launch mpv/i);
assert.match(errors[0] ?? '', /async spawn failed/i);
});

View File

@@ -5,15 +5,45 @@ export interface WindowsMpvLaunchDeps {
getEnv: (name: string) => string | undefined;
runWhere: () => { status: number | null; stdout: string; error?: Error };
fileExists: (candidate: string) => boolean;
spawnDetached: (command: string, args: string[]) => void;
spawnDetached: (command: string, args: string[]) => Promise<void>;
showError: (title: string, content: string) => void;
}
export type ConfiguredWindowsMpvPathStatus = 'blank' | 'configured' | 'invalid';
function normalizeCandidate(candidate: string | undefined): string {
return typeof candidate === 'string' ? candidate.trim() : '';
}
export function resolveWindowsMpvPath(deps: WindowsMpvLaunchDeps): string {
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;
@@ -33,26 +63,92 @@ export function resolveWindowsMpvPath(deps: WindowsMpvLaunchDeps): string {
return '';
}
export function buildWindowsMpvLaunchArgs(targets: string[], extraArgs: string[] = []): string[] {
return ['--player-operation-mode=pseudo-gui', '--profile=subminer', ...extraArgs, ...targets];
const DEFAULT_WINDOWS_MPV_SOCKET = '\\\\.\\pipe\\subminer-socket';
function readExtraArgValue(extraArgs: string[], flag: string): string | undefined {
let value: string | undefined;
for (let i = 0; i < extraArgs.length; i += 1) {
const arg = extraArgs[i];
if (arg === flag) {
const next = extraArgs[i + 1];
if (next && !next.startsWith('-')) {
value = next;
i += 1;
}
continue;
}
if (arg?.startsWith(`${flag}=`)) {
value = arg.slice(flag.length + 1);
}
}
return value;
}
export function launchWindowsMpv(
export function buildWindowsMpvLaunchArgs(
targets: string[],
extraArgs: string[] = [],
binaryPath?: string,
pluginEntrypointPath?: string,
): string[] {
const launchIdle = targets.length === 0;
const inputIpcServer =
readExtraArgValue(extraArgs, '--input-ipc-server') ?? DEFAULT_WINDOWS_MPV_SOCKET;
const scriptEntrypoint =
typeof pluginEntrypointPath === 'string' && pluginEntrypointPath.trim().length > 0
? `--script=${pluginEntrypointPath.trim()}`
: null;
const scriptOptPairs = scriptEntrypoint
? [`subminer-socket_path=${inputIpcServer.replace(/,/g, '\\,')}`]
: [];
if (scriptEntrypoint && typeof binaryPath === 'string' && binaryPath.trim().length > 0) {
scriptOptPairs.unshift(`subminer-binary_path=${binaryPath.trim().replace(/,/g, '\\,')}`);
}
const scriptOpts = scriptOptPairs.length > 0 ? `--script-opts=${scriptOptPairs.join(',')}` : null;
return [
'--player-operation-mode=pseudo-gui',
'--force-window=immediate',
...(launchIdle ? ['--idle=yes'] : []),
...(scriptEntrypoint ? [scriptEntrypoint] : []),
`--input-ipc-server=${inputIpcServer}`,
'--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',
...(scriptOpts ? [scriptOpts] : []),
...extraArgs,
...targets,
];
}
export async function launchWindowsMpv(
targets: string[],
deps: WindowsMpvLaunchDeps,
extraArgs: string[] = [],
): { ok: boolean; mpvPath: string } {
const mpvPath = resolveWindowsMpvPath(deps);
binaryPath?: string,
pluginEntrypointPath?: string,
configuredMpvPath?: string,
): Promise<{ ok: boolean; mpvPath: string }> {
const normalizedConfiguredPath = normalizeCandidate(configuredMpvPath);
const mpvPath = resolveWindowsMpvPath(deps, normalizedConfiguredPath);
if (!mpvPath) {
deps.showError(
'SubMiner mpv launcher',
'Could not find mpv.exe. Install mpv and add it to PATH, or set SUBMINER_MPV_PATH.',
normalizedConfiguredPath
? `Configured mpv.executablePath was not found: ${normalizedConfiguredPath}`
: 'Could not find mpv.exe. Set mpv.executablePath, set SUBMINER_MPV_PATH, or add mpv.exe to PATH.',
);
return { ok: false, mpvPath: '' };
}
try {
deps.spawnDetached(mpvPath, buildWindowsMpvLaunchArgs(targets, extraArgs));
await deps.spawnDetached(
mpvPath,
buildWindowsMpvLaunchArgs(targets, extraArgs, binaryPath, pluginEntrypointPath),
);
return { ok: true, mpvPath };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
@@ -79,23 +175,31 @@ export function createWindowsMpvLaunchDeps(options: {
error: result.error ?? undefined,
};
},
fileExists:
options.fileExists ??
((candidate) => {
fileExists: options.fileExists ?? defaultWindowsMpvFileExists,
spawnDetached: (command, args) =>
new Promise((resolve, reject) => {
try {
return fs.statSync(candidate).isFile();
} catch {
return false;
const child = spawn(command, args, {
detached: true,
stdio: 'ignore',
windowsHide: true,
});
let settled = false;
child.once('error', (error) => {
if (settled) return;
settled = true;
reject(error);
});
child.once('spawn', () => {
if (settled) return;
settled = true;
child.unref();
resolve();
});
} catch (error) {
reject(error);
}
}),
spawnDetached: (command, args) => {
const child = spawn(command, args, {
detached: true,
stdio: 'ignore',
windowsHide: true,
});
child.unref();
},
showError: options.showError,
};
}

View File

@@ -33,7 +33,7 @@ test('buildWindowsMpvShortcutDetails targets SubMiner.exe with --launch-mpv', ()
target: 'C:\\Apps\\SubMiner\\SubMiner.exe',
args: '--launch-mpv',
cwd: 'C:\\Apps\\SubMiner',
description: 'Launch mpv with the SubMiner profile',
description: 'Launch mpv with SubMiner defaults',
icon: 'C:\\Apps\\SubMiner\\SubMiner.exe',
iconIndex: 0,
});

View File

@@ -55,7 +55,7 @@ export function buildWindowsMpvShortcutDetails(exePath: string): WindowsShortcut
target: exePath,
args: '--launch-mpv',
cwd: path.win32.dirname(exePath),
description: 'Launch mpv with the SubMiner profile',
description: 'Launch mpv with SubMiner defaults',
icon: exePath,
iconIndex: 0,
};

View File

@@ -29,7 +29,7 @@ test('youtube playback runtime resets flow ownership after a successful run', as
resolveYoutubePlaybackUrl: async () => {
throw new Error('linux path should not resolve direct playback url');
},
launchWindowsMpv: () => ({ ok: false }),
launchWindowsMpv: async () => ({ ok: false }),
waitForYoutubeMpvConnected: async (timeoutMs) => {
calls.push(`wait-connected:${timeoutMs}`);
return true;
@@ -105,7 +105,7 @@ test('youtube playback runtime resolves the socket path lazily for windows start
calls.push(`resolve:${url}:${format}`);
return 'https://example.com/direct';
},
launchWindowsMpv: (_playbackUrl, args) => {
launchWindowsMpv: async (_playbackUrl, args) => {
calls.push(`launch:${args.join(' ')}`);
return { ok: true, mpvPath: '/usr/bin/mpv' };
},

View File

@@ -17,7 +17,7 @@ export type YoutubePlaybackRuntimeDeps = {
setAppOwnedFlowInFlight: (next: boolean) => void;
ensureYoutubePlaybackRuntimeReady: () => Promise<void>;
resolveYoutubePlaybackUrl: (url: string, format: string) => Promise<string>;
launchWindowsMpv: (playbackUrl: string, args: string[]) => LaunchResult;
launchWindowsMpv: (playbackUrl: string, args: string[]) => Promise<LaunchResult>;
waitForYoutubeMpvConnected: (timeoutMs: number) => Promise<boolean>;
prepareYoutubePlaybackInMpv: (request: { url: string }) => Promise<boolean>;
runYoutubePlaybackFlow: (request: {
@@ -77,7 +77,7 @@ export function createYoutubePlaybackRuntime(deps: YoutubePlaybackRuntimeDeps) {
if (deps.platform === 'win32' && !deps.getMpvConnected()) {
const socketPath = deps.getSocketPath();
const launchResult = deps.launchWindowsMpv(playbackUrl, [
const launchResult = await deps.launchWindowsMpv(playbackUrl, [
'--pause=yes',
'--ytdl=yes',
`--ytdl-format=${deps.mpvYtdlFormat}`,
@@ -92,7 +92,9 @@ export function createYoutubePlaybackRuntime(deps: YoutubePlaybackRuntimeDeps) {
]);
launchedWindowsMpv = launchResult.ok;
if (launchResult.ok && launchResult.mpvPath) {
deps.logInfo(`Bootstrapping Windows mpv for YouTube playback via ${launchResult.mpvPath}`);
deps.logInfo(
`Bootstrapping Windows mpv for YouTube playback via ${launchResult.mpvPath}`,
);
}
if (!launchResult.ok) {
deps.logWarn('Unable to bootstrap Windows mpv for YouTube playback.');