feat(youtube): add YouTube browser window and Whisper subtitle source (#273)

This commit is contained in:
sudacode authored and GitHub committed 2026-09-28 20:35:47 -07:00
1 parent c68b73c8c1
commit 07b32626a6
101 files changed
+2269 -726

No files matched your search

+8 -7
View File
@@ -97,15 +97,9 @@ test('parseArgs captures launch-mpv targets and keeps it out of app startup', ()
});
test('parseArgs captures youtube startup forwarding flags', () => {
const args = parseArgs([
'--youtube-play',
'https://youtube.com/watch?v=abc',
'--youtube-mode',
'generate',
]);
const args = parseArgs(['--youtube-play', 'https://youtube.com/watch?v=abc']);
assert.equal(args.youtubePlay, 'https://youtube.com/watch?v=abc');
assert.equal(args.youtubeMode, 'generate');
assert.equal(hasExplicitCommand(args), true);
assert.equal(shouldStartApp(args), true);
});
@@ -266,6 +260,13 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
assert.equal(commandNeedsOverlayRuntime(settings), false);
assert.equal(commandNeedsOverlayStartupPrereqs(settings), false);
const youtubeBrowser = parseArgs(['--youtube-browser']);
assert.equal(youtubeBrowser.youtubeBrowser, true);
assert.equal(hasExplicitCommand(youtubeBrowser), true);
assert.equal(shouldStartApp(youtubeBrowser), true);
assert.equal(shouldRunYomitanOnlyStartup(youtubeBrowser), false);
assert.equal(commandNeedsOverlayRuntime(youtubeBrowser), false);
const yomitanWithOverlay = parseArgs(['--yomitan', '--toggle-visible-overlay']);
assert.equal(yomitanWithOverlay.yomitan, true);
assert.equal(yomitanWithOverlay.toggleVisibleOverlay, true);
+7 -8
View File
@@ -7,7 +7,6 @@ export interface CliArgs {
launchMpv: boolean;
launchMpvTargets: string[];
youtubePlay?: string;
youtubeMode?: 'download' | 'generate';
stop: boolean;
toggle: boolean;
toggleVisibleOverlay: boolean;
@@ -15,6 +14,7 @@ export interface CliArgs {
yomitan: boolean;
settings: boolean;
syncWindow: boolean;
youtubeBrowser: boolean;
setup: boolean;
show: boolean;
hide: boolean;
@@ -128,7 +128,6 @@ export function parseArgs(argv: string[]): CliArgs {
launchMpv: false,
launchMpvTargets: [],
youtubePlay: undefined,
youtubeMode: undefined,
stop: false,
toggle: false,
toggleVisibleOverlay: false,
@@ -136,6 +135,7 @@ export function parseArgs(argv: string[]): CliArgs {
yomitan: false,
settings: false,
syncWindow: false,
youtubeBrowser: false,
setup: false,
show: false,
hide: false,
@@ -270,12 +270,6 @@ export function parseArgs(argv: string[]): CliArgs {
} else if (arg === '--youtube-play') {
const value = readValue(argv[i + 1]);
if (value) args.youtubePlay = value;
} else if (arg.startsWith('--youtube-mode=')) {
const value = arg.split('=', 2)[1];
if (value === 'download' || value === 'generate') args.youtubeMode = value;
} else if (arg === '--youtube-mode') {
const value = readValue(argv[i + 1]);
if (value === 'download' || value === 'generate') args.youtubeMode = value;
} else if (arg === '--launch-mpv') {
args.launchMpv = true;
args.launchMpvTargets = argv.slice(i + 1).filter((value) => value && !value.startsWith('--'));
@@ -287,6 +281,7 @@ export function parseArgs(argv: string[]): CliArgs {
else if (arg === '--yomitan') args.yomitan = true;
else if (arg === '--settings') args.settings = true;
else if (arg === '--sync-window') args.syncWindow = true;
else if (arg === '--youtube-browser') args.youtubeBrowser = true;
else if (arg === '--setup') args.setup = true;
else if (arg === '--show') args.show = true;
else if (arg === '--hide') args.hide = true;
@@ -570,6 +565,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
args.yomitan ||
args.settings ||
args.syncWindow ||
args.youtubeBrowser ||
args.setup ||
args.show ||
args.hide ||
@@ -649,6 +645,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
!args.yomitan &&
!args.settings &&
!args.syncWindow &&
!args.youtubeBrowser &&
!args.setup &&
!args.show &&
!args.hide &&
@@ -721,6 +718,7 @@ export function shouldStartApp(args: CliArgs): boolean {
args.yomitan ||
args.settings ||
args.syncWindow ||
args.youtubeBrowser ||
args.setup ||
args.copySubtitle ||
args.copySubtitleMultiple ||
@@ -779,6 +777,7 @@ export function shouldRunYomitanOnlyStartup(args: CliArgs): boolean {
!args.togglePrimarySubtitleBar &&
!args.settings &&
!args.syncWindow &&
!args.youtubeBrowser &&
!args.show &&
!args.hide &&
!args.setup &&
+3
View File
@@ -79,6 +79,9 @@ ${B}Jellyfin${R}
--jellyfin-audio-stream-index ${D}N${R} Audio stream override
--jellyfin-subtitle-stream-index ${D}N${R} Subtitle stream override
${B}YouTube${R}
--youtube-browser Open the YouTube browser ${D}(videos play in mpv)${R}
${B}Stats sync${R}
--sync-window Open the stats sync window
--sync-cli sync ${D}[host] [opts]${R} Headless stats sync ${D}(same commands as "subminer sync";${R}
+24 -16
View File
@@ -1757,11 +1757,6 @@ test('parses global shortcuts and startup settings', () => {
},
"youtube": {
"primarySubLanguages": ["ja", "jpn", "jp"]
},
"youtubeSubgen": {
"whisperVadModel": "/models/vad.bin",
"whisperThreads": 12,
"fixWithAi": true
}
}`,
'utf-8',
@@ -1774,9 +1769,30 @@ test('parses global shortcuts and startup settings', () => {
assert.equal(config.shortcuts.toggleVisibleOverlayGlobal, 'Alt+Shift+U');
assert.equal(config.shortcuts.openJimaku, 'Ctrl+Alt+J');
assert.deepEqual(config.youtube.primarySubLanguages, ['ja', 'jpn', 'jp']);
assert.equal(config.youtubeSubgen.whisperVadModel, '/models/vad.bin');
assert.equal(config.youtubeSubgen.whisperThreads, 12);
assert.equal(config.youtubeSubgen.fixWithAi, true);
});
test('parses YouTube subtitle source and warns on invalid values', () => {
assert.equal(DEFAULT_CONFIG.youtube.subtitleSource, 'youtube');
const validDir = makeTempDir();
fs.writeFileSync(
path.join(validDir, 'config.jsonc'),
`{ "youtube": { "subtitleSource": "whisper" } }`,
'utf-8',
);
assert.equal(new ConfigService(validDir).getConfig().youtube.subtitleSource, 'whisper');
const invalidDir = makeTempDir();
fs.writeFileSync(
path.join(invalidDir, 'config.jsonc'),
`{ "youtube": { "subtitleSource": "asr" } }`,
'utf-8',
);
const invalidService = new ConfigService(invalidDir);
assert.equal(invalidService.getConfig().youtube.subtitleSource, 'youtube');
assert.ok(
invalidService.getWarnings().some((warning) => warning.path === 'youtube.subtitleSource'),
);
});
test('parses YouTube media cache config and warns on invalid values', () => {
@@ -2577,12 +2593,6 @@ test('accepts per-feature ai overrides for anki and YouTube subtitles', () => {
"model": "openrouter/anki-model",
"systemPrompt": "Translate mined sentence text."
}
},
"youtubeSubgen": {
"ai": {
"model": "openrouter/subgen-model",
"systemPrompt": "Fix subtitle mistakes only."
}
}
}`,
'utf-8',
@@ -2596,8 +2606,6 @@ test('accepts per-feature ai overrides for anki and YouTube subtitles', () => {
assert.equal(config.ankiConnect.ai.enabled, true);
assert.equal(config.ankiConnect.ai.model, 'openrouter/anki-model');
assert.equal(config.ankiConnect.ai.systemPrompt, 'Translate mined sentence text.');
assert.equal(config.youtubeSubgen.ai.model, 'openrouter/subgen-model');
assert.equal(config.youtubeSubgen.ai.systemPrompt, 'Fix subtitle mistakes only.');
});
test('warns and falls back when ankiConnect.ai override values are invalid', () => {
-2
View File
@@ -49,7 +49,6 @@ const {
jellyfin,
discordPresence,
ai,
youtubeSubgen,
} = INTEGRATIONS_DEFAULT_CONFIG;
const { subtitleStyle, subtitleSidebar } = SUBTITLE_DEFAULT_CONFIG;
const { immersionTracking } = IMMERSION_DEFAULT_CONFIG;
@@ -85,7 +84,6 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
jellyfin,
discordPresence,
ai,
youtubeSubgen,
immersionTracking,
stats,
};
+1
View File
@@ -115,6 +115,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
},
youtube: {
primarySubLanguages: ['ja', 'jpn'],
subtitleSource: 'youtube',
mediaCache: {
mode: 'direct',
maxHeight: 720,
@@ -13,7 +13,6 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
| 'jellyfin'
| 'discordPresence'
| 'ai'
| 'youtubeSubgen'
> = {
ankiConnect: {
enabled: true,
@@ -179,15 +178,4 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
'You are a translation engine. Return only the translated text with no explanations.',
requestTimeoutMs: 15_000,
},
youtubeSubgen: {
whisperBin: '',
whisperModel: '',
whisperVadModel: '',
whisperThreads: 4,
fixWithAi: false,
ai: {
model: '',
systemPrompt: '',
},
},
};
@@ -85,13 +85,6 @@ const UNDOCUMENTED_LEAVES: ReadonlySet<string> = new Set([
'subtitleStyle.textShadow',
'subtitleStyle.WebkitTextStroke',
'subtitleStyle.wordSpacing',
'youtubeSubgen.ai.model',
'youtubeSubgen.ai.systemPrompt',
'youtubeSubgen.fixWithAi',
'youtubeSubgen.whisperBin',
'youtubeSubgen.whisperModel',
'youtubeSubgen.whisperThreads',
'youtubeSubgen.whisperVadModel',
]);
test('config option registry includes critical paths and has unique entries', () => {
+12
View File
@@ -119,6 +119,18 @@ export function buildCoreConfigOptionRegistry(
description:
'Comma-separated primary subtitle language priority for managed subtitle auto-selection.',
},
{
path: 'youtube.subtitleSource',
kind: 'enum',
enumValues: ['youtube', 'whisper'],
enumLabels: {
youtube: 'Download YouTube captions',
whisper: 'Generate with Whisper',
},
defaultValue: defaultConfig.youtube.subtitleSource,
description:
'Where primary YouTube subtitles come from. Whisper transcribes the audio locally using the subtitleGeneration settings.',
},
{
path: 'youtube.mediaCache.mode',
kind: 'enum',
+3 -1
View File
@@ -183,7 +183,9 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
description: [
'Defaults for managed subtitle language preferences and YouTube subtitle loading.',
],
notes: ['Hot-reload: primarySubLanguages applies to the next YouTube subtitle load.'],
notes: [
'Hot-reload: primarySubLanguages and subtitleSource apply to the next YouTube subtitle load.',
],
key: 'youtube',
},
{
+1
View File
@@ -21,6 +21,7 @@ const HOT_RELOAD_EXACT_OR_PREFIX_PATHS = [
'logging.rotation',
'logging.files',
'youtube.primarySubLanguages',
'youtube.subtitleSource',
'ankiConnect.deck',
'ankiConnect.media.normalizeAudio',
'ankiConnect.media.mirrorMpvVolume',
+12
View File
@@ -339,6 +339,18 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
);
}
const subtitleSource = src.youtube.subtitleSource;
if (subtitleSource === 'youtube' || subtitleSource === 'whisper') {
resolved.youtube.subtitleSource = subtitleSource;
} else if (subtitleSource !== undefined) {
warn(
'youtube.subtitleSource',
subtitleSource,
resolved.youtube.subtitleSource,
"Expected 'youtube' or 'whisper'.",
);
}
if (isObject(src.youtube.mediaCache)) {
const mode = src.youtube.mediaCache.mode;
if (mode === 'direct' || mode === 'background') {
-99
View File
@@ -118,105 +118,6 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
}
}
if (isObject(src.youtubeSubgen)) {
const whisperBin = asString(src.youtubeSubgen.whisperBin);
if (whisperBin !== undefined) {
resolved.youtubeSubgen.whisperBin = whisperBin;
} else if (src.youtubeSubgen.whisperBin !== undefined) {
warn(
'youtubeSubgen.whisperBin',
src.youtubeSubgen.whisperBin,
resolved.youtubeSubgen.whisperBin,
'Expected string.',
);
}
const whisperModel = asString(src.youtubeSubgen.whisperModel);
if (whisperModel !== undefined) {
resolved.youtubeSubgen.whisperModel = whisperModel;
} else if (src.youtubeSubgen.whisperModel !== undefined) {
warn(
'youtubeSubgen.whisperModel',
src.youtubeSubgen.whisperModel,
resolved.youtubeSubgen.whisperModel,
'Expected string.',
);
}
const whisperVadModel = asString(src.youtubeSubgen.whisperVadModel);
if (whisperVadModel !== undefined) {
resolved.youtubeSubgen.whisperVadModel = whisperVadModel;
} else if (src.youtubeSubgen.whisperVadModel !== undefined) {
warn(
'youtubeSubgen.whisperVadModel',
src.youtubeSubgen.whisperVadModel,
resolved.youtubeSubgen.whisperVadModel,
'Expected string.',
);
}
const whisperThreads = asNumber(src.youtubeSubgen.whisperThreads);
if (whisperThreads !== undefined && Number.isInteger(whisperThreads) && whisperThreads > 0) {
resolved.youtubeSubgen.whisperThreads = whisperThreads;
} else if (src.youtubeSubgen.whisperThreads !== undefined) {
warn(
'youtubeSubgen.whisperThreads',
src.youtubeSubgen.whisperThreads,
resolved.youtubeSubgen.whisperThreads,
'Expected positive integer.',
);
}
const fixWithAi = asBoolean(src.youtubeSubgen.fixWithAi);
if (fixWithAi !== undefined) {
resolved.youtubeSubgen.fixWithAi = fixWithAi;
} else if (src.youtubeSubgen.fixWithAi !== undefined) {
warn(
'youtubeSubgen.fixWithAi',
src.youtubeSubgen.fixWithAi,
resolved.youtubeSubgen.fixWithAi,
'Expected boolean.',
);
}
if (isObject(src.youtubeSubgen.ai)) {
const aiModel = asString(src.youtubeSubgen.ai.model);
if (aiModel !== undefined) {
resolved.youtubeSubgen.ai.model = aiModel;
} else if (src.youtubeSubgen.ai.model !== undefined) {
warn(
'youtubeSubgen.ai.model',
src.youtubeSubgen.ai.model,
resolved.youtubeSubgen.ai.model,
'Expected string.',
);
}
const aiSystemPrompt = asString(src.youtubeSubgen.ai.systemPrompt);
if (aiSystemPrompt !== undefined) {
resolved.youtubeSubgen.ai.systemPrompt = aiSystemPrompt;
} else if (src.youtubeSubgen.ai.systemPrompt !== undefined) {
warn(
'youtubeSubgen.ai.systemPrompt',
src.youtubeSubgen.ai.systemPrompt,
resolved.youtubeSubgen.ai.systemPrompt,
'Expected string.',
);
}
} else if (src.youtubeSubgen.ai !== undefined) {
warn('youtubeSubgen.ai', src.youtubeSubgen.ai, resolved.youtubeSubgen.ai, 'Expected object.');
}
if (src.youtubeSubgen.primarySubLanguages !== undefined) {
warn(
'youtubeSubgen.primarySubLanguages',
src.youtubeSubgen.primarySubLanguages,
undefined,
'Removed. Use youtube.primarySubLanguages instead.',
);
}
}
if (isObject(src.subtitleStyle)) {
const fallbackSubtitleStyleEnableJlpt = resolved.subtitleStyle.enableJlpt;
const fallbackSubtitleStylePrimaryDefaultMode = resolved.subtitleStyle.primaryDefaultMode;
-1
View File
@@ -328,7 +328,6 @@ test('settings registry hides app-managed and inactive config surfaces', () => {
'controller.preferredGamepadId',
'controller.preferredGamepadLabel',
'controller.profiles',
'youtubeSubgen.whisperBin',
'jellyfin.defaultLibraryId',
'subtitleSidebar.toggleKey',
'jellyfin.recentServers',
+2 -11
View File
@@ -61,7 +61,6 @@ export const LEGACY_HIDDEN_CONFIG_PATHS = [
'controller.preferredGamepadId',
'controller.preferredGamepadLabel',
'controller.profiles',
'youtubeSubgen.primarySubLanguages',
'anilist.characterDictionary.enabled',
'anilist.characterDictionary.refreshTtlHours',
'anilist.characterDictionary.evictionPolicy',
@@ -76,12 +75,7 @@ export const LEGACY_HIDDEN_CONFIG_PATHS = [
'jellyfin.recentServers',
] as const;
const EXCLUDED_PREFIXES = [
'ai',
'ankiConnect.ai',
'controller.buttonIndices',
'youtubeSubgen',
] as const;
const EXCLUDED_PREFIXES = ['ai', 'ankiConnect.ai', 'controller.buttonIndices'] as const;
const JSON_OBJECT_FIELDS = new Set([
'keybindings',
@@ -525,7 +519,6 @@ function topSection(path: string): string {
websocket: 'WebSocket server',
yomitan: 'Yomitan',
youtube: 'YouTube Playback Settings',
youtubeSubgen: 'YouTube subtitle generation',
auto_start_overlay: 'Playback Behavior',
};
return labels[top] ?? humanizePath(top);
@@ -725,9 +718,7 @@ function fieldForLeaf(leaf: Leaf): ConfigSettingsField {
...(option?.enumLabels ? { enumLabels: option.enumLabels } : {}),
restartBehavior: getConfigHotReloadField(leaf.path) ? 'hot-reload' : 'restart',
advanced:
leaf.path.startsWith('controller.') ||
leaf.path.startsWith('immersionTracking.retention.') ||
leaf.path.startsWith('youtubeSubgen.'),
leaf.path.startsWith('controller.') || leaf.path.startsWith('immersionTracking.retention.'),
secret: SECRET_PATHS.has(leaf.path),
settingsHidden: SUBTITLE_CSS_MANAGED_CONFIG_PATHS.has(leaf.path),
};
-7
View File
@@ -21,13 +21,6 @@ const SUBTITLE_CSS_SCOPES: SubtitleCssScope[] = ['primary', 'secondary', 'sideba
const HIDDEN_TEMPLATE_PATHS = [
'anilist.characterDictionary.evictionPolicy',
'anilist.characterDictionary.refreshTtlHours',
'youtubeSubgen.ai.model',
'youtubeSubgen.ai.systemPrompt',
'youtubeSubgen.fixWithAi',
'youtubeSubgen.whisperBin',
'youtubeSubgen.whisperModel',
'youtubeSubgen.whisperThreads',
'youtubeSubgen.whisperVadModel',
];
function normalizeCommentText(value: string): string {
+1
View File
@@ -17,6 +17,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
yomitan: false,
settings: false,
syncWindow: false,
youtubeBrowser: false,
setup: false,
show: false,
hide: false,
+17 -31
View File
@@ -15,13 +15,13 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
launchMpv: false,
launchMpvTargets: [],
youtubePlay: undefined,
youtubeMode: undefined,
stop: false,
toggle: false,
toggleVisibleOverlay: false,
yomitan: false,
settings: false,
syncWindow: false,
youtubeBrowser: false,
setup: false,
show: false,
hide: false,
@@ -142,6 +142,9 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
openSyncUiWindow: () => {
calls.push('openSyncUiWindow');
},
openYoutubeBrowserWindow: () => {
calls.push('openYoutubeBrowserWindow');
},
openFirstRunSetup: (force?: boolean) => {
calls.push(`openFirstRunSetup:${force === true ? 'force' : 'default'}`);
},
@@ -243,7 +246,7 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
calls.push('runJellyfinCommand');
},
runYoutubePlaybackFlow: async (request) => {
calls.push(`runYoutubePlaybackFlow:${request.url}:${request.mode}:${request.source}`);
calls.push(`runYoutubePlaybackFlow:${request.url}:${request.source}`);
},
runUpdateCommand: async (args) => {
calls.push(`runUpdateCommand:${args.updateLauncherPath ?? ''}`);
@@ -283,46 +286,30 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
test('handleCliCommand starts youtube playback flow on initial launch', () => {
const { deps, calls } = createDeps({
runYoutubePlaybackFlow: async (request) => {
calls.push(`youtube:${request.url}:${request.mode}`);
},
});
handleCliCommand(
makeArgs({ youtubePlay: 'https://youtube.com/watch?v=abc', youtubeMode: 'generate' }),
'initial',
deps,
);
assert.deepEqual(calls, ['youtube:https://youtube.com/watch?v=abc:generate']);
});
test('handleCliCommand defaults youtube mode to download when omitted', () => {
const { deps, calls } = createDeps({
runYoutubePlaybackFlow: async (request) => {
calls.push(`youtube:${request.url}:${request.mode}`);
calls.push(`youtube:${request.url}:${request.source}`);
},
});
handleCliCommand(makeArgs({ youtubePlay: 'https://youtube.com/watch?v=abc' }), 'initial', deps);
assert.deepEqual(calls, ['youtube:https://youtube.com/watch?v=abc:download']);
assert.deepEqual(calls, ['youtube:https://youtube.com/watch?v=abc:initial']);
});
test('handleCliCommand reuses initialized overlay runtime for second-instance youtube playback', () => {
const { deps, calls } = createDeps({
isOverlayRuntimeInitialized: () => true,
runYoutubePlaybackFlow: async (request) => {
calls.push(`youtube:${request.url}:${request.mode}:${request.source}`);
calls.push(`youtube:${request.url}:${request.source}`);
},
});
handleCliCommand(
makeArgs({ youtubePlay: 'https://youtube.com/watch?v=abc', youtubeMode: 'download' }),
makeArgs({ youtubePlay: 'https://youtube.com/watch?v=abc' }),
'second-instance',
deps,
);
assert.deepEqual(calls, ['youtube:https://youtube.com/watch?v=abc:download:second-instance']);
assert.deepEqual(calls, ['youtube:https://youtube.com/watch?v=abc:second-instance']);
});
test('handleCliCommand reports youtube playback flow failures to logs and OSD', async () => {
@@ -332,11 +319,7 @@ test('handleCliCommand reports youtube playback flow failures to logs and OSD',
},
});
handleCliCommand(
makeArgs({ youtubePlay: 'https://youtube.com/watch?v=abc', youtubeMode: 'download' }),
'initial',
deps,
);
handleCliCommand(makeArgs({ youtubePlay: 'https://youtube.com/watch?v=abc' }), 'initial', deps);
await new Promise((resolve) => setImmediate(resolve));
assert.ok(calls.some((value) => value.startsWith('error:runYoutubePlaybackFlow failed:')));
@@ -676,6 +659,7 @@ test('createCliCommandDepsRuntime reconnects MPV client when reconnect hook exis
openYomitanSettings: () => {},
openConfigSettingsWindow: () => {},
openSyncUiWindow: () => {},
openYoutubeBrowserWindow: () => {},
cycleSecondarySubMode: () => {},
openRuntimeOptionsPalette: () => {},
printHelp: () => {},
@@ -789,6 +773,7 @@ test('handleCliCommand handles visibility and utility command dispatches', () =>
}> = [
{ args: { yomitan: true }, expected: 'openYomitanSettingsDelayed:1000' },
{ args: { settings: true }, expected: 'openConfigSettingsWindow' },
{ args: { youtubeBrowser: true }, expected: 'openYoutubeBrowserWindow' },
{
args: { showVisibleOverlay: true },
expected: 'setVisibleOverlayVisible:true',
@@ -822,9 +807,10 @@ test('handleCliCommand handles visibility and utility command dispatches', () =>
for (const entry of cases) {
const { deps, calls } = createDeps();
handleCliCommand(makeArgs(entry.args), 'initial', deps);
assert.ok(
calls.includes(entry.expected),
`expected call missing for args ${JSON.stringify(entry.args)}: ${entry.expected}`,
assert.equal(
calls.filter((value) => value === entry.expected).length,
1,
`expected exactly one call for args ${JSON.stringify(entry.args)}: ${entry.expected}`,
);
}
});
+6 -6
View File
@@ -45,6 +45,7 @@ export interface CliCommandServiceDeps {
openYomitanSettingsDelayed: (delayMs: number) => void;
openConfigSettingsWindow: () => void;
openSyncUiWindow: () => void;
openYoutubeBrowserWindow: () => void;
setVisibleOverlayVisible: (visible: boolean) => void;
copyCurrentSubtitle: () => void;
startPendingMultiCopy: (timeoutMs: number) => void;
@@ -102,11 +103,7 @@ export interface CliCommandServiceDeps {
args: CliArgs,
source: CliCommandSource,
) => Promise<void>;
runYoutubePlaybackFlow: (request: {
url: string;
mode: NonNullable<CliArgs['youtubeMode']>;
source: CliCommandSource;
}) => Promise<void>;
runYoutubePlaybackFlow: (request: { url: string; source: CliCommandSource }) => Promise<void>;
ensureBackgroundStatsServer?: () => Promise<void> | void;
printHelp: () => void;
hasMainWindow: () => boolean;
@@ -172,6 +169,7 @@ interface UiCliRuntime {
openYomitanSettings: () => void;
openConfigSettingsWindow: () => void;
openSyncUiWindow: () => void;
openYoutubeBrowserWindow: () => void;
cycleSecondarySubMode: () => void;
openRuntimeOptionsPalette: () => void;
printHelp: () => void;
@@ -277,6 +275,7 @@ export function createCliCommandDepsRuntime(
},
openConfigSettingsWindow: options.ui.openConfigSettingsWindow,
openSyncUiWindow: options.ui.openSyncUiWindow,
openYoutubeBrowserWindow: options.ui.openYoutubeBrowserWindow,
setVisibleOverlayVisible: options.overlay.setVisible,
copyCurrentSubtitle: options.mining.copyCurrentSubtitle,
startPendingMultiCopy: options.mining.startPendingMultiCopy,
@@ -429,6 +428,8 @@ export function handleCliCommand(
deps.openConfigSettingsWindow();
} else if (args.syncWindow) {
deps.openSyncUiWindow();
} else if (args.youtubeBrowser) {
deps.openYoutubeBrowserWindow();
} else if (args.show || args.showVisibleOverlay) {
deps.setVisibleOverlayVisible(true);
} else if (args.hide || args.hideVisibleOverlay) {
@@ -639,7 +640,6 @@ export function handleCliCommand(
() =>
deps.runYoutubePlaybackFlow({
url: youtubeUrl,
mode: args.youtubeMode ?? 'download',
source,
}),
deps,
@@ -17,6 +17,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
yomitan: false,
settings: false,
syncWindow: false,
youtubeBrowser: false,
setup: false,
show: false,
hide: false,
@@ -9,6 +9,8 @@ export function runSubtitleGenerationProcess(input: {
args: string[];
signal?: AbortSignal;
onLine?: (line: string) => void;
/** Shown when the executable is missing; defaults to pointing at subtitleGeneration settings. */
missingMessage?: string;
}): Promise<string> {
input.signal?.throwIfAborted();
return new Promise((resolve, reject) => {
@@ -51,7 +53,8 @@ export function runSubtitleGenerationProcess(input: {
reject(
new Error(
'code' in error && error.code === 'ENOENT'
? `${input.command} was not found. Install it or set its path under subtitleGeneration in Settings.`
? (input.missingMessage ??
`${input.command} was not found. Install it or set its path under subtitleGeneration in Settings.`)
: `Could not run ${input.command}: ${error.message}`,
),
);
+26 -10
View File
@@ -173,6 +173,21 @@ async function writeSubtitles(input: {
}
}
/** Resolve the model and tools, throwing the message to show when generation cannot run. */
export async function requireSubtitleGenerationSetup(
config: SubtitleGenerationConfig,
modelDirectory: string,
) {
const model = await resolveSubtitleGenerationModel(config, modelDirectory);
if (model.kind === 'missing')
throw new Error(
'No Whisper model found. Download a model or configure an existing model path.',
);
if (model.kind === 'invalid') throw new Error(model.message);
const tools = requireSubtitleGenerationTools(await resolveSubtitleGenerationTools(config));
return { modelPath: model.path, tools };
}
export async function generateJapaneseSubtitles(input: {
config: SubtitleGenerationConfig;
modelDirectory: string;
@@ -180,6 +195,8 @@ export async function generateJapaneseSubtitles(input: {
audioStreamIndex?: number;
references?: readonly SubtitleGenerationReference[];
outputPath?: string;
/** Parent for the scratch directory holding the extracted WAV; the system temp dir by default. */
workDirectory?: string;
onProgress?: (progress: SubtitleGenerationProgress) => void;
signal?: AbortSignal;
}): Promise<string> {
@@ -193,13 +210,10 @@ export async function generateJapaneseSubtitles(input: {
await ensureWritableDirectory(
input.outputPath ? path.dirname(path.resolve(input.outputPath)) : path.dirname(mediaPath),
);
const model = await resolveSubtitleGenerationModel(input.config, input.modelDirectory);
if (model.kind === 'missing')
throw new Error(
'No Whisper model found. Download a model or configure an existing model path.',
);
if (model.kind === 'invalid') throw new Error(model.message);
const tools = requireSubtitleGenerationTools(await resolveSubtitleGenerationTools(input.config));
const { modelPath, tools } = await requireSubtitleGenerationSetup(
input.config,
input.modelDirectory,
);
input.onProgress?.({ stage: 'extract', message: 'Inspecting audio tracks...' });
const probe = await runSubtitleGenerationProcess({
command: tools.ffprobe,
@@ -215,7 +229,9 @@ export async function generateJapaneseSubtitles(input: {
signal: input.signal,
});
const audio = parseAudioProbe(probe, input.audioStreamIndex);
const temporaryDirectory = await mkdtemp(path.join(tmpdir(), 'subminer-whisper-'));
const temporaryDirectory = await mkdtemp(
path.join(input.workDirectory ?? tmpdir(), 'subminer-whisper-'),
);
try {
const wavPath = path.join(temporaryDirectory, 'audio.wav');
const subtitleBase = path.join(temporaryDirectory, 'subtitles');
@@ -277,7 +293,7 @@ export async function generateJapaneseSubtitles(input: {
config: input.config,
tools,
referenceStarts,
modelPath: model.path,
modelPath,
wavPath,
directory: temporaryDirectory,
signal: input.signal,
@@ -288,7 +304,7 @@ export async function generateJapaneseSubtitles(input: {
command: tools.whisper,
args: [
'-m',
model.path,
modelPath,
'-f',
wavPath,
'-l',
@@ -478,6 +478,35 @@ test('downloadYoutubeSubtitleTrack prefers direct download URL when available',
});
});
test('downloadYoutubeSubtitleTrack falls back to yt-dlp when the direct URL is rejected', async () => {
if (process.platform === 'win32') {
return;
}
await withFakeYtDlp('both', async (root) => {
await withStubFetch(
async () => new Response('Too Many Requests', { status: 429 }),
async () => {
const result = await downloadYoutubeSubtitleTrack({
targetUrl: 'https://www.youtube.com/watch?v=abc123',
outputDir: path.join(root, 'out'),
track: {
id: 'auto:ja',
language: 'ja',
sourceLanguage: 'ja',
kind: 'auto',
label: 'Japanese (auto)',
downloadUrl: 'https://www.youtube.com/api/timedtext?v=abc123&lang=ja&fmt=vtt',
fileExtension: 'vtt',
},
});
assert.equal(path.extname(result.path), '.vtt');
},
);
});
});
test('downloadYoutubeSubtitleTrack sanitizes metadata source language in filenames', async () => {
await withTempDir(async (root) => {
await withStubFetch(
+10 -5
View File
@@ -221,11 +221,16 @@ export async function downloadYoutubeSubtitleTrack(input: {
}
}
if (canDownloadSubtitleFromUrl(input.track)) {
return await downloadSubtitleFromUrl({
outputDir: input.outputDir,
prefix,
track: input.track,
});
try {
return await downloadSubtitleFromUrl({
outputDir: input.outputDir,
prefix,
track: input.track,
});
} catch {
// YouTube rejects some direct timedtext requests (HTTP 429) that yt-dlp still gets
// through with its own client handling, so retry the same track via yt-dlp.
}
}
const outputTemplate = path.join(input.outputDir, `${prefix}.%(ext)s`);
const args = [
@@ -116,6 +116,31 @@ test('probeYoutubeTracks prefers srv3 over vtt for automatic captions', async ()
);
});
test('probeYoutubeTracks skips machine-translated caption URLs when the real track exists', async () => {
const translated = 'https://www.youtube.com/api/timedtext?v=abc123&lang=en-US&tlang=ja&fmt=srv3';
const original = 'https://www.youtube.com/api/timedtext?v=abc123&lang=ja&fmt=srv3';
await withFakeYtDlp(
{
id: 'abc123',
title: 'Example',
automatic_captions: {
ja: [
{ ext: 'srv3', url: translated, name: 'Japanese' },
{ ext: 'srv3', url: original, name: 'Japanese' },
],
fr: [{ ext: 'srv3', url: `${translated}&x=fr`, name: 'French' }],
},
},
async () => {
const result = await probeYoutubeTracks('https://www.youtube.com/watch?v=abc123');
const byLanguage = new Map(result.tracks.map((track) => [track.sourceLanguage, track]));
assert.equal(byLanguage.get('ja')?.downloadUrl, original);
// A translation-only language still gets a track rather than disappearing.
assert.equal(byLanguage.get('fr')?.downloadUrl, `${translated}&x=fr`);
},
);
});
test('probeYoutubeTracks honors SUBMINER_YTDLP_BIN when yt-dlp is not on PATH', async () => {
if (process.platform === 'win32') {
return;
+16 -1
View File
@@ -56,10 +56,25 @@ function runCapture(
});
}
// YouTube machine-translates captions on request (`tlang`) and lists them under the target
// language, next to the real track. Those URLs reliably fail with HTTP 429, so they are only
// used when a language has nothing else.
function isTranslatedCaptionUrl(url: string): boolean {
try {
return new URL(url).searchParams.has('tlang');
} catch {
return false;
}
}
function choosePreferredFormat(
formats: YtDlpSubtitleEntry,
allFormats: YtDlpSubtitleEntry,
kind: YoutubeTrackKind,
): { ext: string; url: string; title?: string } | null {
const untranslated = allFormats.filter(
(format) => typeof format.url === 'string' && !isTranslatedCaptionUrl(format.url),
);
const formats = untranslated.length > 0 ? untranslated : allFormats;
const preferredOrder =
kind === 'auto'
? ['srv3', 'srv2', 'srv1', 'vtt', 'srt', 'ttml', 'json3']
@@ -0,0 +1,168 @@
import assert from 'node:assert/strict';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import test from 'node:test';
import { DEFAULT_SUBTITLE_GENERATION_CONFIG } from '../../../shared/subtitle-generation';
import {
buildYoutubeWhisperAudioArgs,
createYoutubeWhisperSubtitleService,
parseYtDlpDownloadPercent,
YOUTUBE_WHISPER_AUDIO_DIR_PREFIX,
} from './whisper-subtitles';
const URL = 'https://www.youtube.com/watch?v=abcdefghijk';
function makeTempRoot(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-youtube-whisper-test-'));
}
function audioDirs(root: string): string[] {
return fs.readdirSync(root).filter((name) => name.startsWith(YOUTUBE_WHISPER_AUDIO_DIR_PREFIX));
}
/** Stands in for yt-dlp: writes the file its `-o` template names and reports progress. */
function fakeYtDlp(calls: string[][]) {
return async (input: { args: string[]; onLine?: (line: string) => void }) => {
calls.push(input.args);
const template = input.args[input.args.indexOf('-o') + 1]!;
fs.writeFileSync(template.replace('%(ext)s', 'm4a'), 'audio');
input.onLine?.('[download] 50.0% of 3.89MiB at 1.00MiB/s ETA 00:02');
return '';
};
}
function createService(root: string, overrides: Record<string, unknown> = {}) {
return createYoutubeWhisperSubtitleService({
getConfig: () => DEFAULT_SUBTITLE_GENERATION_CONFIG,
getModelDirectory: () => '/models',
tempRoot: root,
getYtDlpCommand: () => 'yt-dlp',
requireSetup: async () => ({
modelPath: '/models/ggml-small.bin',
tools: { ffmpeg: 'ffmpeg', ffprobe: 'ffprobe', whisper: 'whisper-cli', vad: null },
}),
...overrides,
});
}
test('audio download picks a small original-language stream and ignores user embeds', () => {
const args = buildYoutubeWhisperAudioArgs(URL, '/tmp/audio.%(ext)s');
assert.deepEqual(args.slice(args.indexOf('-f'), args.indexOf('-f') + 4), [
'-f',
'ba[abr>=48]/ba',
'-S',
'lang,+abr',
]);
for (const flag of ['--no-playlist', '--no-sponsorblock', '--no-embed-subs']) {
assert.ok(args.includes(flag), flag);
}
assert.equal(args.at(-1), URL);
});
test('parseYtDlpDownloadPercent reads yt-dlp progress lines only', () => {
assert.equal(parseYtDlpDownloadPercent('[download] 42.5% of 3.89MiB'), 42.5);
assert.equal(parseYtDlpDownloadPercent('[download] Destination: audio.m4a'), null);
});
test('generate transcribes the downloaded audio and deletes it afterwards', async () => {
const root = makeTempRoot();
const calls: string[][] = [];
const progress: string[] = [];
let transcribedPath = '';
const service = createService(root, {
runProcess: fakeYtDlp(calls),
generate: async (input: { mediaPath: string; outputPath: string; workDirectory?: string }) => {
transcribedPath = input.mediaPath;
assert.equal(fs.readFileSync(input.mediaPath, 'utf8'), 'audio');
// The extracted WAV must land where cleanup already looks.
assert.equal(input.workDirectory, path.dirname(input.mediaPath));
return input.outputPath;
},
});
const result = await service.generate({
url: URL,
outputPath: '/subs/youtube-whisper.ja.srt',
onProgress: (update) => progress.push(`${update.stage}:${update.percent}`),
});
assert.equal(result, '/subs/youtube-whisper.ja.srt');
assert.equal(calls.length, 1);
assert.equal(path.basename(transcribedPath), 'audio.m4a');
assert.deepEqual(progress, ['download:0', 'download:50']);
assert.deepEqual(audioDirs(root), []);
});
test('generate deletes the audio when transcription fails', async () => {
const root = makeTempRoot();
const service = createService(root, {
runProcess: fakeYtDlp([]),
generate: async () => {
throw new Error('whisper crashed');
},
});
await assert.rejects(
service.generate({ url: URL, outputPath: '/subs/x.srt' }),
/whisper crashed/,
);
assert.deepEqual(audioDirs(root), []);
});
test('generate skips the download when Whisper is not set up', async () => {
const root = makeTempRoot();
const calls: string[][] = [];
const service = createService(root, {
runProcess: fakeYtDlp(calls),
requireSetup: async () => {
throw new Error('No Whisper model found.');
},
});
await assert.rejects(
service.generate({ url: URL, outputPath: '/subs/x.srt' }),
/No Whisper model/,
);
assert.equal(calls.length, 0);
assert.deepEqual(audioDirs(root), []);
});
test('leftover audio from a crashed run is removed before the next download', async () => {
const root = makeTempRoot();
fs.mkdirSync(path.join(root, `${YOUTUBE_WHISPER_AUDIO_DIR_PREFIX}stale`));
let dirsDuringDownload: string[] = [];
const service = createService(root, {
runProcess: async (input: { args: string[] }) => {
dirsDuringDownload = audioDirs(root);
return fakeYtDlp([])(input);
},
generate: async (input: { outputPath: string }) => input.outputPath,
});
await service.generate({ url: URL, outputPath: '/subs/x.srt' });
assert.equal(dirsDuringDownload.length, 1);
assert.notEqual(dirsDuringDownload[0], `${YOUTUBE_WHISPER_AUDIO_DIR_PREFIX}stale`);
});
test('removeActiveAudio deletes audio for a job that is still running', async () => {
const root = makeTempRoot();
let release: (() => void) | null = null;
const service = createService(root, {
runProcess: fakeYtDlp([]),
generate: async (input: { outputPath: string }) => {
await new Promise<void>((resolve) => {
release = resolve;
});
return input.outputPath;
},
});
const job = service.generate({ url: URL, outputPath: '/subs/x.srt' });
while (!release) await new Promise((resolve) => setImmediate(resolve));
assert.equal(audioDirs(root).length, 1);
service.removeActiveAudio();
assert.deepEqual(audioDirs(root), []);
(release as () => void)();
await job;
});
@@ -0,0 +1,148 @@
import { mkdtemp, readdir, rm } from 'node:fs/promises';
import { rmSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import type {
SubtitleGenerationConfig,
SubtitleGenerationProgress,
} from '../../../shared/subtitle-generation';
import { generateJapaneseSubtitles, requireSubtitleGenerationSetup } from '../subtitle-generation';
import { runSubtitleGenerationProcess } from '../subtitle-generation-process';
import { getYoutubeYtDlpCommand, YTDLP_SINGLE_VIDEO_ARG } from './ytdlp-command';
export const YOUTUBE_WHISPER_AUDIO_DIR_PREFIX = 'subminer-youtube-audio-';
/**
* Whisper resamples everything to 16 kHz mono, so bitrate beyond plain speech clarity only costs
* download time. Take the smallest audio-only stream of at least 48 kbps, falling back to the
* smallest one. Sorting on `lang` first keeps the original track ahead of auto-dubbed ones.
*/
const AUDIO_FORMAT = 'ba[abr>=48]/ba';
const AUDIO_SORT = 'lang,+abr';
export function buildYoutubeWhisperAudioArgs(url: string, outputTemplate: string): string[] {
return [
YTDLP_SINGLE_VIDEO_ARG,
'--no-warnings',
'--newline',
// Override user yt-dlp config that would change the audio or add requests: SponsorBlock
// removal shifts every subtitle after a cut, and subtitle embedding hits the timedtext API.
'--no-sponsorblock',
'--no-write-subs',
'--no-write-auto-subs',
'--no-embed-subs',
'--no-write-thumbnail',
'--no-embed-thumbnail',
'--no-embed-metadata',
'--no-embed-chapters',
'-f',
AUDIO_FORMAT,
'-S',
AUDIO_SORT,
'-o',
outputTemplate,
url,
];
}
export function parseYtDlpDownloadPercent(line: string): number | null {
const match = /^\[download\]\s+(\d+(?:\.\d+)?)%/.exec(line.trim());
return match ? Math.min(100, Number(match[1])) : null;
}
async function findDownloadedAudio(directory: string): Promise<string> {
const names = await readdir(directory);
const audio = names.find((name) => name.startsWith('audio.') && !/\.(part|ytdl)$/.test(name));
if (!audio) throw new Error('yt-dlp finished without saving the audio.');
return path.join(directory, audio);
}
export type YoutubeWhisperSubtitleServiceDeps = {
getConfig: () => SubtitleGenerationConfig;
getModelDirectory: () => string;
tempRoot?: string;
getYtDlpCommand?: () => string;
requireSetup?: typeof requireSubtitleGenerationSetup;
runProcess?: typeof runSubtitleGenerationProcess;
generate?: typeof generateJapaneseSubtitles;
};
/**
* Generates Japanese subtitles for a YouTube video by transcribing its audio with Whisper.
* The audio lives in a private temp directory that is removed as soon as generation ends,
* whether it succeeds, fails, or is aborted.
*/
export function createYoutubeWhisperSubtitleService(deps: YoutubeWhisperSubtitleServiceDeps) {
const tempRoot = deps.tempRoot ?? os.tmpdir();
const activeDirs = new Set<string>();
// Audio directories only outlive a job when the app crashes mid-generation.
const removeStaleAudioDirs = async (): Promise<void> => {
const names = await readdir(tempRoot).catch(() => []);
await Promise.all(
names
.filter((name) => name.startsWith(YOUTUBE_WHISPER_AUDIO_DIR_PREFIX))
.map((name) => path.join(tempRoot, name))
.filter((dir) => !activeDirs.has(dir))
.map((dir) => rm(dir, { recursive: true, force: true }).catch(() => undefined)),
);
};
async function generate(input: {
url: string;
outputPath: string;
/** Overrides the configured settings, e.g. the modal's per-session model choice. */
config?: SubtitleGenerationConfig;
signal?: AbortSignal;
onProgress?: (progress: SubtitleGenerationProgress) => void;
}): Promise<string> {
input.signal?.throwIfAborted();
const config = input.config ?? deps.getConfig();
// Fail before spending time on the download when Whisper cannot run.
await (deps.requireSetup ?? requireSubtitleGenerationSetup)(config, deps.getModelDirectory());
await removeStaleAudioDirs();
const directory = await mkdtemp(path.join(tempRoot, YOUTUBE_WHISPER_AUDIO_DIR_PREFIX));
activeDirs.add(directory);
try {
input.onProgress?.({ stage: 'download', percent: 0, message: 'Downloading audio...' });
await (deps.runProcess ?? runSubtitleGenerationProcess)({
command: (deps.getYtDlpCommand ?? getYoutubeYtDlpCommand)(),
args: buildYoutubeWhisperAudioArgs(input.url, path.join(directory, 'audio.%(ext)s')),
signal: input.signal,
missingMessage: 'yt-dlp was not found. Install it or set SUBMINER_YTDLP_BIN.',
onLine: (line) => {
const percent = parseYtDlpDownloadPercent(line);
if (percent !== null)
input.onProgress?.({ stage: 'download', percent, message: 'Downloading audio...' });
},
});
return await (deps.generate ?? generateJapaneseSubtitles)({
config,
modelDirectory: deps.getModelDirectory(),
mediaPath: await findDownloadedAudio(directory),
outputPath: input.outputPath,
// Keep the extracted WAV inside the managed directory so every cleanup path removes it.
workDirectory: directory,
onProgress: input.onProgress,
signal: input.signal,
});
} finally {
await rm(directory, { recursive: true, force: true }).catch(() => undefined);
activeDirs.delete(directory);
}
}
/** Synchronous so it still runs while the app is quitting. */
function removeActiveAudio(): void {
for (const directory of activeDirs) {
try {
rmSync(directory, { recursive: true, force: true });
} catch {
// Leftovers are swept before the next download.
}
}
activeDirs.clear();
}
return { generate, removeActiveAudio };
}
+11 -1
View File
@@ -1,7 +1,8 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import path from 'node:path';
import { resolveDefaultLogFilePath, setLogRotation } from './logger';
import { EventEmitter } from 'node:events';
import { ignoreClosedPipeErrors, resolveDefaultLogFilePath, setLogRotation } from './logger';
import { localDateKey } from './shared/log-files';
test('resolveDefaultLogFilePath uses APPDATA on windows', () => {
@@ -56,3 +57,12 @@ test('setLogRotation accepts numeric retention days', () => {
}
}
});
test('ignoreClosedPipeErrors swallows broken-pipe errors but rethrows others', () => {
const stream = new EventEmitter();
ignoreClosedPipeErrors(stream);
const closedPipe = Object.assign(new Error('write EPIPE'), { code: 'EPIPE' });
assert.doesNotThrow(() => stream.emit('error', closedPipe));
const other = Object.assign(new Error('boom'), { code: 'EACCES' });
assert.throws(() => stream.emit('error', other), /boom/);
});
+21
View File
@@ -32,6 +32,27 @@ const LEVEL_PRIORITY: Record<LogLevel, number> = {
const DEFAULT_LOG_LEVEL: LogLevel = 'warn';
const CLOSED_PIPE_ERROR_CODES = new Set(['EPIPE', 'ERR_STREAM_DESTROYED']);
/**
* Keeps the app alive when whoever held its stdout/stderr goes away (e.g. Ctrl+C on the
* launcher that spawned it). Node reports the broken pipe as an async 'error' event on the
* stream, which crashes the process when nothing listens for it. Other errors still throw.
*/
export function ignoreClosedPipeErrors(stream: {
on: (event: 'error', listener: (error: NodeJS.ErrnoException) => void) => unknown;
}): void {
stream.on('error', (error) => {
if (error.code && CLOSED_PIPE_ERROR_CODES.has(error.code)) {
return;
}
throw error;
});
}
ignoreClosedPipeErrors(process.stdout);
ignoreClosedPipeErrors(process.stderr);
let cliLogLevel: LogLevel | undefined;
let configLogLevel: LogLevel | undefined;
let configLogRotation: LogRotation = DEFAULT_LOG_ROTATION;
+110 -4
View File
@@ -329,6 +329,7 @@ import {
} from './core/services/youtube/generate';
import { applyOverlayClickThrough } from './core/services/overlay-click-through';
import { createYoutubeMediaCacheService } from './core/services/youtube/media-cache';
import { createYoutubeWhisperSubtitleService } from './core/services/youtube/whisper-subtitles';
import { resolveYoutubePlaybackUrl } from './core/services/youtube/playback-resolve';
import { probeYoutubeTracks } from './core/services/youtube/track-probe';
import {
@@ -484,6 +485,8 @@ import {
} from './main/runtime/media-timing-review';
import { openMediaTimingReviewModal } from './main/runtime/media-timing-review-open';
import { openYoutubeTrackPicker } from './main/runtime/youtube-picker-open';
import { createYoutubeBrowserPlaybackRuntime } from './main/runtime/youtube-browser-playback';
import { createYoutubeBrowserWindowRuntime } from './main/runtime/youtube-browser-window';
import { openRuntimeOptionsModal as openRuntimeOptionsModalRuntime } from './main/runtime/runtime-options-open';
import { openJimakuModal as openJimakuModalRuntime } from './main/runtime/jimaku-open';
import { openTsukihimeModal as openTsukihimeModalRuntime } from './main/runtime/tsukihime-open';
@@ -665,6 +668,8 @@ const JELLYFIN_MPV_CONNECT_TIMEOUT_MS = 3000;
const JELLYFIN_MPV_AUTO_LAUNCH_TIMEOUT_MS = 20000;
const YOUTUBE_MPV_CONNECT_TIMEOUT_MS = 3000;
const YOUTUBE_MPV_AUTO_LAUNCH_TIMEOUT_MS = 10000;
// mpv's ytdl hook resolves the stream before tracks appear; a cold yt-dlp run takes well over 5s.
const YOUTUBE_MPV_LOAD_TIMEOUT_MS = 20000;
const YOUTUBE_MPV_YTDL_FORMAT = 'bestvideo*+bestaudio/best';
const YOUTUBE_DIRECT_PLAYBACK_FORMAT = 'b';
const MPV_JELLYFIN_DEFAULT_ARGS = [
@@ -1022,6 +1027,13 @@ process.on('SIGTERM', () => {
requestAppQuit();
});
// Managed Whisper models live next to the config file.
const getSubtitleGenerationModelDirectory = (): string =>
path.join(path.dirname(configService.getConfigPath()), 'models', 'whisper');
const youtubeWhisperSubtitles = createYoutubeWhisperSubtitleService({
getConfig: () => configService.getConfig().subtitleGeneration,
getModelDirectory: getSubtitleGenerationModelDirectory,
});
const youtubeFlowRuntime = createYoutubeFlowRuntime({
probeYoutubeTracks: (url: string) => probeYoutubeTracks(url),
acquireYoutubeSubtitleTrack: (input) => acquireYoutubeSubtitleTrack(input),
@@ -1161,6 +1173,25 @@ const youtubeFlowRuntime = createYoutubeFlowRuntime({
fs.rmSync(dir, { recursive: true, force: true });
}
},
getSubtitleSource: () => configService.getConfig().youtube.subtitleSource,
// Runs as the subtitle generation modal's active job, so the modal shows it and can cancel it.
generateWhisperSubtitles: ({ url, outputPath, signal, onProgress }) =>
subtitleGenerationRuntime.generateYoutubeSubtitles({
url,
signal,
generate: (jobSignal, reportProgress) =>
youtubeWhisperSubtitles.generate({
url,
outputPath,
signal: jobSignal,
onProgress: (progress) => {
reportProgress(progress);
onProgress(progress);
},
}),
}),
openSubtitleGenerationModal: () =>
openSubtitleGenerationModal(createOverlayHostedModalOpenDeps()),
});
const prepareYoutubePlaybackInMpv = createPrepareYoutubePlaybackInMpvHandler({
requestPath: async () => {
@@ -1388,7 +1419,8 @@ const youtubePlaybackRuntime = createYoutubePlaybackRuntime({
);
},
waitForYoutubeMpvConnected: (timeoutMs) => waitForYoutubeMpvConnected(timeoutMs),
prepareYoutubePlaybackInMpv: (request) => prepareYoutubePlaybackInMpv(request),
prepareYoutubePlaybackInMpv: (request) =>
prepareYoutubePlaybackInMpv({ ...request, timeoutMs: YOUTUBE_MPV_LOAD_TIMEOUT_MS }),
startYoutubeMediaCache: (url) => {
const mediaCacheConfig = configService.getConfig().youtube.mediaCache;
youtubeMediaCache.start(url, {
@@ -3146,6 +3178,7 @@ const {
runJellyfinCommand,
openJellyfinSetupWindow,
getJellyfinClientInfo,
ensureMpvConnectedForPlayback,
} = composeJellyfinRuntimeHandlers({
getResolvedJellyfinConfigMainDeps: {
getResolvedConfig: () => configService.getConfig(),
@@ -3423,6 +3456,52 @@ const {
},
});
let quitWhenMpvClosesAfterYoutubeBrowser = false;
const isMpvPlayingMedia = (): boolean =>
Boolean(appState.mpvClient?.connected && appState.currentMediaPath?.trim());
const youtubeBrowserPlaybackRuntime = createYoutubeBrowserPlaybackRuntime({
isMpvPlaying: isMpvPlayingMedia,
// Windows playback bootstraps its own mpv inside the playback flow.
ensureMpvReady: async () =>
process.platform === 'win32' ? true : await ensureMpvConnectedForPlayback(),
runPlaybackFlow: (url) =>
youtubePlaybackRuntime.runYoutubePlaybackFlow({ url, source: 'youtube-browser' }),
appendToMpvPlaylist: (url) => {
sendMpvCommandRuntime(appState.mpvClient, ['loadfile', url, 'append']);
},
notifyFailure: (message) =>
overlayNotificationsRuntime.showConfiguredStatusNotification(message, { title: 'YouTube' }),
logWarn: (message) => logger.warn(message),
onStartupFailed: () => quitAfterYoutubeBrowserIfIdle(),
});
const youtubeBrowserWindowRuntime = createYoutubeBrowserWindowRuntime({
createBrowserWindow: (options) => new BrowserWindow(options),
preloadPath: path.join(__dirname, 'preload-youtube-browser.js'),
ipcMain,
showContextMenu: (window, template) => {
Menu.buildFromTemplate(template).popup({ window });
},
openExternal: (url) => {
void shell.openExternal(url);
},
openVideo: (request) => youtubeBrowserPlaybackRuntime.openVideo(request),
logWarn: (message, error) => logger.warn(message, error),
logDebug: (message) => logger.debug(message),
onClosed: () => {
if (!appState.initialArgs?.youtubeBrowser) return;
quitWhenMpvClosesAfterYoutubeBrowser = true;
quitAfterYoutubeBrowserIfIdle();
},
});
// A process started just for the browser (`--youtube-browser`) exits once the window is closed,
// but keeps the overlay alive until mpv closes if a video is playing or still starting. Rechecked
// when a start fails, so a failed start after the window closed does not leave the app running.
const quitAfterYoutubeBrowserIfIdle = (): void => {
if (!quitWhenMpvClosesAfterYoutubeBrowser || youtubeBrowserWindowRuntime.isOpen()) return;
if (isMpvPlayingMedia() || youtubeBrowserPlaybackRuntime.isStartingPlayback()) return;
requestAppQuit();
};
const maybeFocusExistingFirstRunSetupWindow = createMaybeFocusExistingFirstRunSetupWindowHandler({
getSetupWindow: () => appState.firstRunSetupWindow,
});
@@ -4055,7 +4134,12 @@ const {
},
stopJellyfinRemoteSession: () => stopJellyfinRemoteSession(),
cleanupInternalSubtitleTrackCache: () => cachedInternalSubtitleTrackExtractor.clear(),
cleanupYoutubeSubtitleTempDirs: () => youtubeFlowRuntime.cleanupSubtitleTempDirs(),
cleanupYoutubeSubtitleTempDirs: () => {
// Stops yt-dlp/whisper-cli and deletes the audio they were working on.
youtubeFlowRuntime.cancelWhisperGeneration();
youtubeWhisperSubtitles.removeActiveAudio();
youtubeFlowRuntime.cleanupSubtitleTempDirs();
},
cleanupYoutubeMediaCache: () => youtubeMediaCache.cleanup(),
cleanupRemoteMediaWindows: () => getSharedRemoteMediaWindowCache().cleanup(),
cleanupJellyfinSubtitleCache: () => cleanupJellyfinSubtitleCache(),
@@ -4658,6 +4742,9 @@ const {
}
youtubePrimarySubtitleNotificationRuntime.handleMediaPathChange(path);
void youtubeMediaCachePlaybackRuntime.handleMediaPathChange(path);
youtubeBrowserPlaybackRuntime.handleMediaPathChange(path);
youtubeFlowRuntime.handleMediaPathChange(path);
subtitleGenerationRuntime.handleMediaPathChange(path);
if (path) {
ensureImmersionTrackerStarted();
secondarySubtitleTrackController.scheduleRefresh();
@@ -5008,6 +5095,13 @@ function createMpvClientRuntimeService(): MpvIpcClient {
if (connected) {
return;
}
youtubeBrowserPlaybackRuntime.handleMpvDisconnected();
youtubeFlowRuntime.cancelWhisperGeneration();
subtitleGenerationRuntime.handleMediaPathChange(null);
if (quitWhenMpvClosesAfterYoutubeBrowser && !youtubeBrowserWindowRuntime.isOpen()) {
requestAppQuit();
return;
}
if (!youtubeFlowRuntime.hasActiveSession()) {
return;
}
@@ -6153,6 +6247,7 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({
openYomitanSettings: () => openYomitanSettings(),
openConfigSettingsWindow: () => configSettingsRuntime.openWindow(),
openSyncUiWindow: () => openSyncUiWindowHandler(),
openYoutubeBrowserWindow: () => youtubeBrowserWindowRuntime.open(),
cycleSecondarySubMode: () => cycleSecondarySubMode(),
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT),
@@ -6416,6 +6511,7 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } =
openYomitanSettings: () => openYomitanSettings(),
openConfigSettingsWindow: () => configSettingsRuntime.openWindow(),
openSyncUiWindow: () => openSyncUiWindowHandler(),
openYoutubeBrowserWindow: () => youtubeBrowserWindowRuntime.open(),
exportLogs: () => {
void exportLogsFromTray();
},
@@ -6684,8 +6780,18 @@ registerSubtitleSelectionIpc({
});
const subtitleGenerationRuntime = createSubtitleGenerationRuntime({
getConfig: () => configService.getConfig().subtitleGeneration,
getModelDirectory: () =>
path.join(path.dirname(configService.getConfigPath()), 'models', 'whisper'),
getModelDirectory: getSubtitleGenerationModelDirectory,
generateYoutube: async ({ url, config, signal, onProgress }) =>
youtubeWhisperSubtitles.generate({
url,
config,
signal,
onProgress,
outputPath: path.join(
await youtubeFlowRuntime.createSubtitleTempDir(),
'youtube-whisper.ja.srt',
),
}),
getMpvClient: () => appState.mpvClient,
onProgress: (progress) => {
for (const window of [overlayManager.getMainWindow(), overlayManager.getModalWindow()]) {
+2
View File
@@ -51,6 +51,7 @@ export interface CliCommandRuntimeServiceContext {
openYomitanSettings: () => void;
openConfigSettingsWindow: () => void;
openSyncUiWindow: () => void;
openYoutubeBrowserWindow: () => void;
cycleSecondarySubMode: () => void;
openRuntimeOptionsPalette: () => void;
printHelp: () => void;
@@ -137,6 +138,7 @@ function createCliCommandDepsFromContext(
openYomitanSettings: context.openYomitanSettings,
openConfigSettingsWindow: context.openConfigSettingsWindow,
openSyncUiWindow: context.openSyncUiWindow,
openYoutubeBrowserWindow: context.openYoutubeBrowserWindow,
cycleSecondarySubMode: context.cycleSecondarySubMode,
openRuntimeOptionsPalette: context.openRuntimeOptionsPalette,
printHelp: context.printHelp,
+2
View File
@@ -219,6 +219,7 @@ export interface CliCommandRuntimeServiceDepsParams {
openYomitanSettings: CliCommandDepsRuntimeOptions['ui']['openYomitanSettings'];
openConfigSettingsWindow: CliCommandDepsRuntimeOptions['ui']['openConfigSettingsWindow'];
openSyncUiWindow: CliCommandDepsRuntimeOptions['ui']['openSyncUiWindow'];
openYoutubeBrowserWindow: CliCommandDepsRuntimeOptions['ui']['openYoutubeBrowserWindow'];
cycleSecondarySubMode: CliCommandDepsRuntimeOptions['ui']['cycleSecondarySubMode'];
openRuntimeOptionsPalette: CliCommandDepsRuntimeOptions['ui']['openRuntimeOptionsPalette'];
printHelp: CliCommandDepsRuntimeOptions['ui']['printHelp'];
@@ -432,6 +433,7 @@ export function createCliCommandRuntimeServiceDeps(
openYomitanSettings: params.ui.openYomitanSettings,
openConfigSettingsWindow: params.ui.openConfigSettingsWindow,
openSyncUiWindow: params.ui.openSyncUiWindow,
openYoutubeBrowserWindow: params.ui.openYoutubeBrowserWindow,
cycleSecondarySubMode: params.ui.cycleSecondarySubMode,
openRuntimeOptionsPalette: params.ui.openRuntimeOptionsPalette,
printHelp: params.ui.printHelp,
@@ -75,6 +75,7 @@ test('build cli command context deps maps handlers and values', () => {
openYomitanSettings: () => calls.push('yomitan'),
openConfigSettingsWindow: () => calls.push('config-settings'),
openSyncUiWindow: () => {},
openYoutubeBrowserWindow: () => {},
cycleSecondarySubMode: () => calls.push('cycle-secondary'),
openRuntimeOptionsPalette: () => calls.push('runtime-options'),
printHelp: () => calls.push('help'),
@@ -49,6 +49,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
openYomitanSettings: () => void;
openConfigSettingsWindow: () => void;
openSyncUiWindow: () => void;
openYoutubeBrowserWindow: () => void;
cycleSecondarySubMode: () => void;
openRuntimeOptionsPalette: () => void;
printHelp: () => void;
@@ -109,6 +110,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
openYomitanSettings: deps.openYomitanSettings,
openConfigSettingsWindow: deps.openConfigSettingsWindow,
openSyncUiWindow: deps.openSyncUiWindow,
openYoutubeBrowserWindow: deps.openYoutubeBrowserWindow,
cycleSecondarySubMode: deps.cycleSecondarySubMode,
openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette,
printHelp: deps.printHelp,
@@ -77,6 +77,7 @@ test('cli command context factory composes main deps and context handlers', () =
openYomitanSettings: () => {},
openConfigSettingsWindow: () => {},
openSyncUiWindow: () => {},
openYoutubeBrowserWindow: () => {},
cycleSecondarySubMode: () => {},
openRuntimeOptionsPalette: () => {},
printHelp: () => {},
@@ -106,6 +106,7 @@ test('cli command context main deps builder maps state and callbacks', async ()
openYomitanSettings: () => calls.push('open-yomitan'),
openConfigSettingsWindow: () => calls.push('open-config-settings'),
openSyncUiWindow: () => {},
openYoutubeBrowserWindow: () => {},
cycleSecondarySubMode: () => calls.push('cycle-secondary'),
openRuntimeOptionsPalette: () => calls.push('open-runtime-options'),
printHelp: () => calls.push('help'),
@@ -66,6 +66,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
openYomitanSettings: () => void;
openConfigSettingsWindow: () => void;
openSyncUiWindow: () => void;
openYoutubeBrowserWindow: () => void;
cycleSecondarySubMode: () => void;
openRuntimeOptionsPalette: () => void;
printHelp: () => void;
@@ -146,6 +147,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
openYomitanSettings: () => deps.openYomitanSettings(),
openConfigSettingsWindow: () => deps.openConfigSettingsWindow(),
openSyncUiWindow: () => deps.openSyncUiWindow(),
openYoutubeBrowserWindow: () => deps.openYoutubeBrowserWindow(),
cycleSecondarySubMode: () => deps.cycleSecondarySubMode(),
openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(),
printHelp: () => deps.printHelp(),
@@ -59,6 +59,7 @@ function createDeps() {
openYomitanSettings: () => {},
openConfigSettingsWindow: () => {},
openSyncUiWindow: () => {},
openYoutubeBrowserWindow: () => {},
cycleSecondarySubMode: () => {},
openRuntimeOptionsPalette: () => {},
printHelp: () => {},
+2
View File
@@ -54,6 +54,7 @@ export type CliCommandContextFactoryDeps = {
openYomitanSettings: () => void;
openConfigSettingsWindow: () => void;
openSyncUiWindow: () => void;
openYoutubeBrowserWindow: () => void;
cycleSecondarySubMode: () => void;
openRuntimeOptionsPalette: () => void;
printHelp: () => void;
@@ -136,6 +137,7 @@ export function createCliCommandContext(
openYomitanSettings: deps.openYomitanSettings,
openConfigSettingsWindow: deps.openConfigSettingsWindow,
openSyncUiWindow: deps.openSyncUiWindow,
openYoutubeBrowserWindow: deps.openYoutubeBrowserWindow,
cycleSecondarySubMode: deps.cycleSecondarySubMode,
openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette,
printHelp: deps.printHelp,
@@ -53,6 +53,7 @@ test('composeCliStartupHandlers returns callable CLI startup handlers', () => {
openYomitanSettings: () => {},
openConfigSettingsWindow: () => {},
openSyncUiWindow: () => {},
openYoutubeBrowserWindow: () => {},
cycleSecondarySubMode: () => {},
openRuntimeOptionsPalette: () => {},
printHelp: () => {},
@@ -146,6 +146,8 @@ export type JellyfinRuntimeComposerResult = ComposerOutputs<{
typeof composeJellyfinRemoteHandlers
>['handleJellyfinRemoteGeneralCommand'];
playJellyfinItemInMpv: ReturnType<typeof createPlayJellyfinItemInMpvHandler>;
/** Connects to mpv, launching an idle player when none is running. */
ensureMpvConnectedForPlayback: () => Promise<boolean>;
cleanupJellyfinSubtitleCache: () => void;
startJellyfinRemoteSession: ReturnType<typeof createStartJellyfinRemoteSessionHandler>;
stopJellyfinRemoteSession: ReturnType<typeof createStopJellyfinRemoteSessionHandler>;
@@ -303,6 +305,7 @@ export function composeJellyfinRuntimeHandlers(
handleJellyfinRemotePlaystate,
handleJellyfinRemoteGeneralCommand,
playJellyfinItemInMpv,
ensureMpvConnectedForPlayback: () => ensureMpvConnectedForJellyfinPlayback(),
cleanupJellyfinSubtitleCache: () => preloadJellyfinExternalSubtitles.cleanupCachedSubtitles(),
startJellyfinRemoteSession,
stopJellyfinRemoteSession,
@@ -32,6 +32,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
yomitan: false,
settings: false,
syncWindow: false,
youtubeBrowser: false,
setup: false,
show: false,
hide: false,
@@ -313,3 +313,133 @@ test('speech model downloads share the job lock and cancellation', async () => {
runtime.cancel();
assert.deepEqual(await download, { ok: false, message: 'Cancelled.' });
});
test('a YouTube Whisper job shows in the modal status and returns its subtitle file', async () => {
const { runtime } = fixture();
let release = () => {};
const job = runtime.generateYoutubeSubtitles({
url: 'https://www.youtube.com/watch?v=abcdefghijk',
signal: new AbortController().signal,
generate: async (_signal, onProgress) => {
onProgress({ stage: 'transcribe', percent: 40, message: 'Generating Japanese subtitles...' });
await new Promise<void>((resolve) => {
release = resolve;
});
return '/tmp/subs/youtube-whisper.ja.srt';
},
});
const active = await runtime.getStatus();
assert.equal(active.running, true);
assert.equal(active.mediaPath, 'https://www.youtube.com/watch?v=abcdefghijk');
assert.equal(active.progress?.percent, 40);
assert.equal((await runtime.start()).ok, false);
release();
assert.equal(await job, '/tmp/subs/youtube-whisper.ja.srt');
const done = await runtime.getStatus();
assert.equal(done.running, false);
assert.equal(done.mediaPath, '/video/episode.mkv');
});
test('cancelling a YouTube Whisper job resolves to null, and failures throw', async () => {
const { runtime } = fixture();
const cancelled = runtime.generateYoutubeSubtitles({
url: 'https://www.youtube.com/watch?v=abcdefghijk',
signal: new AbortController().signal,
generate: (signal) =>
new Promise((_, reject) =>
signal.addEventListener('abort', () => reject(new Error('Aborted')), { once: true }),
),
});
runtime.cancel();
assert.equal(await cancelled, null);
assert.deepEqual((await runtime.getStatus()).lastResult, { ok: false, message: 'Cancelled.' });
await assert.rejects(
runtime.generateYoutubeSubtitles({
url: 'https://www.youtube.com/watch?v=abcdefghijk',
signal: new AbortController().signal,
generate: async () => {
throw new Error('No Whisper model found.');
},
}),
/No Whisper model found/,
);
});
function youtubeFixture(generateYoutube: SubtitleGenerationRuntimeDeps['generateYoutube']) {
const player = {
path: 'https://www.youtube.com/watch?v=abcdefghijk&pp=search',
paused: false,
};
const commands: unknown[][] = [];
const client = {
connected: true,
requestProperty: async (name: string): Promise<unknown> =>
name === 'path' ? player.path : name === 'pause' ? player.paused : null,
request: async (command: unknown[]) => {
commands.push(command);
if (command[0] === 'set_property' && command[1] === 'pause')
player.paused = command[2] === true;
return { error: 'success' };
},
};
const { runtime } = fixture({ getMpvClient: () => client, generateYoutube });
return { runtime, player, commands };
}
test('the modal generates for a playing YouTube video, pausing it until the subtitles load', async () => {
let pausedDuringGeneration = false;
let usedModel = '';
const { runtime, player, commands } = youtubeFixture(async (input) => {
pausedDuringGeneration = player.paused;
usedModel = input.config.managedModel;
assert.equal(input.url, 'https://www.youtube.com/watch?v=abcdefghijk');
return '/tmp/subs/youtube-whisper.ja.srt';
});
assert.equal(
(await runtime.getStatus()).mediaPath,
'https://www.youtube.com/watch?v=abcdefghijk',
);
await runtime.selectModel('medium');
const result = await runtime.start();
assert.deepEqual(result, {
ok: true,
outputPath: '/tmp/subs/youtube-whisper.ja.srt',
message: 'Japanese subtitles generated and loaded.',
});
assert.equal(pausedDuringGeneration, true);
assert.equal(usedModel, 'medium');
assert.deepEqual(
commands.find((command) => command[0] === 'sub-add'),
['sub-add', '/tmp/subs/youtube-whisper.ja.srt', 'select', 'Generated Japanese', 'ja'],
);
assert.equal(player.paused, false);
});
test('switching videos cancels a YouTube job from the modal without loading its subtitles', async () => {
let jobSignal: AbortSignal | null = null;
const { runtime, player, commands } = youtubeFixture(
(input) =>
new Promise((_, reject) => {
jobSignal = input.signal;
input.signal.addEventListener('abort', () => reject(new Error('Aborted')), { once: true });
}),
);
const job = runtime.start();
while (!jobSignal) await new Promise((resolve) => setImmediate(resolve));
runtime.handleMediaPathChange('https://www.youtube.com/watch?v=abcdefghijk');
assert.equal((jobSignal as AbortSignal).aborted, false);
player.path = 'https://www.youtube.com/watch?v=zyxwvutsrqp';
runtime.handleMediaPathChange(player.path);
assert.deepEqual(await job, { ok: false, message: 'Cancelled.' });
assert.equal(
commands.some((command) => command[0] === 'sub-add'),
false,
);
});
+109 -12
View File
@@ -21,6 +21,7 @@ import type {
SubtitleGenerationResult,
SubtitleGenerationStatus,
} from '../../shared/subtitle-generation-ipc';
import { extractYoutubeVideoId, toYoutubeWatchUrl } from './youtube-playback';
interface GenerationMpvClient {
connected: boolean;
@@ -40,6 +41,16 @@ export interface SubtitleGenerationRuntimeDeps {
detectAcceleration?: typeof detectSubtitleGenerationAcceleration;
downloadVad?: typeof downloadSubtitleGenerationVadModel;
resolveVadModel?: typeof resolveSubtitleGenerationVadModel;
/**
* Transcribes a YouTube video's audio into a temporary subtitle file and returns its path.
* Without it the modal only generates for local media.
*/
generateYoutube?: (input: {
url: string;
config: SubtitleGenerationConfig;
signal: AbortSignal;
onProgress: (progress: SubtitleGenerationProgress) => void;
}) => Promise<string>;
}
async function currentLocalMedia(client: GenerationMpvClient | null): Promise<string | null> {
@@ -51,6 +62,25 @@ async function currentLocalMedia(client: GenerationMpvClient | null): Promise<st
return typeof directory === 'string' ? path.resolve(directory, media) : null;
}
async function currentYoutubeMedia(client: GenerationMpvClient | null): Promise<string | null> {
if (!client?.connected) return null;
const media = await client.requestProperty('path');
return typeof media === 'string' ? toYoutubeWatchUrl(media) : null;
}
async function loadGeneratedSubtitles(client: GenerationMpvClient, outputPath: string) {
const loaded = await client.request([
'sub-add',
outputPath,
'select',
'Generated Japanese',
'ja',
]);
if (loaded.error && loaded.error !== 'success') throw new Error(loaded.error);
const delay = await client.request(['set_property', 'sub-delay', 0]);
if (delay.error && delay.error !== 'success') throw new Error(delay.error);
}
function selectedAudioIndex(tracks: unknown): number {
if (!Array.isArray(tracks)) throw new Error('Unable to inspect the selected audio track.');
for (const track of tracks) {
@@ -83,6 +113,8 @@ export function createSubtitleGenerationRuntime(deps: SubtitleGenerationRuntimeD
let lastResult: SubtitleGenerationResult | null = null;
let selectedModel: SubtitleGenerationModelId | null = null;
let vadEnabled: boolean | null = null;
// Shown as the modal's media while a YouTube job runs; YouTube is never local media.
let youtubeJobUrl: string | null = null;
let accelerationCheck:
| {
path: string;
@@ -155,7 +187,11 @@ export function createSubtitleGenerationRuntime(deps: SubtitleGenerationRuntimeD
config,
deps.getModelDirectory(),
);
const mediaPath = await currentLocalMedia(deps.getMpvClient()).catch(() => null);
const client = deps.getMpvClient();
const mediaPath =
youtubeJobUrl ??
(await currentLocalMedia(client).catch(() => null)) ??
(deps.generateYoutube ? await currentYoutubeMedia(client).catch(() => null) : null);
return {
model,
vad: {
@@ -177,8 +213,42 @@ export function createSubtitleGenerationRuntime(deps: SubtitleGenerationRuntimeD
};
}
// YouTube subtitles only exist in mpv, so the video pauses while they generate and resumes
// afterwards. Closing the modal leaves the job running; unpausing is up to the user.
async function startYoutube(
client: GenerationMpvClient,
url: string,
config: SubtitleGenerationConfig,
signal: AbortSignal,
generate: NonNullable<SubtitleGenerationRuntimeDeps['generateYoutube']>,
): Promise<SubtitleGenerationResult> {
const resume = (await client.requestProperty('pause')) === false;
if (resume) await client.request(['set_property', 'pause', true]);
youtubeJobUrl = url;
const stillPlaying = async () =>
deps.getMpvClient() === client &&
(await currentYoutubeMedia(client).catch(() => null)) === url;
try {
const outputPath = await generate({ url, config, signal, onProgress: report });
if (signal.aborted || !(await stillPlaying()))
return { ok: false, message: 'Playback changed, so the subtitles were not loaded.' };
await loadGeneratedSubtitles(client, outputPath);
return { ok: true, outputPath, message: 'Japanese subtitles generated and loaded.' };
} finally {
youtubeJobUrl = null;
if (resume && (await stillPlaying()))
await client.request(['set_property', 'pause', false]).catch(() => undefined);
}
}
return {
getStatus,
/** Stops a YouTube job once its video is no longer playing, which also deletes its audio. */
handleMediaPathChange(mediaPath: string | null | undefined): void {
if (!youtubeJobUrl) return;
if (extractYoutubeVideoId(mediaPath) !== extractYoutubeVideoId(youtubeJobUrl))
controller?.abort();
},
async setVadEnabled(enabled: boolean): Promise<SubtitleGenerationStatus> {
if (controller)
throw new Error('Wait for the current operation before changing speech detection.');
@@ -210,6 +280,39 @@ export function createSubtitleGenerationRuntime(deps: SubtitleGenerationRuntimeD
cancel(): void {
controller?.abort();
},
/**
* Runs YouTube Whisper generation as the active operation, so the modal shows its progress
* and its Cancel button stops it. Aborting `signal` stops it too. Resolves to the written
* subtitle file, or null when the job was cancelled.
*/
async generateYoutubeSubtitles(input: {
url: string;
signal: AbortSignal;
generate: (
signal: AbortSignal,
onProgress: (progress: SubtitleGenerationProgress) => void,
) => Promise<string>;
}): Promise<string | null> {
let cancelled = false;
let outputPath: string | null = null;
const result = await run(async (signal) => {
const stop = () => controller?.abort();
input.signal.addEventListener('abort', stop, { once: true });
if (input.signal.aborted) stop();
youtubeJobUrl = input.url;
try {
outputPath = await input.generate(signal, report);
return { ok: true, outputPath, message: 'Japanese subtitles generated.' };
} finally {
cancelled = signal.aborted;
input.signal.removeEventListener('abort', stop);
youtubeJobUrl = null;
}
});
if (result.ok) return outputPath;
if (cancelled) return null;
throw new Error(result.message);
},
download(): Promise<SubtitleGenerationResult> {
return run(async (signal) => {
await (deps.download ?? downloadSubtitleGenerationModel)({
@@ -236,9 +339,12 @@ export function createSubtitleGenerationRuntime(deps: SubtitleGenerationRuntimeD
if (vad.kind === 'invalid') throw new Error(vad.message);
}
const client = deps.getMpvClient();
const youtubeUrl = deps.generateYoutube ? await currentYoutubeMedia(client) : null;
if (client && youtubeUrl && deps.generateYoutube)
return await startYoutube(client, youtubeUrl, config, signal, deps.generateYoutube);
const mediaPath = await currentLocalMedia(client);
if (!client || !mediaPath)
throw new Error('Open a local video or audio file in mpv first.');
throw new Error('Open a local video or audio file, or a YouTube video, in mpv first.');
const tracks = await client.requestProperty('track-list');
const audioStreamIndex = selectedAudioIndex(tracks);
const references = await readSubtitleGenerationReferences(tracks, (name) =>
@@ -260,16 +366,7 @@ export function createSubtitleGenerationRuntime(deps: SubtitleGenerationRuntimeD
try {
const playingMedia = await currentLocalMedia(client);
if (!signal.aborted && deps.getMpvClient() === client && playingMedia === mediaPath) {
const loaded = await client.request([
'sub-add',
outputPath,
'select',
'Generated Japanese',
'ja',
]);
if (loaded.error && loaded.error !== 'success') throw new Error(loaded.error);
const delay = await client.request(['set_property', 'sub-delay', 0]);
if (delay.error && delay.error !== 'success') throw new Error(delay.error);
await loadGeneratedSubtitles(client, outputPath);
return {
ok: true,
outputPath,
@@ -51,6 +51,7 @@ test('build tray template handler wires actions and init guards', () => {
handlers.openYomitanSettings();
handlers.openConfigSettings();
handlers.openSyncUi();
handlers.openYoutubeBrowser();
handlers.exportLogs();
handlers.openJellyfinSetup();
handlers.toggleJellyfinDiscovery(true);
@@ -74,6 +75,7 @@ test('build tray template handler wires actions and init guards', () => {
openYomitanSettings: () => calls.push('yomitan'),
openConfigSettingsWindow: () => calls.push('configuration'),
openSyncUiWindow: () => calls.push('sync-ui'),
openYoutubeBrowserWindow: () => calls.push('youtube'),
exportLogs: () => calls.push('export-logs'),
openJellyfinSetupWindow: () => calls.push('jellyfin'),
isJellyfinConfigured: () => true,
@@ -100,6 +102,7 @@ test('build tray template handler wires actions and init guards', () => {
'yomitan',
'configuration',
'sync-ui',
'youtube',
'export-logs',
'jellyfin',
'jellyfin-discovery:true',
@@ -130,6 +133,7 @@ test('windows mpv launcher tray action force-opens completed setup', () => {
openYomitanSettings: () => calls.push('yomitan'),
openConfigSettingsWindow: () => calls.push('configuration'),
openSyncUiWindow: () => calls.push('configuration'),
openYoutubeBrowserWindow: () => calls.push('youtube'),
exportLogs: () => calls.push('export-logs'),
openJellyfinSetupWindow: () => calls.push('jellyfin'),
isJellyfinConfigured: () => false,
+5
View File
@@ -49,6 +49,7 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
openYomitanSettings: () => void;
openConfigSettings: () => void;
openSyncUi: () => void;
openYoutubeBrowser: () => void;
exportLogs: () => void;
openJellyfinSetup: () => void;
showJellyfinDiscovery: boolean;
@@ -70,6 +71,7 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
openYomitanSettings: () => void;
openConfigSettingsWindow: () => void;
openSyncUiWindow: () => void;
openYoutubeBrowserWindow: () => void;
exportLogs: () => void;
openJellyfinSetupWindow: () => void;
isJellyfinConfigured: () => boolean;
@@ -116,6 +118,9 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
openSyncUi: () => {
deps.openSyncUiWindow();
},
openYoutubeBrowser: () => {
deps.openYoutubeBrowserWindow();
},
exportLogs: () => {
deps.exportLogs();
},
+2
View File
@@ -34,6 +34,7 @@ test('tray main deps builders return mapped handlers', () => {
openYomitanSettings: () => calls.push('yomitan'),
openConfigSettingsWindow: () => calls.push('configuration'),
openSyncUiWindow: () => calls.push('sync-ui'),
openYoutubeBrowserWindow: () => calls.push('youtube'),
exportLogs: () => calls.push('export-logs'),
openJellyfinSetupWindow: () => calls.push('jellyfin'),
isJellyfinConfigured: () => true,
@@ -61,6 +62,7 @@ test('tray main deps builders return mapped handlers', () => {
openYomitanSettings: () => calls.push('open-yomitan'),
openConfigSettings: () => calls.push('open-configuration'),
openSyncUi: () => {},
openYoutubeBrowser: () => {},
exportLogs: () => calls.push('open-export-logs'),
openJellyfinSetup: () => calls.push('open-jellyfin'),
showJellyfinDiscovery: true,
+3
View File
@@ -39,6 +39,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
openYomitanSettings: () => void;
openConfigSettings: () => void;
openSyncUi: () => void;
openYoutubeBrowser: () => void;
exportLogs: () => void;
openJellyfinSetup: () => void;
showJellyfinDiscovery: boolean;
@@ -60,6 +61,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
openYomitanSettings: () => void;
openConfigSettingsWindow: () => void;
openSyncUiWindow: () => void;
openYoutubeBrowserWindow: () => void;
exportLogs: () => void;
openJellyfinSetupWindow: () => void;
isJellyfinConfigured: () => boolean;
@@ -85,6 +87,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
openYomitanSettings: deps.openYomitanSettings,
openConfigSettingsWindow: deps.openConfigSettingsWindow,
openSyncUiWindow: deps.openSyncUiWindow,
openYoutubeBrowserWindow: deps.openYoutubeBrowserWindow,
exportLogs: deps.exportLogs,
openJellyfinSetupWindow: deps.openJellyfinSetupWindow,
isJellyfinConfigured: deps.isJellyfinConfigured,
@@ -34,6 +34,7 @@ test('tray runtime handlers compose resolve/menu/ensure/destroy handlers', () =>
openYomitanSettings: () => {},
openConfigSettingsWindow: () => {},
openSyncUiWindow: () => {},
openYoutubeBrowserWindow: () => {},
exportLogs: () => {},
openJellyfinSetupWindow: () => {},
isJellyfinConfigured: () => false,
+8
View File
@@ -40,6 +40,7 @@ test('tray menu template contains expected entries and handlers', () => {
openYomitanSettings: () => calls.push('yomitan'),
openConfigSettings: () => calls.push('configuration'),
openSyncUi: () => calls.push('sync-ui'),
openYoutubeBrowser: () => calls.push('youtube'),
exportLogs: () => calls.push('export-logs'),
openJellyfinSetup: () => calls.push('jellyfin'),
showJellyfinDiscovery: true,
@@ -69,6 +70,7 @@ test('tray menu template contains expected entries and handlers', () => {
'Open Yomitan Settings',
'Open SubMiner Settings',
'Sync Stats && History',
'Browse YouTube',
'Export Logs',
'Configure Jellyfin',
'Jellyfin Discovery',
@@ -88,6 +90,7 @@ test('tray menu template contains expected entries and handlers', () => {
entryFor('View Changelog').click?.();
entryFor('Open Texthooker').click?.();
entryFor('Sync Stats && History').click?.();
entryFor('Browse YouTube').click?.();
entryFor('Export Logs').click?.();
entryFor('Check for Updates').click?.();
calls.push(template.some((entry) => entry.type === 'separator') ? 'separator' : 'bad');
@@ -99,6 +102,7 @@ test('tray menu template contains expected entries and handlers', () => {
'changelog',
'texthooker',
'sync-ui',
'youtube',
'export-logs',
'updates',
'separator',
@@ -119,6 +123,7 @@ test('tray menu template omits first-run setup entry when setup is complete', ()
openYomitanSettings: () => undefined,
openConfigSettings: () => undefined,
openSyncUi: () => undefined,
openYoutubeBrowser: () => undefined,
exportLogs: () => undefined,
openJellyfinSetup: () => undefined,
showJellyfinDiscovery: false,
@@ -149,6 +154,7 @@ test('tray menu template omits texthooker entry when texthooker page is disabled
openYomitanSettings: () => undefined,
openConfigSettings: () => undefined,
openSyncUi: () => undefined,
openYoutubeBrowser: () => undefined,
exportLogs: () => undefined,
openJellyfinSetup: () => undefined,
showJellyfinDiscovery: false,
@@ -177,6 +183,7 @@ test('tray menu template renders active jellyfin discovery checkbox', () => {
openYomitanSettings: () => undefined,
openConfigSettings: () => undefined,
openSyncUi: () => undefined,
openYoutubeBrowser: () => undefined,
exportLogs: () => undefined,
openJellyfinSetup: () => undefined,
showJellyfinDiscovery: true,
@@ -206,6 +213,7 @@ test('tray menu template renders a visible linux discovery check mark when activ
openYomitanSettings: () => undefined,
openConfigSettings: () => undefined,
openSyncUi: () => undefined,
openYoutubeBrowser: () => undefined,
exportLogs: () => undefined,
openJellyfinSetup: () => undefined,
showJellyfinDiscovery: true,
+5
View File
@@ -42,6 +42,7 @@ export type TrayMenuActionHandlers = {
openYomitanSettings: () => void;
openConfigSettings: () => void;
openSyncUi: () => void;
openYoutubeBrowser: () => void;
exportLogs: () => void;
openJellyfinSetup: () => void;
showJellyfinDiscovery: boolean;
@@ -113,6 +114,10 @@ export function buildTrayMenuTemplateRuntime(handlers: TrayMenuActionHandlers):
label: 'Sync Stats && History',
click: handlers.openSyncUi,
},
{
label: 'Browse YouTube',
click: handlers.openYoutubeBrowser,
},
{
label: 'Export Logs',
click: handlers.exportLogs,
@@ -0,0 +1,210 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createYoutubeBrowserPlaybackRuntime,
parseYoutubeBrowserVideoRequest,
type YoutubeBrowserPlaybackDeps,
} from './youtube-browser-playback';
const VIDEO_A = 'https://www.youtube.com/watch?v=aaaaaaaaaaa';
const VIDEO_B = 'https://www.youtube.com/watch?v=bbbbbbbbbbb';
const VIDEO_C = 'https://www.youtube.com/watch?v=ccccccccccc';
function createHarness(overrides: Partial<YoutubeBrowserPlaybackDeps> = {}) {
const calls: string[] = [];
const pendingFlows: Array<() => void> = [];
let mpvPlaying = false;
const runtime = createYoutubeBrowserPlaybackRuntime({
isMpvPlaying: () => mpvPlaying,
ensureMpvReady: async () => true,
runPlaybackFlow: (url) => {
calls.push(`flow:${url}`);
return new Promise<void>((resolve) => pendingFlows.push(resolve));
},
appendToMpvPlaylist: (url) => calls.push(`append:${url}`),
notifyFailure: (message) => calls.push(`notify:${message}`),
logWarn: () => {},
onStartupFailed: () => calls.push('startup-failed'),
...overrides,
});
return {
runtime,
calls,
setMpvPlaying: (next: boolean) => {
mpvPlaying = next;
},
finishFlows: async () => {
for (const resolve of pendingFlows.splice(0)) resolve();
await new Promise((resolve) => setImmediate(resolve));
},
};
}
test('parseYoutubeBrowserVideoRequest accepts play and queue requests only', () => {
assert.deepEqual(parseYoutubeBrowserVideoRequest({ action: 'queue', url: VIDEO_A }), {
action: 'queue',
url: VIDEO_A,
});
assert.equal(parseYoutubeBrowserVideoRequest({ action: 'delete', url: VIDEO_A }), null);
assert.equal(parseYoutubeBrowserVideoRequest({ action: 'play' }), null);
assert.equal(parseYoutubeBrowserVideoRequest(null), null);
});
test('play runs the playback flow with a single-video url', async () => {
const { runtime, calls } = createHarness();
const result = await runtime.openVideo({
action: 'play',
url: `${VIDEO_A}&list=WL&index=2`,
});
assert.deepEqual(result, { ok: true, message: 'Opening in mpv' });
assert.deepEqual(calls, [`flow:${VIDEO_A}`]);
});
test('queue appends to mpv while something is playing and plays otherwise', async () => {
const { runtime, calls, setMpvPlaying } = createHarness();
await runtime.openVideo({ action: 'queue', url: VIDEO_A });
runtime.handleMediaPathChange(VIDEO_A);
setMpvPlaying(true);
const queued = await runtime.openVideo({ action: 'queue', url: VIDEO_B });
assert.deepEqual(queued, { ok: true, message: 'Queued in mpv' });
assert.deepEqual(calls, [`flow:${VIDEO_A}`, `append:${VIDEO_B}`]);
});
test('queue requests while the first video starts append behind it in order', async () => {
const { runtime, calls } = createHarness();
await runtime.openVideo({ action: 'play', url: VIDEO_A });
assert.equal(runtime.isStartingPlayback(), true);
assert.deepEqual(await runtime.openVideo({ action: 'queue', url: VIDEO_B }), {
ok: true,
message: 'Queued in mpv',
});
await runtime.openVideo({ action: 'queue', url: VIDEO_C });
assert.deepEqual(calls, [`flow:${VIDEO_A}`]);
runtime.handleMediaPathChange(VIDEO_A);
await new Promise((resolve) => setImmediate(resolve));
assert.equal(runtime.isStartingPlayback(), false);
assert.deepEqual(calls, [`flow:${VIDEO_A}`, `append:${VIDEO_B}`, `append:${VIDEO_C}`]);
});
test('queue requests play instead when the first video fails to start', async () => {
let failFlow = true;
const calls: string[] = [];
const { runtime } = createHarness({
runPlaybackFlow: async (url) => {
calls.push(`flow:${url}`);
if (failFlow) {
failFlow = false;
throw new Error('no formats');
}
},
// The fallback play for the queued video must already count as starting.
onStartupFailed: () => calls.push(`startup-failed:${runtime.isStartingPlayback()}`),
});
await runtime.openVideo({ action: 'play', url: VIDEO_A });
await runtime.openVideo({ action: 'queue', url: VIDEO_B });
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(calls, [`flow:${VIDEO_A}`, 'startup-failed:true', `flow:${VIDEO_B}`]);
});
test('repeat play clicks while mpv is starting open the video once', async () => {
let releaseMpv: (ready: boolean) => void = () => {};
const { runtime, calls } = createHarness({
ensureMpvReady: () =>
new Promise<boolean>((resolve) => {
releaseMpv = resolve;
}),
});
const first = runtime.openVideo({ action: 'play', url: VIDEO_A });
const second = await runtime.openVideo({ action: 'play', url: VIDEO_A });
releaseMpv(true);
await first;
assert.deepEqual(second, { ok: true, message: 'Already opening in mpv' });
assert.deepEqual(calls, [`flow:${VIDEO_A}`]);
});
test('rejects non-video links and reports when mpv cannot start', async () => {
const { runtime, calls } = createHarness({ ensureMpvReady: async () => false });
assert.equal(
(await runtime.openVideo({ action: 'play', url: 'https://www.youtube.com/' })).ok,
false,
);
assert.deepEqual(await runtime.openVideo({ action: 'play', url: VIDEO_A }), {
ok: false,
message: 'Could not start mpv.',
});
assert.deepEqual(calls, ['startup-failed']);
});
test('advancing onto a queued video runs the playback flow for it', async () => {
const { runtime, calls, setMpvPlaying, finishFlows } = createHarness();
await runtime.openVideo({ action: 'play', url: VIDEO_A });
runtime.handleMediaPathChange(VIDEO_A);
await finishFlows();
setMpvPlaying(true);
await runtime.openVideo({ action: 'queue', url: VIDEO_B });
runtime.handleMediaPathChange('');
runtime.handleMediaPathChange(VIDEO_B);
runtime.handleMediaPathChange(VIDEO_B);
assert.deepEqual(calls, [`flow:${VIDEO_A}`, `append:${VIDEO_B}`, `flow:${VIDEO_B}`]);
});
test('path changes are ignored while a flow is loading its own video', async () => {
const { runtime, calls } = createHarness();
await runtime.openVideo({ action: 'play', url: VIDEO_A });
await runtime.openVideo({ action: 'play', url: VIDEO_B });
runtime.handleMediaPathChange(VIDEO_A);
runtime.handleMediaPathChange(VIDEO_B);
assert.deepEqual(calls, [`flow:${VIDEO_A}`, `flow:${VIDEO_B}`]);
});
test('a stuck flow does not block queued videos from getting theirs', async () => {
const { runtime, calls, setMpvPlaying } = createHarness();
await runtime.openVideo({ action: 'play', url: VIDEO_A });
runtime.handleMediaPathChange(VIDEO_A);
setMpvPlaying(true);
await runtime.openVideo({ action: 'queue', url: VIDEO_B });
runtime.handleMediaPathChange(VIDEO_B);
assert.deepEqual(calls, [`flow:${VIDEO_A}`, `append:${VIDEO_B}`, `flow:${VIDEO_B}`]);
});
test('a queued video gets the flow only the first time mpv reaches it', async () => {
const { runtime, calls, setMpvPlaying, finishFlows } = createHarness();
setMpvPlaying(true);
await runtime.openVideo({ action: 'queue', url: VIDEO_B });
runtime.handleMediaPathChange(VIDEO_B);
await finishFlows();
// A later launcher-started play of the same video runs its own flow.
runtime.handleMediaPathChange(VIDEO_A);
runtime.handleMediaPathChange(VIDEO_B);
assert.deepEqual(calls, [`append:${VIDEO_B}`, `flow:${VIDEO_B}`]);
});
test('videos not sent from the browser never trigger the flow', async () => {
const { runtime, calls } = createHarness();
runtime.handleMediaPathChange(VIDEO_A);
assert.deepEqual(calls, []);
});
test('mpv disconnect forgets queued videos', async () => {
const { runtime, calls, setMpvPlaying, finishFlows } = createHarness();
setMpvPlaying(true);
await runtime.openVideo({ action: 'queue', url: VIDEO_B });
runtime.handleMpvDisconnected();
runtime.handleMediaPathChange(VIDEO_B);
await finishFlows();
assert.deepEqual(calls, [`append:${VIDEO_B}`]);
});
test('flow failures are reported', async () => {
const { runtime, calls } = createHarness({
runPlaybackFlow: async () => {
throw new Error('mpv went away');
},
});
await runtime.openVideo({ action: 'play', url: VIDEO_A });
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(calls, ['notify:YouTube playback failed: mpv went away', 'startup-failed']);
});
@@ -0,0 +1,186 @@
import { extractYoutubeVideoId, toYoutubeWatchUrl } from './youtube-playback';
export type YoutubeBrowserVideoAction = 'play' | 'queue';
export type YoutubeBrowserVideoRequest = {
action: YoutubeBrowserVideoAction;
url: string;
};
export type YoutubeBrowserVideoResult = {
ok: boolean;
message: string;
};
export type YoutubeBrowserPlaybackDeps = {
/** True when mpv is connected and has a file loaded, so appending queues behind it. */
isMpvPlaying: () => boolean;
/** Connect to (or launch) mpv so the playback flow has a player to drive. */
ensureMpvReady: () => Promise<boolean>;
/** SubMiner's YouTube playback flow: load the video, pick subtitles, release the play gate. */
runPlaybackFlow: (url: string) => Promise<void>;
appendToMpvPlaylist: (url: string) => void;
notifyFailure: (message: string) => void;
logWarn: (message: string) => void;
/** A play request ended without loading its video, after queued requests behind it were released. */
onStartupFailed: () => void;
};
export function parseYoutubeBrowserVideoRequest(
payload: unknown,
): YoutubeBrowserVideoRequest | null {
if (typeof payload !== 'object' || payload === null) return null;
const { action, url } = payload as Record<string, unknown>;
if ((action !== 'play' && action !== 'queue') || typeof url !== 'string') return null;
return { action, url };
}
/**
* Hands videos picked in the YouTube browser window to mpv. Queued videos land in mpv's own
* playlist; when mpv advances onto one, `handleMediaPathChange` runs the playback flow for it so
* queued videos get the same subtitle setup as videos played directly.
*/
type PlaybackStartup = {
videoId: string;
/** Resolves true once mpv has the video loaded, false if mpv or the flow failed first. */
loaded: Promise<boolean>;
settle: (loaded: boolean) => void;
};
export function createYoutubeBrowserPlaybackRuntime(deps: YoutubeBrowserPlaybackDeps) {
// Videos queued from the browser that mpv has not reached yet. Only these get the flow on
// playlist advance; launcher-started YouTube playback already runs its own flow.
const queuedVideoIds = new Set<string>();
// Videos whose flow is still loading; the flow issues its own loadfile, so the path change
// that follows must not start a second flow for the same video.
const loadingVideoIds = new Set<string>();
// The latest play request while mpv starts and loads it. Queue requests arriving meanwhile wait
// on it so they append behind that video instead of starting a second flow that replaces it.
let startup: PlaybackStartup | null = null;
let lastSeenVideoId: string | null = null;
const beginStartup = (videoId: string): PlaybackStartup => {
let resolveLoaded: (loaded: boolean) => void = () => {};
let settled = false;
const entry: PlaybackStartup = {
videoId,
loaded: new Promise<boolean>((resolve) => {
resolveLoaded = resolve;
}),
settle: (loaded) => {
if (settled) return;
settled = true;
if (startup === entry) startup = null;
resolveLoaded(loaded);
// Registered after the queued requests' handlers, so one that plays instead has already
// begun its own startup by the time this runs.
if (!loaded) void entry.loaded.then(() => deps.onStartupFailed());
},
};
startup = entry;
return entry;
};
/** Runs the playback flow; resolves false (after reporting) when it fails. */
const startFlow = async (url: string, videoId: string): Promise<boolean> => {
loadingVideoIds.add(videoId);
try {
await deps.runPlaybackFlow(url);
return true;
} catch (error: unknown) {
const reason = error instanceof Error ? error.message : String(error);
deps.logWarn(`YouTube browser playback failed for ${url}: ${reason}`);
deps.notifyFailure(`YouTube playback failed: ${reason}`);
return false;
} finally {
loadingVideoIds.delete(videoId);
}
};
const appendToMpv = (url: string, videoId: string): YoutubeBrowserVideoResult => {
queuedVideoIds.add(videoId);
deps.appendToMpvPlaylist(url);
return { ok: true, message: 'Queued in mpv' };
};
const queueBehindStartup = (
pending: PlaybackStartup,
request: YoutubeBrowserVideoRequest,
url: string,
videoId: string,
): void => {
// Chained in click order. If the earlier video never loaded, this one plays instead.
void pending.loaded
.then((loaded) => (loaded ? appendToMpv(url, videoId) : openVideo(request)))
.then(
(result) => {
if (!result.ok) deps.notifyFailure(`YouTube queue failed: ${result.message}`);
},
(error: unknown) => {
const reason = error instanceof Error ? error.message : String(error);
deps.logWarn(`YouTube browser queue failed for ${url}: ${reason}`);
deps.notifyFailure(`YouTube queue failed: ${reason}`);
},
);
};
const openVideo = async (
request: YoutubeBrowserVideoRequest,
): Promise<YoutubeBrowserVideoResult> => {
const url = toYoutubeWatchUrl(request.url);
const videoId = extractYoutubeVideoId(url);
if (!url || !videoId) {
return { ok: false, message: 'Not a YouTube video link.' };
}
if (request.action === 'queue') {
if (startup) {
queueBehindStartup(startup, request, url, videoId);
return { ok: true, message: 'Queued in mpv' };
}
if (deps.isMpvPlaying()) return appendToMpv(url, videoId);
}
// Starting mpv can take seconds; repeat clicks during that wait must not start a second flow.
if (loadingVideoIds.has(videoId)) {
return { ok: true, message: 'Already opening in mpv' };
}
loadingVideoIds.add(videoId);
const entry = beginStartup(videoId);
let mpvReady = false;
try {
mpvReady = await deps.ensureMpvReady();
} finally {
if (!mpvReady) {
loadingVideoIds.delete(videoId);
entry.settle(false);
}
}
if (!mpvReady) {
return { ok: false, message: 'Could not start mpv.' };
}
void startFlow(url, videoId).then(entry.settle);
return { ok: true, message: 'Opening in mpv' };
};
const handleMediaPathChange = (mediaPath: string | null | undefined): void => {
const videoId = extractYoutubeVideoId(mediaPath);
const previousVideoId = lastSeenVideoId;
lastSeenVideoId = videoId;
if (videoId && startup?.videoId === videoId) startup.settle(true);
if (!videoId || videoId === previousVideoId || loadingVideoIds.has(videoId)) return;
if (!queuedVideoIds.delete(videoId)) return;
const url = toYoutubeWatchUrl(mediaPath);
if (url) void startFlow(url, videoId);
};
const handleMpvDisconnected = (): void => {
queuedVideoIds.clear();
lastSeenVideoId = null;
};
/** True while a browser play request is still starting mpv or loading its video. */
const isStartingPlayback = (): boolean => startup !== null;
return { openVideo, handleMediaPathChange, handleMpvDisconnected, isStartingPlayback };
}
@@ -0,0 +1,12 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildYoutubeBrowserUserAgent } from './youtube-browser-window';
test('buildYoutubeBrowserUserAgent strips Electron and app tokens from the user agent', () => {
assert.equal(
buildYoutubeBrowserUserAgent(
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) SubMiner/0.19.6 Chrome/140.0.7339.41 Electron/42.6.0 Safari/537.36',
),
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.7339.41 Safari/537.36',
);
});
+214
View File
@@ -0,0 +1,214 @@
import type {
BrowserWindow,
BrowserWindowConstructorOptions,
IpcMain,
MenuItemConstructorOptions,
Session,
} from 'electron';
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
import {
parseYoutubeBrowserVideoRequest,
type YoutubeBrowserVideoRequest,
type YoutubeBrowserVideoResult,
} from './youtube-browser-playback';
import { toYoutubeWatchUrl } from './youtube-playback';
/** Persistent partition: cookies (and so the YouTube login) survive app restarts. */
export const YOUTUBE_BROWSER_PARTITION = 'persist:youtube-browser';
export const YOUTUBE_BROWSER_HOME_URL = 'https://www.youtube.com/';
const ALLOWED_PERMISSIONS = new Set(['fullscreen', 'clipboard-sanitized-write']);
const STANDARD_UA_PRODUCTS = '(?:Mozilla|AppleWebKit|Chrome|Safari)';
const NON_BROWSER_UA_TOKEN = new RegExp(
`\\s+(?!${STANDARD_UA_PRODUCTS}/)[A-Za-z][\\w.-]*/\\S+`,
'g',
);
/**
* Plain Chrome user agent. Google refuses sign-in from agents that advertise Electron or an
* embedding app, so every product token other than the standard Chrome ones is dropped.
*/
export function buildYoutubeBrowserUserAgent(defaultUserAgent: string): string {
return defaultUserAgent.replace(NON_BROWSER_UA_TOKEN, '').trim();
}
export type YoutubeBrowserWindowDeps = {
createBrowserWindow: (options: BrowserWindowConstructorOptions) => BrowserWindow;
preloadPath: string;
ipcMain: Pick<IpcMain, 'on'>;
showContextMenu: (window: BrowserWindow, template: MenuItemConstructorOptions[]) => void;
openExternal: (url: string) => void;
openVideo: (request: YoutubeBrowserVideoRequest) => Promise<YoutubeBrowserVideoResult>;
logWarn: (message: string, error?: unknown) => void;
logDebug: (message: string) => void;
onClosed?: () => void;
};
function isHttpUrl(url: string): boolean {
return url.startsWith('https://') || url.startsWith('http://');
}
function configureSession(session: Session): void {
session.setUserAgent(buildYoutubeBrowserUserAgent(session.getUserAgent()));
session.setPermissionRequestHandler((_webContents, permission, callback) => {
callback(ALLOWED_PERMISSIONS.has(permission));
});
}
/**
* A logged-in YouTube window whose video links open in mpv instead of the page player.
* The preload intercepts link clicks; navigation hooks here catch anything that slips past it.
*/
export function createYoutubeBrowserWindowRuntime(deps: YoutubeBrowserWindowDeps) {
let browserWindow: BrowserWindow | null = null;
let ipcRegistered = false;
// `trigger` names the path that caught the video, to tell duplicate requests apart in logs.
const sendVideo = (request: YoutubeBrowserVideoRequest, trigger: string): void => {
deps.logDebug(`YouTube browser ${request.action} via ${trigger}: ${request.url}`);
void deps
.openVideo(request)
.catch((error: unknown): YoutubeBrowserVideoResult => {
deps.logWarn('YouTube browser failed to open video in mpv', error);
return { ok: false, message: 'Could not open the video in mpv.' };
})
.then((result) => {
if (browserWindow && !browserWindow.isDestroyed()) {
browserWindow.webContents.send(IPC_CHANNELS.event.youtubeBrowserToast, result);
}
});
};
const registerIpc = (): void => {
if (ipcRegistered) return;
ipcRegistered = true;
deps.ipcMain.on(IPC_CHANNELS.command.youtubeBrowserOpenVideo, (event, payload: unknown) => {
if (!browserWindow || event.sender !== browserWindow.webContents) return;
const request = parseYoutubeBrowserVideoRequest(payload);
if (request) sendVideo(request, 'click');
});
};
const buildContextMenu = (window: BrowserWindow, linkUrl: string) => {
const history = window.webContents.navigationHistory;
const videoItems: MenuItemConstructorOptions[] = toYoutubeWatchUrl(linkUrl)
? [
{
label: 'Play in mpv',
click: () => sendVideo({ action: 'play', url: linkUrl }, 'context-menu'),
},
{
label: 'Queue in mpv',
click: () => sendVideo({ action: 'queue', url: linkUrl }, 'context-menu'),
},
{ type: 'separator' },
]
: [];
return [
...videoItems,
{ label: 'Back', enabled: history.canGoBack(), click: () => history.goBack() },
{ label: 'Forward', enabled: history.canGoForward(), click: () => history.goForward() },
{ label: 'Reload', click: () => window.webContents.reload() },
] satisfies MenuItemConstructorOptions[];
};
const createWindow = (): BrowserWindow => {
const window = deps.createBrowserWindow({
width: 1280,
height: 860,
title: 'SubMiner YouTube',
autoHideMenuBar: true,
backgroundColor: '#0f0f0f',
webPreferences: {
partition: YOUTUBE_BROWSER_PARTITION,
preload: deps.preloadPath,
nodeIntegration: false,
contextIsolation: true,
sandbox: true,
},
});
const { webContents } = window;
const { session } = webContents;
configureSession(session);
webContents.setWindowOpenHandler(({ url }) => {
if (toYoutubeWatchUrl(url)) {
sendVideo({ action: 'queue', url }, 'window-open');
} else if (isHttpUrl(url)) {
deps.openExternal(url);
}
return { action: 'deny' };
});
// Full page loads of a video (e.g. from a redirect) never reach the preload's click hook.
webContents.on('will-navigate', (event, url) => {
if (!toYoutubeWatchUrl(url)) return;
event.preventDefault();
sendVideo({ action: 'play', url }, 'will-navigate');
});
// In-app navigations the preload missed (keyboard shortcuts, script-driven links): stop the
// page player, hand the video to mpv, and return to the page the user came from.
webContents.on('did-navigate-in-page', (_event, url, isMainFrame) => {
if (!isMainFrame || !toYoutubeWatchUrl(url)) return;
void webContents
.executeJavaScript("document.querySelectorAll('video').forEach((v) => v.pause())")
.catch(() => {});
sendVideo({ action: 'play', url }, 'in-page-navigation');
if (webContents.navigationHistory.canGoBack()) {
webContents.navigationHistory.goBack();
} else {
void webContents.loadURL(YOUTUBE_BROWSER_HOME_URL).catch(() => {});
}
});
webContents.on('context-menu', (_event, params) => {
deps.showContextMenu(window, buildContextMenu(window, params.linkURL));
});
webContents.on('before-input-event', (event, input) => {
if (input.type !== 'keyDown') return;
const history = webContents.navigationHistory;
if (input.alt && input.key === 'ArrowLeft' && history.canGoBack()) {
event.preventDefault();
history.goBack();
} else if (input.alt && input.key === 'ArrowRight' && history.canGoForward()) {
event.preventDefault();
history.goForward();
} else if (input.key === 'F5' || ((input.control || input.meta) && input.key === 'r')) {
event.preventDefault();
webContents.reload();
}
});
window.on('closed', () => {
browserWindow = null;
void session.cookies.flushStore().catch((error: unknown) => {
deps.logWarn('Failed to flush YouTube browser cookies', error);
});
deps.onClosed?.();
});
void window.loadURL(YOUTUBE_BROWSER_HOME_URL).catch((error: unknown) => {
// YouTube redirects its landing page (e.g. `?themeRefresh=1`), which aborts the first load.
if ((error as { code?: unknown }).code === 'ERR_ABORTED') return;
deps.logWarn('Failed to load YouTube in the browser window', error);
});
return window;
};
const open = (): void => {
registerIpc();
if (browserWindow && !browserWindow.isDestroyed()) {
if (browserWindow.isMinimized()) browserWindow.restore();
browserWindow.show();
browserWindow.focus();
return;
}
browserWindow = createWindow();
};
const isOpen = (): boolean => Boolean(browserWindow && !browserWindow.isDestroyed());
return { open, isOpen };
}
+137 -2
View File
@@ -1733,7 +1733,6 @@ test('youtube flow injects downloaded primary even when reusable manual youtube
await runtime.runYoutubePlaybackFlow({
url: 'https://example.com/watch?v=video123',
mode: 'download',
});
assert.equal(selectedPrimarySid, 9);
@@ -1884,9 +1883,145 @@ test('youtube flow falls back to existing auto secondary track when auto seconda
await runtime.runYoutubePlaybackFlow({
url: 'https://example.com/watch?v=video123',
mode: 'download',
});
assert.equal(selectedPrimarySid, 4);
assert.equal(selectedSecondarySid, 1);
});
function createWhisperFlowHarness(
generateWhisperSubtitles: (input: {
url: string;
outputPath: string;
signal: AbortSignal;
}) => Promise<string | null>,
) {
const events: string[] = [];
const failures: string[] = [];
let addedSubtitlePath: string | null = null;
const runtime = createYoutubeFlowRuntime({
probeYoutubeTracks: async () => {
throw new Error('Whisper mode must not probe YouTube captions');
},
acquireYoutubeSubtitleTracks: async () => {
throw new Error('Whisper mode must not download YouTube captions');
},
acquireYoutubeSubtitleTrack: async () => {
throw new Error('Whisper mode must not download YouTube captions');
},
openPicker: async () => false,
pauseMpv: () => events.push('pause'),
resumeMpv: () => events.push('resume'),
sendMpvCommand: (command) => {
if (command[0] === 'sub-add') {
addedSubtitlePath = String(command[1]);
events.push('sub-add');
}
if (command[0] === 'script-message') events.push(String(command[1]));
},
requestMpvProperty: async (name) => {
if (name === 'track-list') {
return addedSubtitlePath
? [{ type: 'sub', id: 3, external: true, 'external-filename': addedSubtitlePath }]
: [];
}
if (name === 'sid') return addedSubtitlePath ? 3 : null;
return null;
},
refreshCurrentSubtitle: () => {},
startTokenizationWarmups: async () => {},
waitForTokenizationReady: async () => {},
waitForAnkiReady: async () => {},
wait: async () => {},
waitForPlaybackWindowReady: async () => {},
waitForOverlayGeometryReady: async () => {},
focusOverlayWindow: () => {},
showMpvOsd: () => {},
reportSubtitleFailure: (message) => failures.push(message),
warn: () => {},
log: () => {},
getYoutubeOutputDir: () => '/tmp',
getSubtitleSource: () => 'whisper',
generateWhisperSubtitles: (input) => {
events.push('generate');
return generateWhisperSubtitles(input);
},
openSubtitleGenerationModal: async () => {
events.push('open-modal');
return true;
},
});
return {
runtime,
failures,
// The modal opens once the player window is ready, so its position in the order varies.
events: () => events.filter((event) => event !== 'open-modal'),
openedModal: () => events.includes('open-modal'),
getAddedSubtitlePath: () => addedSubtitlePath,
};
}
const WHISPER_URL = 'https://www.youtube.com/watch?v=abcdefghijk';
test('whisper subtitle source keeps the video paused until the generated subtitles load', async () => {
const harness = createWhisperFlowHarness(async (input) => input.outputPath);
await harness.runtime.runYoutubePlaybackFlow({ url: WHISPER_URL });
assert.deepEqual(harness.events(), [
'pause',
'subminer-autoplay-hold',
'generate',
'sub-add',
'subminer-autoplay-ready',
'resume',
]);
assert.equal(harness.openedModal(), true);
assert.equal(path.basename(harness.getAddedSubtitlePath() ?? ''), 'youtube-whisper.ja.srt');
assert.deepEqual(harness.failures, []);
});
test('cancelling whisper generation resumes playback without subtitles or an error', async () => {
const harness = createWhisperFlowHarness(async () => null);
await harness.runtime.runYoutubePlaybackFlow({ url: WHISPER_URL });
assert.equal(harness.getAddedSubtitlePath(), null);
assert.deepEqual(harness.failures, []);
assert.equal(harness.events().at(-1), 'resume');
});
test('whisper generation stops quietly and leaves playback alone when the video changes', async () => {
let signal: AbortSignal | null = null;
const harness = createWhisperFlowHarness(
(input) =>
new Promise((_, reject) => {
signal = input.signal;
input.signal.addEventListener('abort', () => reject(new Error('cancelled')));
}),
);
const flow = harness.runtime.runYoutubePlaybackFlow({ url: WHISPER_URL });
while (!signal) await new Promise((resolve) => setImmediate(resolve));
harness.runtime.handleMediaPathChange(WHISPER_URL);
assert.equal((signal as AbortSignal).aborted, false);
harness.runtime.handleMediaPathChange('https://www.youtube.com/watch?v=zyxwvutsrqp');
await flow;
assert.equal((signal as AbortSignal).aborted, true);
assert.equal(harness.getAddedSubtitlePath(), null);
assert.deepEqual(harness.failures, []);
assert.equal(harness.events().includes('resume'), false);
});
test('whisper generation failures are reported and playback resumes', async () => {
const harness = createWhisperFlowHarness(async () => {
throw new Error('No Whisper model found.');
});
await harness.runtime.runYoutubePlaybackFlow({ url: WHISPER_URL });
assert.deepEqual(harness.failures, ['Whisper subtitles failed: No Whisper model found.']);
assert.equal(harness.getAddedSubtitlePath(), null);
assert.equal(harness.events().at(-1), 'resume');
});
+158 -19
View File
@@ -5,6 +5,8 @@ import type {
YoutubePickerResolveRequest,
YoutubePickerResolveResult,
} from '../../types';
import type { YoutubeSubtitleSource } from '../../types/integrations';
import type { SubtitleGenerationProgress } from '../../shared/subtitle-generation';
import type {
YoutubeTrackOption,
YoutubeTrackProbeResult,
@@ -18,9 +20,9 @@ import {
acquireYoutubeSubtitleTracks,
} from '../../core/services/youtube/generate';
import { resolveSubtitleSourcePath } from './subtitle-prefetch-source';
import { extractYoutubeVideoId } from './youtube-playback';
type YoutubeFlowOpenPicker = (payload: YoutubePickerOpenPayload) => Promise<boolean>;
type YoutubeFlowMode = 'download' | 'generate';
type YoutubeFlowDeps = {
probeYoutubeTracks: (url: string) => Promise<YoutubeTrackProbeResult>;
@@ -48,6 +50,20 @@ type YoutubeFlowDeps = {
getYoutubeOutputDir: () => string;
createSubtitleTempDir?: () => Promise<string>;
cleanupSubtitleTempDirs?: (dirs: string[]) => void;
/** Current `youtube.subtitleSource`; YouTube captions when absent. */
getSubtitleSource?: () => YoutubeSubtitleSource;
/**
* Transcribes the video's audio into `outputPath`. Resolves to the written file, or null
* when the user cancelled generation.
*/
generateWhisperSubtitles?: (input: {
url: string;
outputPath: string;
signal: AbortSignal;
onProgress: (progress: SubtitleGenerationProgress) => void;
}) => Promise<string | null>;
/** Shows Whisper progress; closing it leaves generation running. */
openSubtitleGenerationModal?: () => Promise<boolean>;
};
type YoutubeFlowSession = {
@@ -58,6 +74,13 @@ type YoutubeFlowSession = {
const YOUTUBE_PICKER_SETTLE_DELAY_MS = 150;
const YOUTUBE_SECONDARY_RETRY_DELAY_MS = 350;
const WHISPER_TRACK: YoutubeTrackOption = {
id: 'whisper:ja',
language: 'ja',
sourceLanguage: 'ja',
kind: 'auto',
label: 'Japanese (Whisper)',
};
function createSessionId(): string {
return `yt-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
@@ -102,6 +125,21 @@ function createYoutubeFlowOsdProgress(showMpvOsd: (text: string) => void) {
};
}
/** Shows Whisper progress, skipping updates that would not change the text. */
function createWhisperProgressReporter(
showMpvOsd: (text: string) => void,
): (progress: SubtitleGenerationProgress) => void {
let lastText = '';
return (progress) => {
const percent =
progress.percent === undefined ? '' : ` ${Math.floor(progress.percent / 5) * 5}%`;
const text = `Whisper: ${progress.message.replace(/\.+$/, '')}${percent}`;
if (text === lastText) return;
lastText = text;
showMpvOsd(text);
};
}
function releasePlaybackGate(deps: YoutubeFlowDeps): void {
deps.sendMpvCommand(['script-message', 'subminer-autoplay-ready']);
deps.resumeMpv();
@@ -429,6 +467,20 @@ async function injectDownloadedSubtitles(
export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
let activeSession: YoutubeFlowSession | null = null;
const activeSubtitleTempDirs = new Set<string>();
let activeWhisperJob: { videoId: string | null; controller: AbortController } | null = null;
/** Aborting also deletes the job's downloaded audio. */
const cancelWhisperGeneration = (): void => {
activeWhisperJob?.controller.abort();
activeWhisperJob = null;
};
const handleMediaPathChange = (mediaPath: string | null | undefined): void => {
if (!activeWhisperJob) return;
const videoId = extractYoutubeVideoId(mediaPath);
if (videoId && videoId === activeWhisperJob.videoId) return;
cancelWhisperGeneration();
};
const cleanupSubtitleTempDirs = (): void => {
const dirs = [...activeSubtitleTempDirs];
@@ -476,6 +528,14 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
}
};
/** A temp dir for subtitles loaded outside the flow; removed with the flow's own. */
const createSubtitleTempDir = async (): Promise<string> => {
if (!deps.createSubtitleTempDir) return normalizeOutputPath(deps.getYoutubeOutputDir());
const dir = await deps.createSubtitleTempDir();
activeSubtitleTempDirs.add(dir);
return dir;
};
const acquireSelectedTracks = async (input: {
targetUrl: string;
outputDir: string;
@@ -604,7 +664,6 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
const loadTracksIntoMpv = async (input: {
url: string;
mode: YoutubeFlowMode;
outputDir: string;
primaryTrack: YoutubeTrackOption;
secondaryTrack: YoutubeTrackOption | null;
@@ -751,10 +810,7 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
}
};
const openManualPicker = async (input: {
url: string;
mode?: YoutubeFlowMode;
}): Promise<void> => {
const openManualPicker = async (input: { url: string }): Promise<void> => {
deps.showMpvOsd('Opening YouTube subtitle picker...');
let probe: YoutubeTrackProbeResult;
@@ -818,11 +874,12 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
});
const secondaryTrack = getTrackById(probe.tracks, selected.secondaryTrackId);
// A track picked by hand replaces whatever Whisper would have loaded.
cancelWhisperGeneration();
try {
deps.showMpvOsd('Getting subtitles...');
const loaded = await loadTracksIntoMpv({
url: input.url,
mode: input.mode ?? 'download',
outputDir: normalizeOutputPath(deps.getYoutubeOutputDir()),
primaryTrack,
secondaryTrack,
@@ -844,10 +901,86 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
}
};
async function runYoutubePlaybackFlow(input: {
url: string;
mode: YoutubeFlowMode;
}): Promise<void> {
const openWhisperProgressModal = async (jobSignal: AbortSignal): Promise<void> => {
if (!deps.openSubtitleGenerationModal) return;
// Like the picker, wait for mpv's window so the modal lands on top of it.
await deps.waitForPlaybackWindowReady();
await deps.waitForOverlayGeometryReady();
if (jobSignal.aborted) return;
if (!(await deps.openSubtitleGenerationModal().catch(() => false))) {
deps.warn('Unable to open the subtitle generation modal for Whisper progress.');
}
};
const loadWhisperSubtitles = async (
url: string,
generate: NonNullable<YoutubeFlowDeps['generateWhisperSubtitles']>,
tokenizationWarmupPromise: Promise<void>,
): Promise<void> => {
const job = { videoId: extractYoutubeVideoId(url), controller: new AbortController() };
activeWhisperJob = job;
// Playback stays paused until the subtitles load. Generation outlasts the mpv plugin's
// pause-until-ready timeout, so ask it to hold the pause instead of resuming on its own.
deps.sendMpvCommand(['script-message', 'subminer-autoplay-hold']);
deps.showMpvOsd('Generating Japanese subtitles with Whisper...');
try {
const outputDir = await prepareSubtitleOutputDir(
normalizeOutputPath(deps.getYoutubeOutputDir()),
);
const generation = generate({
url,
outputPath: path.join(outputDir, 'youtube-whisper.ja.srt'),
signal: job.controller.signal,
onProgress: createWhisperProgressReporter(deps.showMpvOsd),
});
void openWhisperProgressModal(job.controller.signal);
const subtitlePath = await generation;
job.controller.signal.throwIfAborted();
if (subtitlePath === null) {
deps.log('Whisper subtitle generation was cancelled.');
return;
}
const loaded = await injectDownloadedSubtitles(
deps,
{ track: WHISPER_TRACK, existingTrackId: null, injectedPath: subtitlePath },
null,
null,
);
if (!loaded) {
reportPrimarySubtitleFailure();
return;
}
deps.notifyPrimarySubtitleLoaded?.();
try {
await deps.refreshSubtitleSidebarSource?.(subtitlePath, url);
} catch (error) {
deps.warn(
`Failed to refresh parsed subtitle cues for sidebar: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
await tokenizationWarmupPromise;
await deps.waitForTokenizationReady();
await deps.waitForAnkiReady();
} catch (error) {
if (job.controller.signal.aborted) return;
const message = error instanceof Error ? error.message : String(error);
deps.warn(`Failed to generate YouTube subtitles with Whisper: ${message}`);
deps.reportSubtitleFailure(`Whisper subtitles failed: ${message}`);
} finally {
if (activeWhisperJob === job) activeWhisperJob = null;
if (job.controller.signal.aborted) {
// The video changed or closed; playback now belongs to whatever replaced it.
deps.log('Stopped Whisper subtitle generation because the video changed or closed.');
} else {
releasePlaybackGate(deps);
restoreOverlayInputFocus(deps);
}
}
};
async function runYoutubePlaybackFlow(input: { url: string }): Promise<void> {
deps.showMpvOsd('Opening YouTube video');
const tokenizationWarmupPromise = deps.startTokenizationWarmups().catch((error) => {
deps.warn(
@@ -859,6 +992,15 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
deps.pauseMpv();
suppressYoutubeSubtitleState(deps);
cancelWhisperGeneration();
if (deps.getSubtitleSource?.() === 'whisper' && deps.generateWhisperSubtitles) {
await loadWhisperSubtitles(
input.url,
deps.generateWhisperSubtitles,
tokenizationWarmupPromise,
);
return;
}
const outputDir = normalizeOutputPath(deps.getYoutubeOutputDir());
let probe: YoutubeTrackProbeResult;
@@ -890,14 +1032,10 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
deps.showMpvOsd('Getting subtitles...');
const loaded = await loadTracksIntoMpv({
url: input.url,
mode: input.mode,
outputDir,
primaryTrack,
secondaryTrack,
secondaryFailureLabel:
input.mode === 'generate'
? 'Failed to generate secondary YouTube subtitle track'
: 'Failed to download secondary YouTube subtitle track',
secondaryFailureLabel: 'Failed to download secondary YouTube subtitle track',
tokenizationWarmupPromise,
showDownloadProgress: false,
});
@@ -906,9 +1044,7 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
}
} catch (error) {
deps.warn(
`Failed to ${
input.mode === 'generate' ? 'generate' : 'download'
} primary YouTube subtitle track: ${
`Failed to download primary YouTube subtitle track: ${
error instanceof Error ? error.message : String(error)
}`,
);
@@ -926,5 +1062,8 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
cancelActivePicker,
hasActiveSession: () => Boolean(activeSession),
cleanupSubtitleTempDirs,
createSubtitleTempDir,
cancelWhisperGeneration,
handleMediaPathChange,
};
}
@@ -38,8 +38,8 @@ test('youtube playback runtime resets flow ownership after a successful run', as
calls.push(`prepare:${url}`);
return true;
},
runYoutubePlaybackFlow: async ({ url, mode }) => {
calls.push(`run-flow:${url}:${mode}`);
runYoutubePlaybackFlow: async ({ url }) => {
calls.push(`run-flow:${url}`);
},
logInfo: (message) => {
calls.push(`info:${message}`);
@@ -59,7 +59,6 @@ test('youtube playback runtime resets flow ownership after a successful run', as
await runtime.runYoutubePlaybackFlow({
url: 'https://youtu.be/demo',
mode: 'download',
source: 'initial',
});
@@ -117,8 +116,8 @@ test('youtube playback runtime resolves the socket path lazily for windows start
calls.push(`prepare:${url}`);
return true;
},
runYoutubePlaybackFlow: async ({ url, mode }) => {
calls.push(`run-flow:${url}:${mode}`);
runYoutubePlaybackFlow: async ({ url }) => {
calls.push(`run-flow:${url}`);
},
logInfo: (message) => {
calls.push(`info:${message}`);
@@ -140,7 +139,6 @@ test('youtube playback runtime resolves the socket path lazily for windows start
await runtime.runYoutubePlaybackFlow({
url: 'https://youtu.be/demo',
mode: 'download',
source: 'initial',
});
@@ -185,8 +183,8 @@ test('youtube playback runtime starts media cache without blocking the subtitle
await cachePromise;
calls.push('cache-done');
},
runYoutubePlaybackFlow: async ({ url, mode }) => {
calls.push(`run-flow:${url}:${mode}`);
runYoutubePlaybackFlow: async ({ url }) => {
calls.push(`run-flow:${url}`);
},
logInfo: (message) => {
calls.push(`info:${message}`);
@@ -200,13 +198,12 @@ test('youtube playback runtime starts media cache without blocking the subtitle
await runtime.runYoutubePlaybackFlow({
url: 'https://youtu.be/demo',
mode: 'download',
source: 'second-instance',
});
const prepareIndex = calls.indexOf('prepare:https://youtu.be/demo');
const cacheIndex = calls.indexOf('cache:https://youtu.be/demo');
const runFlowIndex = calls.indexOf('run-flow:https://youtu.be/demo:download');
const runFlowIndex = calls.indexOf('run-flow:https://youtu.be/demo');
assert.notEqual(prepareIndex, -1);
assert.notEqual(cacheIndex, -1);
assert.notEqual(runFlowIndex, -1);
@@ -251,8 +248,8 @@ test('youtube playback runtime logs synchronous media cache startup failures', a
calls.push('cache');
throw new Error('cache exploded');
},
runYoutubePlaybackFlow: async ({ url, mode }) => {
calls.push(`run-flow:${url}:${mode}`);
runYoutubePlaybackFlow: async ({ url }) => {
calls.push(`run-flow:${url}`);
},
logInfo: (message) => {
calls.push(`info:${message}`);
@@ -266,12 +263,11 @@ test('youtube playback runtime logs synchronous media cache startup failures', a
await runtime.runYoutubePlaybackFlow({
url: 'https://youtu.be/demo',
mode: 'download',
source: 'second-instance',
});
await Promise.resolve();
assert.ok(calls.includes('run-flow:https://youtu.be/demo:download'));
assert.ok(calls.includes('run-flow:https://youtu.be/demo'));
assert.ok(
calls.some((entry) =>
entry.startsWith('warn:Failed to start YouTube media cache: cache exploded'),
+7 -11
View File
@@ -1,4 +1,7 @@
import type { CliArgs, CliCommandSource } from '../../cli/args';
import type { CliCommandSource } from '../../cli/args';
/** Where a playback flow request came from; only `initial` arms quit-on-disconnect. */
export type YoutubePlaybackFlowSource = CliCommandSource | 'youtube-browser';
type LaunchResult = {
ok: boolean;
@@ -21,10 +24,7 @@ export type YoutubePlaybackRuntimeDeps = {
waitForYoutubeMpvConnected: (timeoutMs: number) => Promise<boolean>;
prepareYoutubePlaybackInMpv: (request: { url: string }) => Promise<boolean>;
startYoutubeMediaCache?: (url: string) => void | Promise<void>;
runYoutubePlaybackFlow: (request: {
url: string;
mode: NonNullable<CliArgs['youtubeMode']>;
}) => Promise<void>;
runYoutubePlaybackFlow: (request: { url: string }) => Promise<void>;
logInfo: (message: string) => void;
logWarn: (message: string) => void;
schedule: (callback: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
@@ -45,8 +45,7 @@ export function createYoutubePlaybackRuntime(deps: YoutubePlaybackRuntimeDeps) {
const runYoutubePlaybackFlow = async (request: {
url: string;
mode: NonNullable<CliArgs['youtubeMode']>;
source: CliCommandSource;
source: YoutubePlaybackFlowSource;
}): Promise<void> => {
const flowGeneration = ++playbackFlowGeneration;
deps.invalidatePendingAutoplayReadyFallbacks();
@@ -139,10 +138,7 @@ export function createYoutubePlaybackRuntime(deps: YoutubePlaybackRuntimeDeps) {
});
}
await deps.runYoutubePlaybackFlow({
url: request.url,
mode: request.mode,
});
await deps.runYoutubePlaybackFlow({ url: request.url });
flowCompleted = true;
deps.logInfo(`YouTube playback flow completed from ${request.source}.`);
} finally {
+23
View File
@@ -1,6 +1,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
toYoutubeWatchUrl,
isSameYoutubeMediaPath,
isYoutubeMediaPath,
isYoutubePlaybackActive,
@@ -76,3 +77,25 @@ test('shouldUseCachedYoutubeParsedCues requires cached cues for the same youtube
false,
);
});
test('toYoutubeWatchUrl reduces watch, shorts, and short links to a single-video url', () => {
assert.equal(
toYoutubeWatchUrl('https://www.youtube.com/watch?v=dQw4w9WgXcQ&list=WL&index=3&t=42s'),
'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
);
assert.equal(
toYoutubeWatchUrl('https://m.youtube.com/shorts/dQw4w9WgXcQ'),
'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
);
assert.equal(
toYoutubeWatchUrl('https://youtu.be/dQw4w9WgXcQ?si=abc'),
'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
);
});
test('toYoutubeWatchUrl rejects non-video pages and malformed ids', () => {
assert.equal(toYoutubeWatchUrl('https://www.youtube.com/feed/subscriptions'), null);
assert.equal(toYoutubeWatchUrl('https://www.youtube.com/watch?v=bad id'), null);
assert.equal(toYoutubeWatchUrl('https://example.com/watch?v=dQw4w9WgXcQ'), null);
assert.equal(toYoutubeWatchUrl('/tmp/video.mkv'), null);
});
+14 -1
View File
@@ -10,7 +10,7 @@ function matchesYoutubeHost(hostname: string, expectedHost: string): boolean {
return hostname === expectedHost || hostname.endsWith(`.${expectedHost}`);
}
function extractYoutubeVideoId(mediaPath: string | null | undefined): string | null {
export function extractYoutubeVideoId(mediaPath: string | null | undefined): string | null {
const normalized = trimToNull(mediaPath);
if (!normalized) {
return null;
@@ -43,6 +43,19 @@ function extractYoutubeVideoId(mediaPath: string | null | undefined): string | n
return null;
}
const YOUTUBE_VIDEO_ID_PATTERN = /^[A-Za-z0-9_-]{11}$/;
/**
* Single-video watch URL for a YouTube link. Drops `list=`/`index=`/`t=` so mpv's ytdl hook
* loads one video instead of expanding the whole playlist.
*/
export function toYoutubeWatchUrl(mediaPath: string | null | undefined): string | null {
const videoId = extractYoutubeVideoId(mediaPath);
return videoId && YOUTUBE_VIDEO_ID_PATTERN.test(videoId)
? `https://www.youtube.com/watch?v=${videoId}`
: null;
}
export function isYoutubeMediaPath(mediaPath: string | null | undefined): boolean {
const normalized = trimToNull(mediaPath);
if (!normalized) {
+97
View File
@@ -0,0 +1,97 @@
import { ipcRenderer } from 'electron';
// Sandboxed preloads can only require `electron`, so these mirror IPC_CHANNELS in
// src/shared/ipc/contracts.ts (youtubeBrowserOpenVideo / youtubeBrowserToast).
const OPEN_VIDEO_CHANNEL = 'youtube-browser:open-video';
const TOAST_CHANNEL = 'youtube-browser:toast';
const MOUSE_BUTTON_MIDDLE = 1;
const MOUSE_BUTTON_BACK = 3;
const MOUSE_BUTTON_FORWARD = 4;
function isYoutubeVideoHref(href: string): boolean {
let url: URL;
try {
url = new URL(href);
} catch {
return false;
}
const host = url.hostname.toLowerCase();
if (host !== 'youtube.com' && !host.endsWith('.youtube.com')) return false;
return (
(url.pathname === '/watch' && url.searchParams.has('v')) ||
/^\/shorts\/[^/]+/.test(url.pathname)
);
}
function findVideoLink(event: MouseEvent): HTMLAnchorElement | null {
for (const node of event.composedPath()) {
if (node instanceof HTMLAnchorElement && node.href && isYoutubeVideoHref(node.href)) {
return node;
}
}
return null;
}
// Capture phase runs before YouTube's own click handlers, so the page never starts its player.
// Plain click plays now; middle click or a modifier-click queues behind the current video.
function interceptVideoClick(event: MouseEvent): void {
const isPrimary = event.type === 'click' && event.button === 0;
const isMiddle = event.type === 'auxclick' && event.button === MOUSE_BUTTON_MIDDLE;
if (!isPrimary && !isMiddle) return;
const link = findVideoLink(event);
if (!link) return;
event.preventDefault();
event.stopImmediatePropagation();
const queue = isMiddle || event.shiftKey || event.ctrlKey || event.metaKey;
ipcRenderer.send(OPEN_VIDEO_CHANNEL, { action: queue ? 'queue' : 'play', url: link.href });
// Immediate feedback: starting mpv can take a few seconds before the result toast arrives.
showToast(queue ? 'Queueing in mpv…' : 'Starting mpv…', true);
}
window.addEventListener('click', interceptVideoClick, true);
window.addEventListener('auxclick', interceptVideoClick, true);
// Chromium only maps mouse back/forward buttons to history inside a real browser.
window.addEventListener('mouseup', (event) => {
if (event.button === MOUSE_BUTTON_BACK) history.back();
if (event.button === MOUSE_BUTTON_FORWARD) history.forward();
});
let toastTimer: ReturnType<typeof setTimeout> | null = null;
function showToast(message: string, ok: boolean): void {
let toast = document.getElementById('subminer-youtube-toast');
if (!toast) {
toast = document.createElement('div');
toast.id = 'subminer-youtube-toast';
Object.assign(toast.style, {
position: 'fixed',
left: '50%',
bottom: '32px',
transform: 'translateX(-50%)',
zIndex: '2147483647',
padding: '10px 18px',
borderRadius: '8px',
font: '500 14px/1.4 Roboto, Arial, sans-serif',
color: '#fff',
boxShadow: '0 4px 16px rgba(0, 0, 0, 0.4)',
pointerEvents: 'none',
transition: 'opacity 150ms ease',
});
document.body.appendChild(toast);
}
toast.textContent = message;
toast.style.background = ok ? '#1f1f1f' : '#8c1d18';
toast.style.opacity = '1';
if (toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(() => {
if (toast) toast.style.opacity = '0';
}, 2200);
}
ipcRenderer.on(TOAST_CHANNEL, (_event, payload: unknown) => {
if (typeof payload !== 'object' || payload === null) return;
const { message, ok } = payload as { message?: unknown; ok?: unknown };
if (typeof message === 'string') showToast(message, ok !== false);
});
+3 -2
View File
@@ -88,7 +88,7 @@ export function createSubtitleGenerationModal(
const vad = snapshot ? describeGenerationVad(snapshot.vad) : null;
const tools = snapshot ? describeGenerationTools(snapshot.tools) : null;
const readyMessage = !snapshot?.mediaPath
? 'Open local media to generate subtitles.'
? 'Open a local file or a YouTube video to generate subtitles.'
: !tools?.ready
? 'Install the missing tools or set their paths in Settings, then click Check again.'
: !model?.ready
@@ -96,7 +96,8 @@ export function createSubtitleGenerationModal(
: !vad?.ready
? 'Download the speech detection model or uncheck Focus on spoken dialogue.'
: 'Ready when you are.';
dom.media.textContent = snapshot?.mediaPath ?? 'Open a local media file in the player first.';
dom.media.textContent =
snapshot?.mediaPath ?? 'Open a local file or a YouTube video in the player first.';
dom.tools.textContent = tools?.text ?? 'Checking local tools...';
dom.model.textContent = model?.text ?? 'Checking local models...';
dom.modelPicker.classList.toggle('hidden', !snapshot || Boolean(snapshot.externalModelPath));
+2
View File
@@ -50,6 +50,7 @@ export const IPC_CHANNELS = {
toggleStatsOverlay: 'stats:toggle-overlay',
markActiveVideoWatched: 'immersion:mark-active-video-watched',
dispatchSessionAction: 'session-action:dispatch',
youtubeBrowserOpenVideo: 'youtube-browser:open-video',
},
request: {
getSubtitleSelection: 'subtitle-selection:get',
@@ -184,6 +185,7 @@ export const IPC_CHANNELS = {
notificationHistoryToggle: 'notification-history:toggle',
syncUiProgress: 'sync-ui:progress',
syncUiStateChanged: 'sync-ui:state-changed',
youtubeBrowserToast: 'youtube-browser:toast',
},
} as const;
+2 -10
View File
@@ -20,7 +20,7 @@ import type {
YomitanConfig,
YoutubeConfig,
YoutubeMediaCacheMode,
YoutubeSubgenConfig,
YoutubeSubtitleSource,
} from './integrations';
import type {
ControllerButtonIndicesConfig,
@@ -170,7 +170,6 @@ export interface Config {
discordPresence?: DiscordPresenceConfig;
ai?: AiConfig;
youtube?: YoutubeConfig;
youtubeSubgen?: YoutubeSubgenConfig;
immersionTracking?: ImmersionTrackingConfig;
stats?: StatsConfig;
updates?: UpdatesConfig;
@@ -388,19 +387,12 @@ export interface ResolvedConfig {
};
youtube: YoutubeConfig & {
primarySubLanguages: string[];
subtitleSource: YoutubeSubtitleSource;
mediaCache: {
mode: YoutubeMediaCacheMode;
maxHeight: number;
};
};
youtubeSubgen: YoutubeSubgenConfig & {
whisperBin: string;
whisperModel: string;
whisperVadModel: string;
whisperThreads: number;
fixWithAi: boolean;
ai: AiFeatureConfig;
};
immersionTracking: {
enabled: boolean;
dbPath?: string;
+4 -9
View File
@@ -124,6 +124,9 @@ export interface AiConfig {
export type YoutubeMediaCacheMode = 'direct' | 'background';
/** Where primary YouTube subtitles come from: YouTube's captions, or Whisper on the audio. */
export type YoutubeSubtitleSource = 'youtube' | 'whisper';
export interface YoutubeMediaCacheConfig {
mode?: YoutubeMediaCacheMode;
maxHeight?: number;
@@ -131,18 +134,10 @@ export interface YoutubeMediaCacheConfig {
export interface YoutubeConfig {
primarySubLanguages?: string[];
subtitleSource?: YoutubeSubtitleSource;
mediaCache?: YoutubeMediaCacheConfig;
}
export interface YoutubeSubgenConfig {
whisperBin?: string;
whisperModel?: string;
whisperVadModel?: string;
whisperThreads?: number;
fixWithAi?: boolean;
ai?: AiFeatureConfig;
}
export interface StatsConfig {
toggleKey?: string;
markWatchedKey?: string;