feat: add app-owned YouTube subtitle flow with absPlayer-style parsing (#31)

* fix: harden preload argv parsing for popup windows

* fix: align youtube playback with shared overlay startup

* fix: unwrap mpv youtube streams for anki media mining

* docs: update docs for youtube subtitle and mining flow

* refactor: unify cli and runtime wiring for startup and youtube flow

* feat: update subtitle sidebar overlay behavior

* chore: add shared log-file source for diagnostics

* fix(ci): add changelog fragment for immersion changes

* fix: address CodeRabbit review feedback

* fix: persist canonical title from youtube metadata

* style: format stats library tab

* fix: address latest review feedback

* style: format stats library files

* test: stub launcher youtube deps in CI

* test: isolate launcher youtube flow deps

* test: stub launcher youtube deps in failing case

* test: force x11 backend in launcher ci harness

* test: address latest review feedback

* fix(launcher): preserve user YouTube ytdl raw options

* docs(backlog): update task tracking notes

* fix(immersion): special-case youtube media paths in runtime and tracking

* feat(stats): improve YouTube media metadata and picker key handling

* fix(ci): format stats media library hook

* fix: address latest CodeRabbit review items

* docs: update youtube release notes and docs

* feat: auto-load youtube subtitles before manual picker

* fix: restore app-owned youtube subtitle flow

* docs: update youtube playback docs and config copy

* refactor: remove legacy youtube launcher mode plumbing

* fix: refine youtube subtitle startup binding

* docs: clarify youtube subtitle startup behavior

* fix: address PR #31 latest review follow-ups

* fix: address PR #31 follow-up review comments

* test: harden youtube picker test harness

* udpate backlog

* fix: add timeout to youtube metadata probe

* docs: refresh youtube and stats docs

* update backlog

* update backlog

* chore: release v0.9.0
This commit is contained in:
2026-03-24 00:01:24 -07:00
committed by GitHub
parent c17f0a4080
commit 5feed360ca
219 changed files with 12778 additions and 1052 deletions

View File

@@ -553,10 +553,12 @@ export function buildSubminerScriptOpts(
socketPath: string,
aniSkipMetadata: AniSkipMetadata | null,
logLevel: LogLevel = 'info',
extraParts: string[] = [],
): string {
const parts = [
`subminer-binary_path=${sanitizeScriptOptValue(appPath)}`,
`subminer-socket_path=${sanitizeScriptOptValue(socketPath)}`,
...extraParts.map(sanitizeScriptOptValue),
];
if (logLevel !== 'info') {
parts.push(`subminer-log_level=${sanitizeScriptOptValue(logLevel)}`);

View File

@@ -149,20 +149,16 @@ test('doctor command forwards refresh-known-words to app binary', () => {
context.args.doctorRefreshKnownWords = true;
const forwarded: string[][] = [];
assert.throws(
() =>
runDoctorCommand(context, {
commandExists: () => false,
configExists: () => true,
resolveMainConfigPath: () => '/tmp/SubMiner/config.jsonc',
runAppCommandWithInherit: (_appPath, appArgs) => {
forwarded.push(appArgs);
throw new ExitSignal(0);
},
}),
(error: unknown) => error instanceof ExitSignal && error.code === 0,
);
const handled = runDoctorCommand(context, {
commandExists: () => false,
configExists: () => true,
resolveMainConfigPath: () => '/tmp/SubMiner/config.jsonc',
runAppCommandWithInherit: (_appPath, appArgs) => {
forwarded.push(appArgs);
},
});
assert.equal(handled, true);
assert.deepEqual(forwarded, [['--refresh-known-words']]);
});
@@ -187,31 +183,25 @@ test('dictionary command forwards --dictionary and target path to app binary', (
context.args.dictionaryTarget = '/tmp/anime';
const forwarded: string[][] = [];
assert.throws(
() =>
runDictionaryCommand(context, {
runAppCommandWithInherit: (_appPath, appArgs) => {
forwarded.push(appArgs);
throw new ExitSignal(0);
},
}),
(error: unknown) => error instanceof ExitSignal && error.code === 0,
);
const handled = runDictionaryCommand(context, {
runAppCommandWithInherit: (_appPath, appArgs) => {
forwarded.push(appArgs);
},
});
assert.equal(handled, true);
assert.deepEqual(forwarded, [['--dictionary', '--dictionary-target', '/tmp/anime']]);
});
test('dictionary command throws if app handoff unexpectedly returns', () => {
test('dictionary command returns after app handoff starts', () => {
const context = createContext();
context.args.dictionary = true;
assert.throws(
() =>
runDictionaryCommand(context, {
runAppCommandWithInherit: () => undefined as never,
}),
/unexpectedly returned/,
);
const handled = runDictionaryCommand(context, {
runAppCommandWithInherit: () => undefined,
});
assert.equal(handled, true);
});
test('stats command launches attached app command with response path', async () => {

View File

@@ -2,7 +2,7 @@ import { runAppCommandWithInherit } from '../mpv.js';
import type { LauncherCommandContext } from './context.js';
interface DictionaryCommandDeps {
runAppCommandWithInherit: (appPath: string, appArgs: string[]) => never;
runAppCommandWithInherit: (appPath: string, appArgs: string[]) => void;
}
const defaultDeps: DictionaryCommandDeps = {
@@ -27,5 +27,5 @@ export function runDictionaryCommand(
}
deps.runAppCommandWithInherit(appPath, forwarded);
throw new Error('Dictionary command app handoff unexpectedly returned.');
return true;
}

View File

@@ -9,7 +9,7 @@ interface DoctorCommandDeps {
commandExists(command: string): boolean;
configExists(path: string): boolean;
resolveMainConfigPath(): string;
runAppCommandWithInherit(appPath: string, appArgs: string[]): never;
runAppCommandWithInherit(appPath: string, appArgs: string[]): void;
}
const defaultDeps: DoctorCommandDeps = {
@@ -51,7 +51,7 @@ export function runDoctorCommand(
ok: deps.commandExists('ffmpeg'),
detail: deps.commandExists('ffmpeg')
? 'found'
: 'missing (optional unless subtitle generation)',
: 'missing (optional unless legacy subtitle fallback is enabled)',
},
{
label: 'fzf',
@@ -85,6 +85,7 @@ export function runDoctorCommand(
return true;
}
deps.runAppCommandWithInherit(appPath, ['--refresh-known-words']);
return true;
}
const hasHardFailure = checks.some((entry) =>

View File

@@ -21,6 +21,7 @@ export async function runJellyfinCommand(context: LauncherCommandContext): Promi
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel);
appendPasswordStore(forwarded);
runAppCommandWithInherit(appPath, forwarded);
return true;
}
if (args.jellyfinLogin) {
@@ -44,6 +45,7 @@ export async function runJellyfinCommand(context: LauncherCommandContext): Promi
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel);
appendPasswordStore(forwarded);
runAppCommandWithInherit(appPath, forwarded);
return true;
}
if (args.jellyfinLogout) {
@@ -51,6 +53,7 @@ export async function runJellyfinCommand(context: LauncherCommandContext): Promi
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel);
appendPasswordStore(forwarded);
runAppCommandWithInherit(appPath, forwarded);
return true;
}
if (args.jellyfinPlay) {
@@ -69,13 +72,8 @@ export async function runJellyfinCommand(context: LauncherCommandContext): Promi
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel);
appendPasswordStore(forwarded);
runAppCommandWithInherit(appPath, forwarded);
return true;
}
return Boolean(
args.jellyfin ||
args.jellyfinLogin ||
args.jellyfinLogout ||
args.jellyfinPlay ||
args.jellyfinDiscovery,
);
return false;
}

View File

@@ -0,0 +1,133 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import type { LauncherCommandContext } from './context.js';
import { runPlaybackCommandWithDeps } from './playback-command.js';
function createContext(): LauncherCommandContext {
return {
args: {
backend: 'auto',
directory: '.',
recursive: false,
profile: '',
startOverlay: false,
youtubeMode: 'download',
whisperBin: '',
whisperModel: '',
whisperVadModel: '',
whisperThreads: 0,
youtubeSubgenOutDir: '',
youtubeSubgenAudioFormat: '',
youtubeSubgenKeepTemp: false,
youtubeFixWithAi: false,
youtubePrimarySubLangs: [],
youtubeSecondarySubLangs: [],
youtubeAudioLangs: [],
youtubeWhisperSourceLanguage: '',
aiConfig: {},
useTexthooker: false,
autoStartOverlay: false,
texthookerOnly: false,
useRofi: false,
logLevel: 'info',
passwordStore: '',
target: 'https://www.youtube.com/watch?v=65Ovd7t8sNw',
targetKind: 'url',
jimakuApiKey: '',
jimakuApiKeyCommand: '',
jimakuApiBaseUrl: '',
jimakuLanguagePreference: 'ja',
jimakuMaxEntryResults: 20,
jellyfin: false,
jellyfinLogin: false,
jellyfinLogout: false,
jellyfinPlay: false,
jellyfinDiscovery: false,
dictionary: false,
stats: false,
doctor: false,
doctorRefreshKnownWords: false,
configPath: false,
configShow: false,
mpvIdle: false,
mpvSocket: false,
mpvStatus: false,
mpvArgs: '',
appPassthrough: false,
appArgs: [],
jellyfinServer: '',
jellyfinUsername: '',
jellyfinPassword: '',
},
scriptPath: '/tmp/subminer',
scriptName: 'subminer',
mpvSocketPath: '/tmp/subminer.sock',
pluginRuntimeConfig: {
socketPath: '/tmp/subminer.sock',
autoStart: true,
autoStartVisibleOverlay: true,
autoStartPauseUntilReady: true,
},
appPath: '/tmp/SubMiner.AppImage',
launcherJellyfinConfig: {},
processAdapter: {
platform: () => 'linux',
onSignal: () => {},
writeStdout: () => {},
exit: (_code: number): never => {
throw new Error('unexpected exit');
},
setExitCode: () => {},
},
};
}
test('youtube playback launches overlay with app-owned youtube flow args', async () => {
const calls: string[] = [];
const context = createContext();
context.pluginRuntimeConfig = {
...context.pluginRuntimeConfig,
autoStart: false,
autoStartVisibleOverlay: false,
autoStartPauseUntilReady: false,
};
let receivedStartMpvOptions: Record<string, unknown> | null = null;
await runPlaybackCommandWithDeps(context, {
ensurePlaybackSetupReady: async () => {},
chooseTarget: async (_args, _scriptPath) => ({ target: context.args.target, kind: 'url' }),
checkDependencies: () => {},
registerCleanup: () => {},
startMpv: async (
_target,
_targetKind,
_args,
_socketPath,
_appPath,
_preloadedSubtitles,
options,
) => {
receivedStartMpvOptions = options ?? null;
calls.push('startMpv');
},
waitForUnixSocketReady: async () => true,
startOverlay: async (_appPath, _args, _socketPath, extraAppArgs = []) => {
calls.push(`startOverlay:${extraAppArgs.join(' ')}`);
},
launchAppCommandDetached: (_appPath: string, appArgs: string[]) => {
calls.push(`launch:${appArgs.join(' ')}`);
},
log: () => {},
cleanupPlaybackSession: async () => {},
getMpvProc: () => null,
});
assert.deepEqual(calls, [
'startMpv',
'startOverlay:--youtube-play https://www.youtube.com/watch?v=65Ovd7t8sNw --youtube-mode download',
]);
assert.deepEqual(receivedStartMpvOptions, {
startPaused: true,
disableYoutubeSubtitleAutoLoad: true,
});
});

View File

@@ -6,13 +6,13 @@ import { commandExists, isYoutubeTarget, realpathMaybe, resolvePathMaybe } from
import { collectVideos, showFzfMenu, showRofiMenu } from '../picker.js';
import {
cleanupPlaybackSession,
launchAppCommandDetached,
startMpv,
startOverlay,
state,
stopOverlay,
waitForUnixSocketReady,
} from '../mpv.js';
import { generateYoutubeSubtitles } from '../youtube.js';
import type { Args } from '../types.js';
import type { LauncherCommandContext } from './context.js';
import { ensureLauncherSetupReady } from '../setup-gate.js';
@@ -31,11 +31,12 @@ function checkDependencies(args: Args): void {
if (!commandExists('mpv')) missing.push('mpv');
if (args.targetKind === 'url' && isYoutubeTarget(args.target) && !commandExists('yt-dlp')) {
const isYoutubeUrl = args.targetKind === 'url' && isYoutubeTarget(args.target);
if (args.targetKind === 'url' && !isYoutubeUrl && !commandExists('yt-dlp')) {
missing.push('yt-dlp');
}
if (args.targetKind === 'url' && isYoutubeTarget(args.target) && !commandExists('ffmpeg')) {
if (args.targetKind === 'url' && !isYoutubeUrl && !commandExists('ffmpeg')) {
missing.push('ffmpeg');
}
@@ -126,30 +127,66 @@ async function ensurePlaybackSetupReady(context: LauncherCommandContext): Promis
}
export async function runPlaybackCommand(context: LauncherCommandContext): Promise<void> {
return runPlaybackCommandWithDeps(context, {
ensurePlaybackSetupReady,
chooseTarget,
checkDependencies,
registerCleanup,
startMpv,
waitForUnixSocketReady,
startOverlay,
launchAppCommandDetached,
log,
cleanupPlaybackSession,
getMpvProc: () => state.mpvProc,
});
}
type PlaybackCommandDeps = {
ensurePlaybackSetupReady: (context: LauncherCommandContext) => Promise<void>;
chooseTarget: (
args: Args,
scriptPath: string,
) => Promise<{ target: string; kind: 'file' | 'url' } | null>;
checkDependencies: (args: Args) => void;
registerCleanup: (context: LauncherCommandContext) => void;
startMpv: typeof startMpv;
waitForUnixSocketReady: typeof waitForUnixSocketReady;
startOverlay: typeof startOverlay;
launchAppCommandDetached: typeof launchAppCommandDetached;
log: typeof log;
cleanupPlaybackSession: typeof cleanupPlaybackSession;
getMpvProc: () => typeof state.mpvProc;
};
export async function runPlaybackCommandWithDeps(
context: LauncherCommandContext,
deps: PlaybackCommandDeps,
): Promise<void> {
const { args, appPath, scriptPath, mpvSocketPath, pluginRuntimeConfig, processAdapter } = context;
if (!appPath) {
fail('SubMiner AppImage not found. Install to ~/.local/bin/ or set SUBMINER_APPIMAGE_PATH.');
}
await ensurePlaybackSetupReady(context);
await deps.ensurePlaybackSetupReady(context);
if (!args.target) {
checkPickerDependencies(args);
}
const targetChoice = await chooseTarget(args, scriptPath);
const targetChoice = await deps.chooseTarget(args, scriptPath);
if (!targetChoice) {
log('info', args.logLevel, 'No video selected, exiting');
deps.log('info', args.logLevel, 'No video selected, exiting');
processAdapter.exit(0);
}
checkDependencies({
deps.checkDependencies({
...args,
target: targetChoice ? targetChoice.target : args.target,
targetKind: targetChoice ? targetChoice.kind : 'url',
});
registerCleanup(context);
deps.registerCleanup(context);
const selectedTarget = targetChoice
? {
@@ -159,30 +196,11 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi
: { target: args.target, kind: 'url' as const };
const isYoutubeUrl = selectedTarget.kind === 'url' && isYoutubeTarget(selectedTarget.target);
let preloadedSubtitles: { primaryPath?: string; secondaryPath?: string } | undefined;
const isAppOwnedYoutubeFlow = isYoutubeUrl;
const youtubeMode = args.youtubeMode ?? 'download';
if (isYoutubeUrl) {
log('info', args.logLevel, 'YouTube subtitle generation: preload before mpv');
const generated = await generateYoutubeSubtitles(selectedTarget.target, args);
preloadedSubtitles = {
primaryPath: generated.primaryPath,
secondaryPath: generated.secondaryPath,
};
const primaryStatus = generated.primaryPath
? 'ready'
: generated.primaryNative
? 'native'
: 'missing';
const secondaryStatus = generated.secondaryPath
? 'ready'
: generated.secondaryNative
? 'native'
: 'missing';
log(
'info',
args.logLevel,
`YouTube subtitle result: primary=${primaryStatus}, secondary=${secondaryStatus}`,
);
deps.log('info', args.logLevel, 'YouTube subtitle flow: app-owned picker after mpv bootstrap');
}
const shouldPauseUntilOverlayReady =
@@ -191,47 +209,57 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi
pluginRuntimeConfig.autoStartPauseUntilReady;
if (shouldPauseUntilOverlayReady) {
log('info', args.logLevel, 'Configured to pause mpv until overlay and tokenization are ready');
deps.log('info', args.logLevel, 'Configured to pause mpv until overlay and tokenization are ready');
}
await startMpv(
await deps.startMpv(
selectedTarget.target,
selectedTarget.kind,
args,
mpvSocketPath,
appPath,
preloadedSubtitles,
{ startPaused: shouldPauseUntilOverlayReady },
undefined,
{
startPaused: shouldPauseUntilOverlayReady || isAppOwnedYoutubeFlow,
disableYoutubeSubtitleAutoLoad: isAppOwnedYoutubeFlow,
},
);
const ready = await waitForUnixSocketReady(mpvSocketPath, 10000);
const ready = await deps.waitForUnixSocketReady(mpvSocketPath, 10000);
const pluginAutoStartEnabled = pluginRuntimeConfig.autoStart;
const shouldStartOverlay = args.startOverlay || args.autoStartOverlay;
const shouldStartOverlay = args.startOverlay || args.autoStartOverlay || isAppOwnedYoutubeFlow;
if (shouldStartOverlay) {
if (ready) {
log('info', args.logLevel, 'MPV IPC socket ready, starting SubMiner overlay');
deps.log('info', args.logLevel, 'MPV IPC socket ready, starting SubMiner overlay');
} else {
log(
deps.log(
'info',
args.logLevel,
'MPV IPC socket not ready after timeout, starting SubMiner overlay anyway',
);
}
await startOverlay(appPath, args, mpvSocketPath);
await deps.startOverlay(
appPath,
args,
mpvSocketPath,
isAppOwnedYoutubeFlow
? ['--youtube-play', selectedTarget.target, '--youtube-mode', youtubeMode]
: [],
);
} else if (pluginAutoStartEnabled) {
if (ready) {
log('info', args.logLevel, 'MPV IPC socket ready, relying on mpv plugin auto-start');
deps.log('info', args.logLevel, 'MPV IPC socket ready, relying on mpv plugin auto-start');
} else {
log('info', args.logLevel, 'MPV IPC socket not ready yet, relying on mpv plugin auto-start');
deps.log('info', args.logLevel, 'MPV IPC socket not ready yet, relying on mpv plugin auto-start');
}
} else if (ready) {
log(
deps.log(
'info',
args.logLevel,
'MPV IPC socket ready, overlay auto-start disabled (use y-s to start)',
);
} else {
log(
deps.log(
'info',
args.logLevel,
'MPV IPC socket not ready yet, overlay auto-start disabled (use y-s to start)',
@@ -239,7 +267,7 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi
}
await new Promise<void>((resolve) => {
const mpvProc = state.mpvProc;
const mpvProc = deps.getMpvProc();
if (!mpvProc) {
stopOverlay(args);
resolve();
@@ -247,7 +275,7 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi
}
const finalize = (code: number | null | undefined) => {
void cleanupPlaybackSession(args).finally(() => {
void deps.cleanupPlaybackSession(args).finally(() => {
processAdapter.setExitCode(code ?? 0);
resolve();
});

View File

@@ -10,7 +10,6 @@ test('launcher root help lists subcommands', () => {
assert.match(output, /Commands:/);
assert.match(output, /jellyfin\|jf/);
assert.match(output, /yt\|youtube/);
assert.match(output, /doctor/);
assert.match(output, /config/);
assert.match(output, /mpv/);

View File

@@ -249,26 +249,6 @@ export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations
parsed.jellyfinLogout = Boolean(modeFlags.logout);
}
if (invocations.ytInvocation) {
if (invocations.ytInvocation.logLevel)
parsed.logLevel = parseLogLevel(invocations.ytInvocation.logLevel);
if (invocations.ytInvocation.outDir)
parsed.youtubeSubgenOutDir = invocations.ytInvocation.outDir;
if (invocations.ytInvocation.keepTemp) parsed.youtubeSubgenKeepTemp = true;
if (invocations.ytInvocation.whisperBin)
parsed.whisperBin = invocations.ytInvocation.whisperBin;
if (invocations.ytInvocation.whisperModel)
parsed.whisperModel = invocations.ytInvocation.whisperModel;
if (invocations.ytInvocation.whisperVadModel)
parsed.whisperVadModel = invocations.ytInvocation.whisperVadModel;
if (invocations.ytInvocation.whisperThreads)
parsed.whisperThreads = invocations.ytInvocation.whisperThreads;
if (invocations.ytInvocation.ytSubgenAudioFormat) {
parsed.youtubeSubgenAudioFormat = invocations.ytInvocation.ytSubgenAudioFormat;
}
if (invocations.ytInvocation.target) ensureTarget(invocations.ytInvocation.target, parsed);
}
if (invocations.dictionaryLogLevel) {
parsed.logLevel = parseLogLevel(invocations.dictionaryLogLevel);
}

View File

@@ -14,18 +14,6 @@ export interface JellyfinInvocation {
logLevel?: string;
}
export interface YtInvocation {
target?: string;
outDir?: string;
keepTemp?: boolean;
whisperBin?: string;
whisperModel?: string;
whisperVadModel?: string;
whisperThreads?: number;
ytSubgenAudioFormat?: string;
logLevel?: string;
}
export interface CommandActionInvocation {
action: string;
logLevel?: string;
@@ -33,7 +21,6 @@ export interface CommandActionInvocation {
export interface CliInvocations {
jellyfinInvocation: JellyfinInvocation | null;
ytInvocation: YtInvocation | null;
configInvocation: CommandActionInvocation | null;
mpvInvocation: CommandActionInvocation | null;
appInvocation: { appArgs: string[] } | null;
@@ -89,8 +76,6 @@ function getTopLevelCommand(argv: string[]): { name: string; index: number } | n
const commandNames = new Set([
'jellyfin',
'jf',
'yt',
'youtube',
'doctor',
'config',
'mpv',
@@ -142,7 +127,6 @@ export function parseCliPrograms(
invocations: CliInvocations;
} {
let jellyfinInvocation: JellyfinInvocation | null = null;
let ytInvocation: YtInvocation | null = null;
let configInvocation: CommandActionInvocation | null = null;
let mpvInvocation: CommandActionInvocation | null = null;
let appInvocation: { appArgs: string[] } | null = null;
@@ -217,38 +201,6 @@ export function parseCliPrograms(
};
});
commandProgram
.command('yt')
.alias('youtube')
.description('YouTube workflows')
.argument('[target]', 'YouTube URL or ytsearch: query')
.option('-o, --out-dir <dir>', 'Subtitle output dir')
.option('--keep-temp', 'Keep temp files')
.option('--whisper-bin <path>', 'whisper.cpp CLI path')
.option('--whisper-model <path>', 'whisper model path')
.option('--whisper-vad-model <path>', 'whisper.cpp VAD model path')
.option('--whisper-threads <n>', 'whisper.cpp thread count')
.option('--yt-subgen-audio-format <format>', 'Audio extraction format')
.option('--log-level <level>', 'Log level')
.action((target: string | undefined, options: Record<string, unknown>) => {
ytInvocation = {
target,
outDir: typeof options.outDir === 'string' ? options.outDir : undefined,
keepTemp: options.keepTemp === true,
whisperBin: typeof options.whisperBin === 'string' ? options.whisperBin : undefined,
whisperModel: typeof options.whisperModel === 'string' ? options.whisperModel : undefined,
whisperVadModel:
typeof options.whisperVadModel === 'string' ? options.whisperVadModel : undefined,
whisperThreads:
typeof options.whisperThreads === 'number' && Number.isFinite(options.whisperThreads)
? Math.floor(options.whisperThreads)
: undefined,
ytSubgenAudioFormat:
typeof options.ytSubgenAudioFormat === 'string' ? options.ytSubgenAudioFormat : undefined,
logLevel: typeof options.logLevel === 'string' ? options.logLevel : undefined,
};
});
commandProgram
.command('dictionary')
.alias('dict')
@@ -382,7 +334,6 @@ export function parseCliPrograms(
rootTarget: rootProgram.processedArgs[0],
invocations: {
jellyfinInvocation,
ytInvocation,
configInvocation,
mpvInvocation,
appInvocation,

View File

@@ -1,9 +1,10 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import path from 'node:path';
import { getDefaultMpvLogFile } from './types.js';
import { getDefaultLauncherLogFile, getDefaultMpvLogFile } from './types.js';
test('getDefaultMpvLogFile uses APPDATA on windows', () => {
const today = new Date().toISOString().slice(0, 10);
const resolved = getDefaultMpvLogFile({
platform: 'win32',
homeDir: 'C:\\Users\\tester',
@@ -17,8 +18,27 @@ test('getDefaultMpvLogFile uses APPDATA on windows', () => {
'C:\\Users\\tester\\AppData\\Roaming',
'SubMiner',
'logs',
`SubMiner-${new Date().toISOString().slice(0, 10)}.log`,
`mpv-${today}.log`,
),
),
);
});
test('getDefaultLauncherLogFile uses launcher prefix', () => {
const today = new Date().toISOString().slice(0, 10);
const resolved = getDefaultLauncherLogFile({
platform: 'linux',
homeDir: '/home/tester',
});
assert.equal(
resolved,
path.join(
'/home/tester',
'.config',
'SubMiner',
'logs',
`launcher-${today}.log`,
),
);
});

View File

@@ -1,7 +1,6 @@
import fs from 'node:fs';
import path from 'node:path';
import type { LogLevel } from './types.js';
import { DEFAULT_MPV_LOG_FILE } from './types.js';
import { DEFAULT_MPV_LOG_FILE, getDefaultLauncherLogFile } from './types.js';
import { appendLogLine, resolveDefaultLogFilePath } from '../src/shared/log-files.js';
export const COLORS = {
red: '\x1b[0;31m',
@@ -28,14 +27,32 @@ export function getMpvLogPath(): string {
return DEFAULT_MPV_LOG_FILE;
}
export function getLauncherLogPath(): string {
const envPath = process.env.SUBMINER_LAUNCHER_LOG?.trim();
if (envPath) return envPath;
return getDefaultLauncherLogFile();
}
export function getAppLogPath(): string {
const envPath = process.env.SUBMINER_APP_LOG?.trim();
if (envPath) return envPath;
return resolveDefaultLogFilePath('app');
}
function appendTimestampedLog(logPath: string, message: string): void {
appendLogLine(logPath, `[${new Date().toISOString()}] ${message}`);
}
export function appendToMpvLog(message: string): void {
const logPath = getMpvLogPath();
try {
fs.mkdirSync(path.dirname(logPath), { recursive: true });
fs.appendFileSync(logPath, `[${new Date().toISOString()}] ${message}\n`, { encoding: 'utf8' });
} catch {
// ignore logging failures
}
appendTimestampedLog(getMpvLogPath(), message);
}
export function appendToLauncherLog(message: string): void {
appendTimestampedLog(getLauncherLogPath(), message);
}
export function appendToAppLog(message: string): void {
appendTimestampedLog(getAppLogPath(), message);
}
export function log(level: LogLevel, configured: LogLevel, message: string): void {
@@ -49,11 +66,11 @@ export function log(level: LogLevel, configured: LogLevel, message: string): voi
? COLORS.red
: COLORS.cyan;
process.stdout.write(`${color}[${level.toUpperCase()}]${COLORS.reset} ${message}\n`);
appendToMpvLog(`[${level.toUpperCase()}] ${message}`);
appendToLauncherLog(`[${level.toUpperCase()}] ${message}`);
}
export function fail(message: string): never {
process.stderr.write(`${COLORS.red}[ERROR]${COLORS.reset} ${message}\n`);
appendToMpvLog(`[ERROR] ${message}`);
appendToLauncherLog(`[ERROR] ${message}`);
process.exit(1);
}

View File

@@ -205,136 +205,6 @@ test('doctor refresh-known-words forwards app refresh command without requiring
});
});
test('youtube command rejects removed --mode option', () => {
withTempDir((root) => {
const homeDir = path.join(root, 'home');
const xdgConfigHome = path.join(root, 'xdg');
const appPath = path.join(root, 'fake-subminer.sh');
fs.writeFileSync(appPath, '#!/bin/sh\nexit 0\n');
fs.chmodSync(appPath, 0o755);
const env = {
...makeTestEnv(homeDir, xdgConfigHome),
SUBMINER_APPIMAGE_PATH: appPath,
};
const result = runLauncher(
['youtube', 'https://www.youtube.com/watch?v=test123', '--mode', 'automatic'],
env,
);
assert.equal(result.status, 1);
assert.match(result.stderr, /unknown option '--mode'/i);
});
});
test('youtube playback generates subtitles before mpv launch', { timeout: 15000 }, () => {
withTempDir((root) => {
const homeDir = path.join(root, 'home');
const xdgConfigHome = path.join(root, 'xdg');
const binDir = path.join(root, 'bin');
const appPath = path.join(root, 'fake-subminer.sh');
const ytdlpLogPath = path.join(root, 'yt-dlp.log');
const mpvCapturePath = path.join(root, 'mpv-order.txt');
const mpvArgsPath = path.join(root, 'mpv-args.txt');
const socketPath = path.join(root, 'mpv.sock');
const bunBinary = JSON.stringify(process.execPath.replace(/\\/g, '/'));
fs.mkdirSync(binDir, { recursive: true });
fs.mkdirSync(path.join(xdgConfigHome, 'SubMiner'), { recursive: true });
fs.mkdirSync(path.join(xdgConfigHome, 'mpv', 'script-opts'), { recursive: true });
fs.writeFileSync(
path.join(xdgConfigHome, 'SubMiner', 'setup-state.json'),
JSON.stringify({
version: 1,
status: 'completed',
completedAt: '2026-03-08T00:00:00.000Z',
completionSource: 'user',
lastSeenYomitanDictionaryCount: 0,
pluginInstallStatus: 'installed',
pluginInstallPathSummary: null,
}),
);
fs.writeFileSync(
path.join(xdgConfigHome, 'mpv', 'script-opts', 'subminer.conf'),
`socket_path=${socketPath}\nauto_start=no\nauto_start_visible_overlay=no\nauto_start_pause_until_ready=no\n`,
);
fs.writeFileSync(appPath, '#!/bin/sh\nexit 0\n');
fs.chmodSync(appPath, 0o755);
fs.writeFileSync(
path.join(binDir, 'yt-dlp'),
`#!/bin/sh
set -eu
printf '%s\\n' "$*" >> "$SUBMINER_TEST_YTDLP_LOG"
if printf '%s\\n' "$*" | grep -q -- '--dump-single-json'; then
printf '{"id":"video123"}\\n'
exit 0
fi
out_dir=""
prev=""
for arg in "$@"; do
if [ "$prev" = "-o" ]; then
out_dir=$(dirname "$arg")
break
fi
prev="$arg"
done
mkdir -p "$out_dir"
printf '1\\n00:00:00,000 --> 00:00:01,000\\nこんにちは\\n' > "$out_dir/video123.ja.srt"
printf '1\\n00:00:00,000 --> 00:00:01,000\\nhello\\n' > "$out_dir/video123.en.srt"
`,
'utf8',
);
fs.chmodSync(path.join(binDir, 'yt-dlp'), 0o755);
fs.writeFileSync(path.join(binDir, 'ffmpeg'), '#!/bin/sh\nexit 0\n', 'utf8');
fs.chmodSync(path.join(binDir, 'ffmpeg'), 0o755);
fs.writeFileSync(
path.join(binDir, 'mpv'),
`#!/bin/sh
set -eu
if [ -s "$SUBMINER_TEST_YTDLP_LOG" ]; then
printf 'generated-before-mpv\\n' > "$SUBMINER_TEST_MPV_ORDER"
else
printf 'mpv-before-generation\\n' > "$SUBMINER_TEST_MPV_ORDER"
fi
printf '%s\\n' "$@" > "$SUBMINER_TEST_MPV_ARGS"
socket_path=""
for arg in "$@"; do
case "$arg" in
--input-ipc-server=*)
socket_path="\${arg#--input-ipc-server=}"
;;
esac
done
${bunBinary} -e "const net=require('node:net'); const fs=require('node:fs'); const path=require('node:path'); const socket=process.argv[1]||''; try{ if(socket) fs.mkdirSync(path.dirname(socket),{recursive:true}); }catch{} try{ if(socket) fs.rmSync(socket,{force:true}); }catch{} const server=net.createServer((c)=>c.end()); server.on('error',()=>process.exit(0)); if(!socket) process.exit(0); try{ server.listen(socket,()=>setTimeout(()=>server.close(()=>process.exit(0)),250)); } catch { process.exit(0); }" "$socket_path"
`,
'utf8',
);
fs.chmodSync(path.join(binDir, 'mpv'), 0o755);
const env = {
...makeTestEnv(homeDir, xdgConfigHome),
PATH: `${binDir}${path.delimiter}${process.env.Path || process.env.PATH || ''}`,
Path: `${binDir}${path.delimiter}${process.env.Path || process.env.PATH || ''}`,
SUBMINER_APPIMAGE_PATH: appPath,
SUBMINER_TEST_YTDLP_LOG: ytdlpLogPath,
SUBMINER_TEST_MPV_ORDER: mpvCapturePath,
SUBMINER_TEST_MPV_ARGS: mpvArgsPath,
};
const result = runLauncher(['youtube', 'https://www.youtube.com/watch?v=test123'], env);
assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
assert.equal(fs.readFileSync(mpvCapturePath, 'utf8').trim(), 'generated-before-mpv');
assert.match(
fs.readFileSync(mpvArgsPath, 'utf8'),
/https:\/\/www\.youtube\.com\/watch\?v=test123/,
);
assert.match(fs.readFileSync(ytdlpLogPath, 'utf8'), /--dump-single-json/);
});
});
test('launcher forwards --args to mpv as parsed tokens', { timeout: 15000 }, () => {
withTempDir((root) => {
const homeDir = path.join(root, 'home');
@@ -387,6 +257,10 @@ ${bunBinary} -e "const net=require('node:net'); const fs=require('node:fs'); con
'utf8',
);
fs.chmodSync(path.join(binDir, 'mpv'), 0o755);
fs.writeFileSync(path.join(binDir, 'yt-dlp'), '#!/bin/sh\nexit 0\n', 'utf8');
fs.writeFileSync(path.join(binDir, 'ffmpeg'), '#!/bin/sh\nexit 0\n', 'utf8');
fs.chmodSync(path.join(binDir, 'yt-dlp'), 0o755);
fs.chmodSync(path.join(binDir, 'ffmpeg'), 0o755);
const env = {
...makeTestEnv(homeDir, xdgConfigHome),
@@ -466,6 +340,10 @@ ${bunBinary} -e "const net=require('node:net'); const fs=require('node:fs'); con
'utf8',
);
fs.chmodSync(path.join(binDir, 'mpv'), 0o755);
fs.writeFileSync(path.join(binDir, 'yt-dlp'), '#!/bin/sh\nexit 0\n', 'utf8');
fs.writeFileSync(path.join(binDir, 'ffmpeg'), '#!/bin/sh\nexit 0\n', 'utf8');
fs.chmodSync(path.join(binDir, 'yt-dlp'), 0o755);
fs.chmodSync(path.join(binDir, 'ffmpeg'), 0o755);
const env = {
...makeTestEnv(homeDir, xdgConfigHome),
@@ -484,6 +362,87 @@ ${bunBinary} -e "const net=require('node:net'); const fs=require('node:fs'); con
});
});
test('launcher routes youtube urls through regular playback startup', { timeout: 15000 }, () => {
withTempDir((root) => {
const homeDir = path.join(root, 'home');
const xdgConfigHome = path.join(root, 'xdg');
const binDir = path.join(root, 'bin');
const appPath = path.join(root, 'fake-subminer.sh');
const mpvArgsPath = path.join(root, 'mpv-args.txt');
const socketPath = path.join(root, 'mpv.sock');
const bunBinary = JSON.stringify(process.execPath.replace(/\\/g, '/'));
fs.mkdirSync(binDir, { recursive: true });
fs.mkdirSync(path.join(xdgConfigHome, 'SubMiner'), { recursive: true });
fs.mkdirSync(path.join(xdgConfigHome, 'mpv', 'script-opts'), { recursive: true });
fs.writeFileSync(
path.join(xdgConfigHome, 'SubMiner', 'setup-state.json'),
JSON.stringify({
version: 1,
status: 'completed',
completedAt: '2026-03-08T00:00:00.000Z',
completionSource: 'user',
lastSeenYomitanDictionaryCount: 0,
pluginInstallStatus: 'installed',
pluginInstallPathSummary: null,
}),
);
fs.writeFileSync(
path.join(xdgConfigHome, 'mpv', 'script-opts', 'subminer.conf'),
`socket_path=${socketPath}\nauto_start=yes\nauto_start_visible_overlay=yes\nauto_start_pause_until_ready=yes\n`,
);
fs.writeFileSync(
appPath,
'#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n',
);
fs.chmodSync(appPath, 0o755);
fs.writeFileSync(
path.join(binDir, 'mpv'),
`#!/bin/sh
set -eu
printf '%s\\n' "$@" > "$SUBMINER_TEST_MPV_ARGS"
socket_path=""
for arg in "$@"; do
case "$arg" in
--input-ipc-server=*)
socket_path="\${arg#--input-ipc-server=}"
;;
esac
done
${bunBinary} -e "const net=require('node:net'); const fs=require('node:fs'); const path=require('node:path'); const socket=process.argv[1]||''; try{ if (socket) fs.mkdirSync(path.dirname(socket),{recursive:true}); }catch{} try{ if (socket) fs.rmSync(socket,{force:true}); }catch{} if(!socket) process.exit(0); const server=net.createServer((c)=>c.end()); server.on('error',()=>process.exit(0)); try{ server.listen(socket,()=>setTimeout(()=>server.close(()=>process.exit(0)),250)); } catch { process.exit(0); }" "$socket_path"
`,
'utf8',
);
fs.chmodSync(path.join(binDir, 'mpv'), 0o755);
fs.writeFileSync(path.join(binDir, 'yt-dlp'), '#!/bin/sh\nexit 0\n', 'utf8');
fs.writeFileSync(path.join(binDir, 'ffmpeg'), '#!/bin/sh\nexit 0\n', 'utf8');
fs.chmodSync(path.join(binDir, 'yt-dlp'), 0o755);
fs.chmodSync(path.join(binDir, 'ffmpeg'), 0o755);
const env = {
...makeTestEnv(homeDir, xdgConfigHome),
PATH: `${binDir}${path.delimiter}${process.env.Path || process.env.PATH || ''}`,
Path: `${binDir}${path.delimiter}${process.env.Path || process.env.PATH || ''}`,
DISPLAY: ':99',
XDG_SESSION_TYPE: 'x11',
SUBMINER_APPIMAGE_PATH: appPath,
SUBMINER_TEST_MPV_ARGS: mpvArgsPath,
SUBMINER_TEST_CAPTURE: path.join(root, 'captured-args.txt'),
};
const result = runLauncher(['https://www.youtube.com/watch?v=abc123'], env);
assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
const forwardedArgs = fs
.readFileSync(mpvArgsPath, 'utf8')
.trim()
.split('\n')
.map((item) => item.trim())
.filter(Boolean);
assert.equal(forwardedArgs.includes('https://www.youtube.com/watch?v=abc123'), true);
});
});
test('dictionary command forwards --dictionary and --dictionary-target to app command path', () => {
withTempDir((root) => {
const homeDir = path.join(root, 'home');

View File

@@ -302,7 +302,47 @@ test('startOverlay resolves without fixed 2s sleep when readiness signals arrive
}
});
test('cleanupPlaybackSession preserves background app while stopping mpv-owned children', async () => {
test('startOverlay captures app stdout and stderr into app log', async () => {
const { dir, socketPath } = createTempSocketPath();
const appPath = path.join(dir, 'fake-subminer.sh');
const appLogPath = path.join(dir, 'app.log');
const originalAppLog = process.env.SUBMINER_APP_LOG;
fs.writeFileSync(
appPath,
'#!/bin/sh\nprintf "hello from stdout\\n"\nprintf "hello from stderr\\n" >&2\nexit 0\n',
);
fs.chmodSync(appPath, 0o755);
fs.writeFileSync(socketPath, '');
const originalCreateConnection = net.createConnection;
try {
process.env.SUBMINER_APP_LOG = appLogPath;
net.createConnection = (() => {
const socket = new EventEmitter() as net.Socket;
socket.destroy = (() => socket) as net.Socket['destroy'];
socket.setTimeout = (() => socket) as net.Socket['setTimeout'];
setTimeout(() => socket.emit('connect'), 10);
return socket;
}) as typeof net.createConnection;
await startOverlay(appPath, makeArgs(), socketPath);
const logText = fs.readFileSync(appLogPath, 'utf8');
assert.match(logText, /\[STDOUT\] hello from stdout/);
assert.match(logText, /\[STDERR\] hello from stderr/);
} finally {
net.createConnection = originalCreateConnection;
state.overlayProc = null;
state.overlayManagedByLauncher = false;
if (originalAppLog === undefined) {
delete process.env.SUBMINER_APP_LOG;
} else {
process.env.SUBMINER_APP_LOG = originalAppLog;
}
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('cleanupPlaybackSession stops launcher-managed overlay app and mpv-owned children', async () => {
const { dir } = createTempSocketPath();
const appPath = path.join(dir, 'fake-subminer.sh');
const appInvocationsPath = path.join(dir, 'app-invocations.log');
@@ -345,8 +385,8 @@ test('cleanupPlaybackSession preserves background app while stopping mpv-owned c
try {
await cleanupPlaybackSession(makeArgs());
assert.deepEqual(calls, ['mpv-kill', 'helper-kill']);
assert.equal(fs.existsSync(appInvocationsPath), false);
assert.deepEqual(calls, ['overlay-kill', 'mpv-kill', 'helper-kill']);
assert.match(fs.readFileSync(appInvocationsPath, 'utf8'), /--stop/);
} finally {
state.overlayProc = null;
state.mpvProc = null;

View File

@@ -5,7 +5,7 @@ import net from 'node:net';
import { spawn, spawnSync } from 'node:child_process';
import type { LogLevel, Backend, Args, MpvTrack } from './types.js';
import { DEFAULT_MPV_SUBMINER_ARGS, DEFAULT_YOUTUBE_YTDL_FORMAT } from './types.js';
import { log, fail, getMpvLogPath } from './log.js';
import { appendToAppLog, getAppLogPath, log, fail, getMpvLogPath } from './log.js';
import { buildSubminerScriptOpts, resolveAniSkipMetadataForFile } from './aniskip-metadata.js';
import {
commandExists,
@@ -542,7 +542,7 @@ export async function startMpv(
socketPath: string,
appPath: string,
preloadedSubtitles?: { primaryPath?: string; secondaryPath?: string },
options?: { startPaused?: boolean },
options?: { startPaused?: boolean; disableYoutubeSubtitleAutoLoad?: boolean },
): Promise<void> {
if (targetKind === 'file' && (!fs.existsSync(target) || !fs.statSync(target).isFile())) {
fail(`Video file not found: ${target}`);
@@ -557,24 +557,19 @@ export async function startMpv(
const mpvArgs: string[] = [];
if (args.profile) mpvArgs.push(`--profile=${args.profile}`);
mpvArgs.push(...DEFAULT_MPV_SUBMINER_ARGS);
if (args.mpvArgs) {
mpvArgs.push(...parseMpvArgString(args.mpvArgs));
}
if (targetKind === 'url' && isYoutubeTarget(target)) {
log('info', args.logLevel, 'Applying URL playback options');
mpvArgs.push('--ytdl=yes', '--ytdl-raw-options=');
if (isYoutubeTarget(target)) {
const subtitleLangs = uniqueNormalizedLangCodes([
...args.youtubePrimarySubLangs,
...args.youtubeSecondarySubLangs,
]).join(',');
const audioLangs = uniqueNormalizedLangCodes(args.youtubeAudioLangs).join(',');
log('info', args.logLevel, 'Applying YouTube playback options');
log('debug', args.logLevel, `YouTube subtitle langs: ${subtitleLangs}`);
log('debug', args.logLevel, `YouTube audio langs: ${audioLangs}`);
mpvArgs.push(`--ytdl-format=${DEFAULT_YOUTUBE_YTDL_FORMAT}`, `--alang=${audioLangs}`);
mpvArgs.push('--ytdl=yes');
const subtitleLangs = uniqueNormalizedLangCodes([
...args.youtubePrimarySubLangs,
...args.youtubeSecondarySubLangs,
]).join(',');
const audioLangs = uniqueNormalizedLangCodes(args.youtubeAudioLangs).join(',');
log('info', args.logLevel, 'Applying YouTube playback options');
log('debug', args.logLevel, `YouTube subtitle langs: ${subtitleLangs}`);
log('debug', args.logLevel, `YouTube audio langs: ${audioLangs}`);
mpvArgs.push(`--ytdl-format=${DEFAULT_YOUTUBE_YTDL_FORMAT}`, `--alang=${audioLangs}`);
if (options?.disableYoutubeSubtitleAutoLoad !== true) {
mpvArgs.push(
'--sub-auto=fuzzy',
`--slang=${subtitleLangs}`,
@@ -582,8 +577,13 @@ export async function startMpv(
'--ytdl-raw-options-append=sub-format=vtt/best',
`--ytdl-raw-options-append=sub-langs=${subtitleLangs}`,
);
} else {
mpvArgs.push('--sub-auto=no');
}
}
if (args.mpvArgs) {
mpvArgs.push(...parseMpvArgString(args.mpvArgs));
}
if (preloadedSubtitles?.primaryPath) {
mpvArgs.push(`--sub-file=${preloadedSubtitles.primaryPath}`);
@@ -597,7 +597,17 @@ export async function startMpv(
const aniSkipMetadata = shouldResolveAniSkipMetadata(target, targetKind, preloadedSubtitles)
? await resolveAniSkipMetadataForFile(target)
: null;
const scriptOpts = buildSubminerScriptOpts(appPath, socketPath, aniSkipMetadata, args.logLevel);
const extraScriptOpts =
targetKind === 'url' && isYoutubeTarget(target) && options?.disableYoutubeSubtitleAutoLoad === true
? ['subminer-auto_start_pause_until_ready=no']
: [];
const scriptOpts = buildSubminerScriptOpts(
appPath,
socketPath,
aniSkipMetadata,
args.logLevel,
extraScriptOpts,
);
if (aniSkipMetadata) {
log(
'debug',
@@ -661,19 +671,25 @@ async function waitForOverlayStartCommandSettled(
});
}
export async function startOverlay(appPath: string, args: Args, socketPath: string): Promise<void> {
export async function startOverlay(
appPath: string,
args: Args,
socketPath: string,
extraAppArgs: string[] = [],
): Promise<void> {
const backend = detectBackend(args.backend);
log('info', args.logLevel, `Starting SubMiner overlay (backend: ${backend})...`);
const overlayArgs = ['--start', '--backend', backend, '--socket', socketPath];
const overlayArgs = ['--start', '--backend', backend, '--socket', socketPath, ...extraAppArgs];
if (args.logLevel !== 'info') overlayArgs.push('--log-level', args.logLevel);
if (args.useTexthooker) overlayArgs.push('--texthooker');
const target = resolveAppSpawnTarget(appPath, overlayArgs);
state.overlayProc = spawn(target.command, target.args, {
stdio: 'inherit',
stdio: ['ignore', 'pipe', 'pipe'],
env: buildAppEnv(),
});
attachAppProcessLogging(state.overlayProc);
state.overlayManagedByLauncher = true;
const [socketReady] = await Promise.all([
@@ -699,10 +715,7 @@ export function launchTexthookerOnly(appPath: string, args: Args): never {
if (args.logLevel !== 'info') overlayArgs.push('--log-level', args.logLevel);
log('info', args.logLevel, 'Launching texthooker mode...');
const result = spawnSync(appPath, overlayArgs, {
stdio: 'inherit',
env: buildAppEnv(),
});
const result = runSyncAppCommand(appPath, overlayArgs, true);
if (result.error) {
fail(`Failed to launch texthooker mode: ${result.error.message}`);
}
@@ -713,30 +726,7 @@ export function stopOverlay(args: Args): void {
if (state.stopRequested) return;
state.stopRequested = true;
if (state.overlayManagedByLauncher && state.appPath) {
log('info', args.logLevel, 'Stopping SubMiner overlay...');
const stopArgs = ['--stop'];
if (args.logLevel !== 'info') stopArgs.push('--log-level', args.logLevel);
const result = spawnSync(state.appPath, stopArgs, {
stdio: 'ignore',
env: buildAppEnv(),
});
if (result.error) {
log('warn', args.logLevel, `Failed to stop SubMiner overlay: ${result.error.message}`);
} else if (typeof result.status === 'number' && result.status !== 0) {
log('warn', args.logLevel, `SubMiner overlay stop command exited with status ${result.status}`);
}
if (state.overlayProc && !state.overlayProc.killed) {
try {
state.overlayProc.kill('SIGTERM');
} catch {
// ignore
}
}
}
stopManagedOverlayApp(args);
if (state.mpvProc && !state.mpvProc.killed) {
try {
@@ -761,6 +751,8 @@ export function stopOverlay(args: Args): void {
}
export async function cleanupPlaybackSession(args: Args): Promise<void> {
stopManagedOverlayApp(args);
if (state.mpvProc && !state.mpvProc.killed) {
try {
state.mpvProc.kill('SIGTERM');
@@ -783,9 +775,40 @@ export async function cleanupPlaybackSession(args: Args): Promise<void> {
await terminateTrackedDetachedMpv(args.logLevel);
}
function stopManagedOverlayApp(args: Args): void {
if (!(state.overlayManagedByLauncher && state.appPath)) {
return;
}
log('info', args.logLevel, 'Stopping SubMiner overlay...');
const stopArgs = ['--stop'];
if (args.logLevel !== 'info') stopArgs.push('--log-level', args.logLevel);
const target = resolveAppSpawnTarget(state.appPath, stopArgs);
const result = spawnSync(target.command, target.args, {
stdio: 'ignore',
env: buildAppEnv(),
});
if (result.error) {
log('warn', args.logLevel, `Failed to stop SubMiner overlay: ${result.error.message}`);
} else if (typeof result.status === 'number' && result.status !== 0) {
log('warn', args.logLevel, `SubMiner overlay stop command exited with status ${result.status}`);
}
if (state.overlayProc && !state.overlayProc.killed) {
try {
state.overlayProc.kill('SIGTERM');
} catch {
// ignore
}
}
}
function buildAppEnv(): NodeJS.ProcessEnv {
const env: Record<string, string | undefined> = {
...process.env,
SUBMINER_APP_LOG: getAppLogPath(),
SUBMINER_MPV_LOG: getMpvLogPath(),
};
delete env.ELECTRON_RUN_AS_NODE;
@@ -804,6 +827,64 @@ function buildAppEnv(): NodeJS.ProcessEnv {
return env;
}
function appendCapturedAppOutput(kind: 'STDOUT' | 'STDERR', chunk: string): void {
const normalized = chunk.replace(/\r\n/g, '\n');
for (const line of normalized.split('\n')) {
if (!line) continue;
appendToAppLog(`[${kind}] ${line}`);
}
}
function attachAppProcessLogging(
proc: ReturnType<typeof spawn>,
options?: {
mirrorStdout?: boolean;
mirrorStderr?: boolean;
},
): void {
proc.stdout?.setEncoding('utf8');
proc.stderr?.setEncoding('utf8');
proc.stdout?.on('data', (chunk: string) => {
appendCapturedAppOutput('STDOUT', chunk);
if (options?.mirrorStdout) process.stdout.write(chunk);
});
proc.stderr?.on('data', (chunk: string) => {
appendCapturedAppOutput('STDERR', chunk);
if (options?.mirrorStderr) process.stderr.write(chunk);
});
}
function runSyncAppCommand(
appPath: string,
appArgs: string[],
mirrorOutput: boolean,
): {
status: number;
stdout: string;
stderr: string;
error?: Error;
} {
const target = resolveAppSpawnTarget(appPath, appArgs);
const result = spawnSync(target.command, target.args, {
env: buildAppEnv(),
encoding: 'utf8',
});
if (result.stdout) {
appendCapturedAppOutput('STDOUT', result.stdout);
if (mirrorOutput) process.stdout.write(result.stdout);
}
if (result.stderr) {
appendCapturedAppOutput('STDERR', result.stderr);
if (mirrorOutput) process.stderr.write(result.stderr);
}
return {
status: result.status ?? 1,
stdout: result.stdout ?? '',
stderr: result.stderr ?? '',
error: result.error ?? undefined,
};
}
function maybeCaptureAppArgs(appArgs: string[]): boolean {
const capturePath = process.env.SUBMINER_TEST_CAPTURE?.trim();
if (!capturePath) {
@@ -821,20 +902,23 @@ function resolveAppSpawnTarget(appPath: string, appArgs: string[]): SpawnTarget
return resolveCommandInvocation(appPath, appArgs);
}
export function runAppCommandWithInherit(appPath: string, appArgs: string[]): never {
export function runAppCommandWithInherit(appPath: string, appArgs: string[]): void {
if (maybeCaptureAppArgs(appArgs)) {
process.exit(0);
}
const target = resolveAppSpawnTarget(appPath, appArgs);
const result = spawnSync(target.command, target.args, {
stdio: 'inherit',
const proc = spawn(target.command, target.args, {
stdio: ['ignore', 'pipe', 'pipe'],
env: buildAppEnv(),
});
if (result.error) {
fail(`Failed to run app command: ${result.error.message}`);
}
process.exit(result.status ?? 0);
attachAppProcessLogging(proc, { mirrorStdout: true, mirrorStderr: true });
proc.once('error', (error) => {
fail(`Failed to run app command: ${error.message}`);
});
proc.once('close', (code) => {
process.exit(code ?? 0);
});
}
export function runAppCommandCaptureOutput(
@@ -854,18 +938,7 @@ export function runAppCommandCaptureOutput(
};
}
const target = resolveAppSpawnTarget(appPath, appArgs);
const result = spawnSync(target.command, target.args, {
env: buildAppEnv(),
encoding: 'utf8',
});
return {
status: result.status ?? 1,
stdout: result.stdout ?? '',
stderr: result.stderr ?? '',
error: result.error ?? undefined,
};
return runSyncAppCommand(appPath, appArgs, false);
}
export function runAppCommandAttached(
@@ -887,13 +960,14 @@ export function runAppCommandAttached(
return new Promise((resolve, reject) => {
const proc = spawn(target.command, target.args, {
stdio: 'inherit',
stdio: ['ignore', 'pipe', 'pipe'],
env: buildAppEnv(),
});
attachAppProcessLogging(proc, { mirrorStdout: true, mirrorStderr: true });
proc.once('error', (error) => {
reject(error);
});
proc.once('exit', (code, signal) => {
proc.once('close', (code, signal) => {
if (code !== null) {
resolve(code);
} else if (signal) {
@@ -921,10 +995,7 @@ export function runAppCommandWithInheritLogged(
logLevel,
`${label}: launching app with args: ${[target.command, ...target.args].join(' ')}`,
);
const result = spawnSync(target.command, target.args, {
stdio: 'inherit',
env: buildAppEnv(),
});
const result = runSyncAppCommand(appPath, appArgs, true);
if (result.error) {
fail(`Failed to run app command: ${result.error.message}`);
}
@@ -953,15 +1024,24 @@ export function launchAppCommandDetached(
logLevel,
`${label}: launching detached app with args: ${[target.command, ...target.args].join(' ')}`,
);
const proc = spawn(target.command, target.args, {
stdio: 'ignore',
detached: true,
env: buildAppEnv(),
});
proc.once('error', (error) => {
log('warn', logLevel, `${label}: failed to launch detached app: ${error.message}`);
});
proc.unref();
const appLogPath = getAppLogPath();
fs.mkdirSync(path.dirname(appLogPath), { recursive: true });
const stdoutFd = fs.openSync(appLogPath, 'a');
const stderrFd = fs.openSync(appLogPath, 'a');
try {
const proc = spawn(target.command, target.args, {
stdio: ['ignore', stdoutFd, stderrFd],
detached: true,
env: buildAppEnv(),
});
proc.once('error', (error) => {
log('warn', logLevel, `${label}: failed to launch detached app: ${error.message}`);
});
proc.unref();
} finally {
fs.closeSync(stdoutFd);
fs.closeSync(stderrFd);
}
}
export function launchMpvIdleDetached(

View File

@@ -310,6 +310,7 @@ test(
const appStartPath = path.join(smokeCase.artifactsDir, 'fake-app-start.log');
const appStopPath = path.join(smokeCase.artifactsDir, 'fake-app-stop.log');
await waitForJsonLines(appStartPath, 1);
await waitForJsonLines(appStopPath, 1);
const appStartEntries = readJsonLines(appStartPath);
const appStopEntries = readJsonLines(appStopPath);
@@ -324,7 +325,7 @@ test(
assert.match(result.stdout, /Starting SubMiner overlay/i);
assert.equal(appStartEntries.length, 1);
assert.equal(appStopEntries.length, 0);
assert.equal(appStopEntries.length, 1);
assert.equal(mpvEntries.length >= 1, true);
const appStartArgs = appStartEntries[0]?.argv;

View File

@@ -1,5 +1,6 @@
import path from 'node:path';
import os from 'node:os';
import { resolveDefaultLogFilePath } from '../src/shared/log-files.js';
export { VIDEO_EXTENSIONS } from '../src/shared/video-extensions.js';
export const ROFI_THEME_FILE = 'subminer.rasi';
@@ -29,21 +30,28 @@ export const DEFAULT_YOUTUBE_SUBGEN_OUT_DIR = path.join(
'subminer',
'youtube-subs',
);
export function getDefaultLauncherLogFile(options?: {
platform?: NodeJS.Platform;
homeDir?: string;
appDataDir?: string;
}): string {
return resolveDefaultLogFilePath('launcher', {
platform: options?.platform ?? process.platform,
homeDir: options?.homeDir ?? os.homedir(),
appDataDir: options?.appDataDir,
});
}
export function getDefaultMpvLogFile(options?: {
platform?: NodeJS.Platform;
homeDir?: string;
appDataDir?: string;
}): string {
const platform = options?.platform ?? process.platform;
const homeDir = options?.homeDir ?? os.homedir();
const baseDir =
platform === 'win32'
? path.join(
options?.appDataDir?.trim() || path.join(homeDir, 'AppData', 'Roaming'),
'SubMiner',
)
: path.join(homeDir, '.config', 'SubMiner');
return path.join(baseDir, 'logs', `SubMiner-${new Date().toISOString().slice(0, 10)}.log`);
return resolveDefaultLogFilePath('mpv', {
platform: options?.platform ?? process.platform,
homeDir: options?.homeDir ?? os.homedir(),
appDataDir: options?.appDataDir,
});
}
export const DEFAULT_MPV_LOG_FILE = getDefaultMpvLogFile();
@@ -79,6 +87,7 @@ export interface Args {
recursive: boolean;
profile: string;
startOverlay: boolean;
youtubeMode?: 'download' | 'generate';
whisperBin: string;
whisperModel: string;
whisperVadModel: string;