mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-10-21 05:01:26 -07:00
feat(youtube): add YouTube browser window and Whisper subtitle source
- Add `subminer youtube`/`yt` and tray "Browse YouTube" window that plays or queues picked videos in mpv - Add `youtube.subtitleSource` (`youtube` | `whisper`) to transcribe YouTube audio via subtitle generation - Allow the subtitle generation modal on YouTube videos - Pick the real Japanese track over machine-translated ones and fall back to yt-dlp on HTTP 429 - Remove legacy launcher youtubeSubgen/whisper/AI args and `--youtube-mode` - Fix EPIPE crash when the launching terminal is closed with Ctrl+C - Thin media timing review handles with orange original-boundary bars - Update docs and change fragments
This commit is contained in:
105 files changed
+2297
-623
No files matched your search
@@ -98,15 +98,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);
|
||||
});
|
||||
@@ -267,6 +261,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
@@ -7,7 +7,6 @@ export interface CliArgs {
|
||||
launchMpv: boolean;
|
||||
launchMpvTargets: string[];
|
||||
youtubePlay?: string;
|
||||
youtubeMode?: 'download' | 'generate';
|
||||
stop: boolean;
|
||||
toggle: boolean;
|
||||
toggleVisibleOverlay: boolean;
|
||||
@@ -16,6 +15,7 @@ export interface CliArgs {
|
||||
hachidori: boolean;
|
||||
settings: boolean;
|
||||
syncWindow: boolean;
|
||||
youtubeBrowser: boolean;
|
||||
setup: boolean;
|
||||
show: boolean;
|
||||
hide: boolean;
|
||||
@@ -129,7 +129,6 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
launchMpv: false,
|
||||
launchMpvTargets: [],
|
||||
youtubePlay: undefined,
|
||||
youtubeMode: undefined,
|
||||
stop: false,
|
||||
toggle: false,
|
||||
toggleVisibleOverlay: false,
|
||||
@@ -138,6 +137,7 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
hachidori: false,
|
||||
settings: false,
|
||||
syncWindow: false,
|
||||
youtubeBrowser: false,
|
||||
setup: false,
|
||||
show: false,
|
||||
hide: false,
|
||||
@@ -272,12 +272,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('--'));
|
||||
@@ -290,6 +284,7 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
else if (arg === '--hachidori') args.hachidori = 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;
|
||||
@@ -574,6 +569,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
|
||||
args.hachidori ||
|
||||
args.settings ||
|
||||
args.syncWindow ||
|
||||
args.youtubeBrowser ||
|
||||
args.setup ||
|
||||
args.show ||
|
||||
args.hide ||
|
||||
@@ -654,6 +650,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
|
||||
!args.hachidori &&
|
||||
!args.settings &&
|
||||
!args.syncWindow &&
|
||||
!args.youtubeBrowser &&
|
||||
!args.setup &&
|
||||
!args.show &&
|
||||
!args.hide &&
|
||||
@@ -727,6 +724,7 @@ export function shouldStartApp(args: CliArgs): boolean {
|
||||
args.hachidori ||
|
||||
args.settings ||
|
||||
args.syncWindow ||
|
||||
args.youtubeBrowser ||
|
||||
args.setup ||
|
||||
args.copySubtitle ||
|
||||
args.copySubtitleMultiple ||
|
||||
@@ -789,6 +787,7 @@ export function shouldRunDictionarySettingsOnlyStartup(args: CliArgs): boolean {
|
||||
!args.togglePrimarySubtitleBar &&
|
||||
!args.settings &&
|
||||
!args.syncWindow &&
|
||||
!args.youtubeBrowser &&
|
||||
!args.show &&
|
||||
!args.hide &&
|
||||
!args.setup &&
|
||||
|
||||
@@ -80,6 +80,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
@@ -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', () => {
|
||||
|
||||
@@ -51,7 +51,6 @@ const {
|
||||
jellyfin,
|
||||
discordPresence,
|
||||
ai,
|
||||
youtubeSubgen,
|
||||
} = INTEGRATIONS_DEFAULT_CONFIG;
|
||||
const { subtitleStyle, subtitleSidebar } = SUBTITLE_DEFAULT_CONFIG;
|
||||
const { immersionTracking } = IMMERSION_DEFAULT_CONFIG;
|
||||
@@ -89,7 +88,6 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
|
||||
jellyfin,
|
||||
discordPresence,
|
||||
ai,
|
||||
youtubeSubgen,
|
||||
immersionTracking,
|
||||
stats,
|
||||
};
|
||||
|
||||
@@ -119,6 +119,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', () => {
|
||||
|
||||
@@ -133,6 +133,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',
|
||||
|
||||
@@ -199,7 +199,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',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -366,6 +366,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') {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
@@ -529,7 +523,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);
|
||||
@@ -729,9 +722,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),
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -18,6 +18,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
hachidori: false,
|
||||
settings: false,
|
||||
syncWindow: false,
|
||||
youtubeBrowser: false,
|
||||
setup: false,
|
||||
show: false,
|
||||
hide: false,
|
||||
|
||||
@@ -15,7 +15,6 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
launchMpv: false,
|
||||
launchMpvTargets: [],
|
||||
youtubePlay: undefined,
|
||||
youtubeMode: undefined,
|
||||
stop: false,
|
||||
toggle: false,
|
||||
toggleVisibleOverlay: false,
|
||||
@@ -23,6 +22,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
hachidori: false,
|
||||
settings: false,
|
||||
syncWindow: false,
|
||||
youtubeBrowser: false,
|
||||
setup: false,
|
||||
show: false,
|
||||
hide: false,
|
||||
@@ -146,6 +146,9 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
|
||||
openSyncUiWindow: () => {
|
||||
calls.push('openSyncUiWindow');
|
||||
},
|
||||
openYoutubeBrowserWindow: () => {
|
||||
calls.push('openYoutubeBrowserWindow');
|
||||
},
|
||||
openFirstRunSetup: (force?: boolean) => {
|
||||
calls.push(`openFirstRunSetup:${force === true ? 'force' : 'default'}`);
|
||||
},
|
||||
@@ -247,7 +250,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 ?? ''}`);
|
||||
@@ -287,46 +290,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 () => {
|
||||
@@ -336,11 +323,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:')));
|
||||
@@ -681,6 +664,7 @@ test('createCliCommandDepsRuntime reconnects MPV client when reconnect hook exis
|
||||
openYomitanSettings: () => {},
|
||||
openConfigSettingsWindow: () => {},
|
||||
openSyncUiWindow: () => {},
|
||||
openYoutubeBrowserWindow: () => {},
|
||||
cycleSecondarySubMode: () => {},
|
||||
openRuntimeOptionsPalette: () => {},
|
||||
printHelp: () => {},
|
||||
@@ -794,6 +778,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',
|
||||
@@ -827,9 +812,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}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -46,6 +46,7 @@ export interface CliCommandServiceDeps {
|
||||
openHachidoriSettingsDelayed: (delayMs: number) => void;
|
||||
openConfigSettingsWindow: () => void;
|
||||
openSyncUiWindow: () => void;
|
||||
openYoutubeBrowserWindow: () => void;
|
||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
||||
copyCurrentSubtitle: () => void;
|
||||
startPendingMultiCopy: (timeoutMs: number) => void;
|
||||
@@ -103,11 +104,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;
|
||||
@@ -174,6 +171,7 @@ interface UiCliRuntime {
|
||||
openHachidoriSettings: () => void;
|
||||
openConfigSettingsWindow: () => void;
|
||||
openSyncUiWindow: () => void;
|
||||
openYoutubeBrowserWindow: () => void;
|
||||
cycleSecondarySubMode: () => void;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
printHelp: () => void;
|
||||
@@ -284,6 +282,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,
|
||||
@@ -438,6 +437,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) {
|
||||
@@ -648,7 +649,6 @@ export function handleCliCommand(
|
||||
() =>
|
||||
deps.runYoutubePlaybackFlow({
|
||||
url: youtubeUrl,
|
||||
mode: args.youtubeMode ?? 'download',
|
||||
source,
|
||||
}),
|
||||
deps,
|
||||
|
||||
@@ -18,6 +18,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
hachidori: 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}`,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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/);
|
||||
});
|
||||
@@ -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
@@ -338,6 +338,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 {
|
||||
@@ -494,6 +495,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';
|
||||
@@ -675,6 +678,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 = [
|
||||
@@ -1054,6 +1059,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),
|
||||
@@ -1193,6 +1205,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 () => {
|
||||
@@ -1420,7 +1451,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, {
|
||||
@@ -3234,6 +3266,7 @@ const {
|
||||
runJellyfinCommand,
|
||||
openJellyfinSetupWindow,
|
||||
getJellyfinClientInfo,
|
||||
ensureMpvConnectedForPlayback,
|
||||
} = composeJellyfinRuntimeHandlers({
|
||||
getResolvedJellyfinConfigMainDeps: {
|
||||
getResolvedConfig: () => configService.getConfig(),
|
||||
@@ -3511,6 +3544,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,
|
||||
});
|
||||
@@ -4172,7 +4251,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(),
|
||||
@@ -4775,6 +4859,9 @@ const {
|
||||
}
|
||||
youtubePrimarySubtitleNotificationRuntime.handleMediaPathChange(path);
|
||||
void youtubeMediaCachePlaybackRuntime.handleMediaPathChange(path);
|
||||
youtubeBrowserPlaybackRuntime.handleMediaPathChange(path);
|
||||
youtubeFlowRuntime.handleMediaPathChange(path);
|
||||
subtitleGenerationRuntime.handleMediaPathChange(path);
|
||||
if (path) {
|
||||
ensureImmersionTrackerStarted();
|
||||
secondarySubtitleTrackController.scheduleRefresh();
|
||||
@@ -5125,6 +5212,13 @@ function createMpvClientRuntimeService(): MpvIpcClient {
|
||||
if (connected) {
|
||||
return;
|
||||
}
|
||||
youtubeBrowserPlaybackRuntime.handleMpvDisconnected();
|
||||
youtubeFlowRuntime.cancelWhisperGeneration();
|
||||
subtitleGenerationRuntime.handleMediaPathChange(null);
|
||||
if (quitWhenMpvClosesAfterYoutubeBrowser && !youtubeBrowserWindowRuntime.isOpen()) {
|
||||
requestAppQuit();
|
||||
return;
|
||||
}
|
||||
if (!youtubeFlowRuntime.hasActiveSession()) {
|
||||
return;
|
||||
}
|
||||
@@ -6339,6 +6433,7 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({
|
||||
openYomitanSettings: () => openYomitanSettings(),
|
||||
openConfigSettingsWindow: () => configSettingsRuntime.openWindow(),
|
||||
openSyncUiWindow: () => openSyncUiWindowHandler(),
|
||||
openYoutubeBrowserWindow: () => youtubeBrowserWindowRuntime.open(),
|
||||
cycleSecondarySubMode: () => cycleSecondarySubMode(),
|
||||
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
|
||||
printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT),
|
||||
@@ -6605,6 +6700,7 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } =
|
||||
openYomitanSettings: () => openYomitanSettings(),
|
||||
openConfigSettingsWindow: () => configSettingsRuntime.openWindow(),
|
||||
openSyncUiWindow: () => openSyncUiWindowHandler(),
|
||||
openYoutubeBrowserWindow: () => youtubeBrowserWindowRuntime.open(),
|
||||
exportLogs: () => {
|
||||
void exportLogsFromTray();
|
||||
},
|
||||
@@ -6889,8 +6985,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()]) {
|
||||
|
||||
@@ -52,6 +52,7 @@ export interface CliCommandRuntimeServiceContext {
|
||||
openHachidoriSettings: () => void;
|
||||
openConfigSettingsWindow: () => void;
|
||||
openSyncUiWindow: () => void;
|
||||
openYoutubeBrowserWindow: () => void;
|
||||
cycleSecondarySubMode: () => void;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
printHelp: () => void;
|
||||
@@ -139,6 +140,7 @@ function createCliCommandDepsFromContext(
|
||||
openHachidoriSettings: context.openHachidoriSettings,
|
||||
openConfigSettingsWindow: context.openConfigSettingsWindow,
|
||||
openSyncUiWindow: context.openSyncUiWindow,
|
||||
openYoutubeBrowserWindow: context.openYoutubeBrowserWindow,
|
||||
cycleSecondarySubMode: context.cycleSecondarySubMode,
|
||||
openRuntimeOptionsPalette: context.openRuntimeOptionsPalette,
|
||||
printHelp: context.printHelp,
|
||||
|
||||
@@ -220,6 +220,7 @@ export interface CliCommandRuntimeServiceDepsParams {
|
||||
openHachidoriSettings: CliCommandDepsRuntimeOptions['ui']['openHachidoriSettings'];
|
||||
openConfigSettingsWindow: CliCommandDepsRuntimeOptions['ui']['openConfigSettingsWindow'];
|
||||
openSyncUiWindow: CliCommandDepsRuntimeOptions['ui']['openSyncUiWindow'];
|
||||
openYoutubeBrowserWindow: CliCommandDepsRuntimeOptions['ui']['openYoutubeBrowserWindow'];
|
||||
cycleSecondarySubMode: CliCommandDepsRuntimeOptions['ui']['cycleSecondarySubMode'];
|
||||
openRuntimeOptionsPalette: CliCommandDepsRuntimeOptions['ui']['openRuntimeOptionsPalette'];
|
||||
printHelp: CliCommandDepsRuntimeOptions['ui']['printHelp'];
|
||||
@@ -434,6 +435,7 @@ export function createCliCommandRuntimeServiceDeps(
|
||||
openHachidoriSettings: params.ui.openHachidoriSettings,
|
||||
openConfigSettingsWindow: params.ui.openConfigSettingsWindow,
|
||||
openSyncUiWindow: params.ui.openSyncUiWindow,
|
||||
openYoutubeBrowserWindow: params.ui.openYoutubeBrowserWindow,
|
||||
cycleSecondarySubMode: params.ui.cycleSecondarySubMode,
|
||||
openRuntimeOptionsPalette: params.ui.openRuntimeOptionsPalette,
|
||||
printHelp: params.ui.printHelp,
|
||||
|
||||
@@ -76,6 +76,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'),
|
||||
|
||||
@@ -50,6 +50,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
|
||||
openHachidoriSettings: () => void;
|
||||
openConfigSettingsWindow: () => void;
|
||||
openSyncUiWindow: () => void;
|
||||
openYoutubeBrowserWindow: () => void;
|
||||
cycleSecondarySubMode: () => void;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
printHelp: () => void;
|
||||
@@ -111,6 +112,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
|
||||
openHachidoriSettings: deps.openHachidoriSettings,
|
||||
openConfigSettingsWindow: deps.openConfigSettingsWindow,
|
||||
openSyncUiWindow: deps.openSyncUiWindow,
|
||||
openYoutubeBrowserWindow: deps.openYoutubeBrowserWindow,
|
||||
cycleSecondarySubMode: deps.cycleSecondarySubMode,
|
||||
openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette,
|
||||
printHelp: deps.printHelp,
|
||||
|
||||
@@ -78,6 +78,7 @@ test('cli command context factory composes main deps and context handlers', () =
|
||||
openYomitanSettings: () => {},
|
||||
openConfigSettingsWindow: () => {},
|
||||
openSyncUiWindow: () => {},
|
||||
openYoutubeBrowserWindow: () => {},
|
||||
cycleSecondarySubMode: () => {},
|
||||
openRuntimeOptionsPalette: () => {},
|
||||
printHelp: () => {},
|
||||
|
||||
@@ -107,6 +107,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'),
|
||||
|
||||
@@ -67,6 +67,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
|
||||
openHachidoriSettings: () => void;
|
||||
openConfigSettingsWindow: () => void;
|
||||
openSyncUiWindow: () => void;
|
||||
openYoutubeBrowserWindow: () => void;
|
||||
cycleSecondarySubMode: () => void;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
printHelp: () => void;
|
||||
@@ -148,6 +149,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
|
||||
openHachidoriSettings: () => deps.openHachidoriSettings(),
|
||||
openConfigSettingsWindow: () => deps.openConfigSettingsWindow(),
|
||||
openSyncUiWindow: () => deps.openSyncUiWindow(),
|
||||
openYoutubeBrowserWindow: () => deps.openYoutubeBrowserWindow(),
|
||||
cycleSecondarySubMode: () => deps.cycleSecondarySubMode(),
|
||||
openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(),
|
||||
printHelp: () => deps.printHelp(),
|
||||
|
||||
@@ -60,6 +60,7 @@ function createDeps() {
|
||||
openYomitanSettings: () => {},
|
||||
openConfigSettingsWindow: () => {},
|
||||
openSyncUiWindow: () => {},
|
||||
openYoutubeBrowserWindow: () => {},
|
||||
cycleSecondarySubMode: () => {},
|
||||
openRuntimeOptionsPalette: () => {},
|
||||
printHelp: () => {},
|
||||
|
||||
@@ -55,6 +55,7 @@ export type CliCommandContextFactoryDeps = {
|
||||
openHachidoriSettings: () => void;
|
||||
openConfigSettingsWindow: () => void;
|
||||
openSyncUiWindow: () => void;
|
||||
openYoutubeBrowserWindow: () => void;
|
||||
cycleSecondarySubMode: () => void;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
printHelp: () => void;
|
||||
@@ -138,6 +139,7 @@ export function createCliCommandContext(
|
||||
openHachidoriSettings: deps.openHachidoriSettings,
|
||||
openConfigSettingsWindow: deps.openConfigSettingsWindow,
|
||||
openSyncUiWindow: deps.openSyncUiWindow,
|
||||
openYoutubeBrowserWindow: deps.openYoutubeBrowserWindow,
|
||||
cycleSecondarySubMode: deps.cycleSecondarySubMode,
|
||||
openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette,
|
||||
printHelp: deps.printHelp,
|
||||
|
||||
@@ -54,6 +54,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,
|
||||
|
||||
@@ -38,6 +38,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
hachidori: 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,
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
@@ -76,6 +77,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,
|
||||
@@ -102,6 +104,7 @@ test('build tray template handler wires actions and init guards', () => {
|
||||
'yomitan',
|
||||
'configuration',
|
||||
'sync-ui',
|
||||
'youtube',
|
||||
'export-logs',
|
||||
'jellyfin',
|
||||
'jellyfin-discovery:true',
|
||||
@@ -134,6 +137,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,
|
||||
|
||||
@@ -53,6 +53,7 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
|
||||
openYomitanSettings: () => void;
|
||||
openConfigSettings: () => void;
|
||||
openSyncUi: () => void;
|
||||
openYoutubeBrowser: () => void;
|
||||
exportLogs: () => void;
|
||||
openJellyfinSetup: () => void;
|
||||
showJellyfinDiscovery: boolean;
|
||||
@@ -76,6 +77,7 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
|
||||
openYomitanSettings: () => void;
|
||||
openConfigSettingsWindow: () => void;
|
||||
openSyncUiWindow: () => void;
|
||||
openYoutubeBrowserWindow: () => void;
|
||||
exportLogs: () => void;
|
||||
openJellyfinSetupWindow: () => void;
|
||||
isJellyfinConfigured: () => boolean;
|
||||
@@ -124,6 +126,9 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
|
||||
openSyncUi: () => {
|
||||
deps.openSyncUiWindow();
|
||||
},
|
||||
openYoutubeBrowser: () => {
|
||||
deps.openYoutubeBrowserWindow();
|
||||
},
|
||||
exportLogs: () => {
|
||||
deps.exportLogs();
|
||||
},
|
||||
|
||||
@@ -36,6 +36,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,
|
||||
@@ -65,6 +66,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,
|
||||
|
||||
@@ -43,6 +43,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
|
||||
openYomitanSettings: () => void;
|
||||
openConfigSettings: () => void;
|
||||
openSyncUi: () => void;
|
||||
openYoutubeBrowser: () => void;
|
||||
exportLogs: () => void;
|
||||
openJellyfinSetup: () => void;
|
||||
showJellyfinDiscovery: boolean;
|
||||
@@ -66,6 +67,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
|
||||
openYomitanSettings: () => void;
|
||||
openConfigSettingsWindow: () => void;
|
||||
openSyncUiWindow: () => void;
|
||||
openYoutubeBrowserWindow: () => void;
|
||||
exportLogs: () => void;
|
||||
openJellyfinSetupWindow: () => void;
|
||||
isJellyfinConfigured: () => boolean;
|
||||
@@ -93,6 +95,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,
|
||||
|
||||
@@ -36,6 +36,7 @@ test('tray runtime handlers compose resolve/menu/ensure/destroy handlers', () =>
|
||||
openYomitanSettings: () => {},
|
||||
openConfigSettingsWindow: () => {},
|
||||
openSyncUiWindow: () => {},
|
||||
openYoutubeBrowserWindow: () => {},
|
||||
exportLogs: () => {},
|
||||
openJellyfinSetupWindow: () => {},
|
||||
isJellyfinConfigured: () => false,
|
||||
|
||||
@@ -45,6 +45,7 @@ for (const dictionaryBackend of ['yomitan', 'hachidori'] as const) {
|
||||
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,
|
||||
@@ -74,6 +75,7 @@ for (const dictionaryBackend of ['yomitan', 'hachidori'] as const) {
|
||||
settingsLabel,
|
||||
'Open SubMiner Settings',
|
||||
'Sync Stats && History',
|
||||
'Browse YouTube',
|
||||
'Export Logs',
|
||||
'Configure Jellyfin',
|
||||
'Jellyfin Discovery',
|
||||
@@ -98,6 +100,7 @@ for (const dictionaryBackend of ['yomitan', 'hachidori'] as const) {
|
||||
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');
|
||||
@@ -109,6 +112,7 @@ for (const dictionaryBackend of ['yomitan', 'hachidori'] as const) {
|
||||
'changelog',
|
||||
'texthooker',
|
||||
'sync-ui',
|
||||
'youtube',
|
||||
'export-logs',
|
||||
'updates',
|
||||
'separator',
|
||||
@@ -132,6 +136,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,
|
||||
@@ -164,6 +169,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,
|
||||
@@ -194,6 +200,7 @@ test('tray menu template renders active jellyfin discovery checkbox', () => {
|
||||
openYomitanSettings: () => undefined,
|
||||
openConfigSettings: () => undefined,
|
||||
openSyncUi: () => undefined,
|
||||
openYoutubeBrowser: () => undefined,
|
||||
exportLogs: () => undefined,
|
||||
openJellyfinSetup: () => undefined,
|
||||
showJellyfinDiscovery: true,
|
||||
@@ -225,6 +232,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,
|
||||
|
||||
@@ -46,6 +46,7 @@ export type TrayMenuActionHandlers = {
|
||||
openYomitanSettings: () => void;
|
||||
openConfigSettings: () => void;
|
||||
openSyncUi: () => void;
|
||||
openYoutubeBrowser: () => void;
|
||||
exportLogs: () => void;
|
||||
openJellyfinSetup: () => void;
|
||||
showJellyfinDiscovery: boolean;
|
||||
@@ -123,6 +124,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',
|
||||
);
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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'),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -393,12 +393,6 @@
|
||||
<span class="media-timing-review-original-boundary is-start"></span>
|
||||
<span class="media-timing-review-original-boundary is-end"></span>
|
||||
</div>
|
||||
<span class="media-timing-review-original-label is-start" aria-hidden="true">
|
||||
Line start
|
||||
</span>
|
||||
<span class="media-timing-review-original-label is-end" aria-hidden="true">
|
||||
Line end
|
||||
</span>
|
||||
<div
|
||||
id="mediaTimingReviewSelectedRange"
|
||||
class="media-timing-review-selected-range"
|
||||
|
||||
@@ -449,8 +449,8 @@ export function createMediaTimingReviewModal(
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves an untouched clip end back to where the line's dialogue ends. The Line end
|
||||
* rail keeps marking the subtitle timing, and Reset restores it.
|
||||
* Moves an untouched clip end back to where the line's dialogue ends. The orange
|
||||
* end bar keeps marking the subtitle timing, and Reset restores it.
|
||||
*/
|
||||
function trimTrailingSilence(peaks: readonly number[]): void {
|
||||
if (!payload || !trailingTrimPending || previewPlaying || previewRequest.isInFlight()) {
|
||||
|
||||
@@ -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));
|
||||
|
||||
+20
-47
@@ -1763,34 +1763,6 @@ body:focus-visible,
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.media-timing-review-original-label {
|
||||
position: absolute;
|
||||
z-index: 7;
|
||||
padding: 3px 5px;
|
||||
border: 1px solid color-mix(in srgb, var(--ctp-crust) 35%, transparent);
|
||||
border-radius: 4px;
|
||||
background: var(--ctp-peach);
|
||||
box-shadow: 0 2px 6px color-mix(in srgb, var(--ctp-crust) 55%, transparent);
|
||||
color: var(--ctp-crust);
|
||||
font-size: 8px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.06em;
|
||||
line-height: 1;
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.media-timing-review-original-label.is-start {
|
||||
top: 5px;
|
||||
left: calc(var(--original-start) + 7px);
|
||||
}
|
||||
|
||||
.media-timing-review-original-label.is-end {
|
||||
right: calc(100% - var(--original-end) + 7px);
|
||||
bottom: 5px;
|
||||
}
|
||||
|
||||
.media-timing-review-track.is-loading::after {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
@@ -1856,61 +1828,62 @@ body:focus-visible,
|
||||
}
|
||||
}
|
||||
|
||||
/* A thin line centered on the clip edge, so the line itself is the trim boundary. */
|
||||
.media-timing-review-handle {
|
||||
position: absolute;
|
||||
z-index: 6;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 14px;
|
||||
border: 1px solid var(--ctp-teal);
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
var(--ctp-teal),
|
||||
color-mix(in srgb, var(--ctp-teal) 74%, var(--ctp-crust))
|
||||
);
|
||||
width: 3px;
|
||||
background: var(--ctp-teal);
|
||||
cursor: ew-resize;
|
||||
touch-action: none;
|
||||
transform: translateX(-50%);
|
||||
transition: box-shadow 130ms ease;
|
||||
}
|
||||
|
||||
/* Widens the grab area past the visible bracket without moving the clip edge. */
|
||||
/* Widens the grab area past the visible line without moving the clip edge. */
|
||||
.media-timing-review-handle::before {
|
||||
position: absolute;
|
||||
inset: 0 -7px;
|
||||
inset: 0 -8px;
|
||||
content: '';
|
||||
}
|
||||
|
||||
/* Grip knob, symmetric about the line so it never reads as a separate edge. */
|
||||
.media-timing-review-handle::after {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 6px;
|
||||
height: 18px;
|
||||
width: 7px;
|
||||
height: 16px;
|
||||
content: '';
|
||||
border-inline: 1px solid color-mix(in srgb, var(--ctp-crust) 55%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--ctp-crust) 55%, transparent);
|
||||
border-radius: 4px;
|
||||
background: var(--ctp-teal);
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.media-timing-review-handle-start {
|
||||
left: var(--selection-start);
|
||||
border-radius: 5px 0 0 5px;
|
||||
}
|
||||
|
||||
.media-timing-review-handle-end {
|
||||
left: var(--selection-end);
|
||||
border-radius: 0 5px 5px 0;
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
.media-timing-review-handle:hover {
|
||||
box-shadow: 0 0 16px color-mix(in srgb, var(--ctp-teal) 60%, transparent);
|
||||
box-shadow: 0 0 10px color-mix(in srgb, var(--ctp-teal) 70%, transparent);
|
||||
}
|
||||
|
||||
.media-timing-review-handle:focus-visible {
|
||||
outline: 2px solid transparent;
|
||||
box-shadow:
|
||||
inset 0 0 0 2px var(--ctp-yellow),
|
||||
0 0 16px color-mix(in srgb, var(--ctp-yellow) 55%, transparent);
|
||||
background: var(--ctp-yellow);
|
||||
box-shadow: 0 0 12px color-mix(in srgb, var(--ctp-yellow) 60%, transparent);
|
||||
}
|
||||
|
||||
.media-timing-review-handle:focus-visible::after {
|
||||
background: var(--ctp-yellow);
|
||||
}
|
||||
|
||||
.media-timing-review-expand-row span {
|
||||
|
||||
@@ -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
@@ -20,7 +20,7 @@ import type {
|
||||
YomitanConfig,
|
||||
YoutubeConfig,
|
||||
YoutubeMediaCacheMode,
|
||||
YoutubeSubgenConfig,
|
||||
YoutubeSubtitleSource,
|
||||
} from './integrations';
|
||||
import type {
|
||||
ControllerButtonIndicesConfig,
|
||||
@@ -174,7 +174,6 @@ export interface Config {
|
||||
discordPresence?: DiscordPresenceConfig;
|
||||
ai?: AiConfig;
|
||||
youtube?: YoutubeConfig;
|
||||
youtubeSubgen?: YoutubeSubgenConfig;
|
||||
immersionTracking?: ImmersionTrackingConfig;
|
||||
stats?: StatsConfig;
|
||||
updates?: UpdatesConfig;
|
||||
@@ -394,19 +393,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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user