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

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

No files matched your search

+24 -16
View File
@@ -1757,11 +1757,6 @@ test('parses global shortcuts and startup settings', () => {
},
"youtube": {
"primarySubLanguages": ["ja", "jpn", "jp"]
},
"youtubeSubgen": {
"whisperVadModel": "/models/vad.bin",
"whisperThreads": 12,
"fixWithAi": true
}
}`,
'utf-8',
@@ -1774,9 +1769,30 @@ test('parses global shortcuts and startup settings', () => {
assert.equal(config.shortcuts.toggleVisibleOverlayGlobal, 'Alt+Shift+U');
assert.equal(config.shortcuts.openJimaku, 'Ctrl+Alt+J');
assert.deepEqual(config.youtube.primarySubLanguages, ['ja', 'jpn', 'jp']);
assert.equal(config.youtubeSubgen.whisperVadModel, '/models/vad.bin');
assert.equal(config.youtubeSubgen.whisperThreads, 12);
assert.equal(config.youtubeSubgen.fixWithAi, true);
});
test('parses YouTube subtitle source and warns on invalid values', () => {
assert.equal(DEFAULT_CONFIG.youtube.subtitleSource, 'youtube');
const validDir = makeTempDir();
fs.writeFileSync(
path.join(validDir, 'config.jsonc'),
`{ "youtube": { "subtitleSource": "whisper" } }`,
'utf-8',
);
assert.equal(new ConfigService(validDir).getConfig().youtube.subtitleSource, 'whisper');
const invalidDir = makeTempDir();
fs.writeFileSync(
path.join(invalidDir, 'config.jsonc'),
`{ "youtube": { "subtitleSource": "asr" } }`,
'utf-8',
);
const invalidService = new ConfigService(invalidDir);
assert.equal(invalidService.getConfig().youtube.subtitleSource, 'youtube');
assert.ok(
invalidService.getWarnings().some((warning) => warning.path === 'youtube.subtitleSource'),
);
});
test('parses YouTube media cache config and warns on invalid values', () => {
@@ -2577,12 +2593,6 @@ test('accepts per-feature ai overrides for anki and YouTube subtitles', () => {
"model": "openrouter/anki-model",
"systemPrompt": "Translate mined sentence text."
}
},
"youtubeSubgen": {
"ai": {
"model": "openrouter/subgen-model",
"systemPrompt": "Fix subtitle mistakes only."
}
}
}`,
'utf-8',
@@ -2596,8 +2606,6 @@ test('accepts per-feature ai overrides for anki and YouTube subtitles', () => {
assert.equal(config.ankiConnect.ai.enabled, true);
assert.equal(config.ankiConnect.ai.model, 'openrouter/anki-model');
assert.equal(config.ankiConnect.ai.systemPrompt, 'Translate mined sentence text.');
assert.equal(config.youtubeSubgen.ai.model, 'openrouter/subgen-model');
assert.equal(config.youtubeSubgen.ai.systemPrompt, 'Fix subtitle mistakes only.');
});
test('warns and falls back when ankiConnect.ai override values are invalid', () => {
-2
View File
@@ -49,7 +49,6 @@ const {
jellyfin,
discordPresence,
ai,
youtubeSubgen,
} = INTEGRATIONS_DEFAULT_CONFIG;
const { subtitleStyle, subtitleSidebar } = SUBTITLE_DEFAULT_CONFIG;
const { immersionTracking } = IMMERSION_DEFAULT_CONFIG;
@@ -85,7 +84,6 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
jellyfin,
discordPresence,
ai,
youtubeSubgen,
immersionTracking,
stats,
};
+1
View File
@@ -115,6 +115,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
},
youtube: {
primarySubLanguages: ['ja', 'jpn'],
subtitleSource: 'youtube',
mediaCache: {
mode: 'direct',
maxHeight: 720,
@@ -13,7 +13,6 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
| 'jellyfin'
| 'discordPresence'
| 'ai'
| 'youtubeSubgen'
> = {
ankiConnect: {
enabled: true,
@@ -179,15 +178,4 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
'You are a translation engine. Return only the translated text with no explanations.',
requestTimeoutMs: 15_000,
},
youtubeSubgen: {
whisperBin: '',
whisperModel: '',
whisperVadModel: '',
whisperThreads: 4,
fixWithAi: false,
ai: {
model: '',
systemPrompt: '',
},
},
};
@@ -85,13 +85,6 @@ const UNDOCUMENTED_LEAVES: ReadonlySet<string> = new Set([
'subtitleStyle.textShadow',
'subtitleStyle.WebkitTextStroke',
'subtitleStyle.wordSpacing',
'youtubeSubgen.ai.model',
'youtubeSubgen.ai.systemPrompt',
'youtubeSubgen.fixWithAi',
'youtubeSubgen.whisperBin',
'youtubeSubgen.whisperModel',
'youtubeSubgen.whisperThreads',
'youtubeSubgen.whisperVadModel',
]);
test('config option registry includes critical paths and has unique entries', () => {
+12
View File
@@ -119,6 +119,18 @@ export function buildCoreConfigOptionRegistry(
description:
'Comma-separated primary subtitle language priority for managed subtitle auto-selection.',
},
{
path: 'youtube.subtitleSource',
kind: 'enum',
enumValues: ['youtube', 'whisper'],
enumLabels: {
youtube: 'Download YouTube captions',
whisper: 'Generate with Whisper',
},
defaultValue: defaultConfig.youtube.subtitleSource,
description:
'Where primary YouTube subtitles come from. Whisper transcribes the audio locally using the subtitleGeneration settings.',
},
{
path: 'youtube.mediaCache.mode',
kind: 'enum',
+3 -1
View File
@@ -183,7 +183,9 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
description: [
'Defaults for managed subtitle language preferences and YouTube subtitle loading.',
],
notes: ['Hot-reload: primarySubLanguages applies to the next YouTube subtitle load.'],
notes: [
'Hot-reload: primarySubLanguages and subtitleSource apply to the next YouTube subtitle load.',
],
key: 'youtube',
},
{
+1
View File
@@ -21,6 +21,7 @@ const HOT_RELOAD_EXACT_OR_PREFIX_PATHS = [
'logging.rotation',
'logging.files',
'youtube.primarySubLanguages',
'youtube.subtitleSource',
'ankiConnect.deck',
'ankiConnect.media.normalizeAudio',
'ankiConnect.media.mirrorMpvVolume',
+12
View File
@@ -339,6 +339,18 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
);
}
const subtitleSource = src.youtube.subtitleSource;
if (subtitleSource === 'youtube' || subtitleSource === 'whisper') {
resolved.youtube.subtitleSource = subtitleSource;
} else if (subtitleSource !== undefined) {
warn(
'youtube.subtitleSource',
subtitleSource,
resolved.youtube.subtitleSource,
"Expected 'youtube' or 'whisper'.",
);
}
if (isObject(src.youtube.mediaCache)) {
const mode = src.youtube.mediaCache.mode;
if (mode === 'direct' || mode === 'background') {
-99
View File
@@ -118,105 +118,6 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
}
}
if (isObject(src.youtubeSubgen)) {
const whisperBin = asString(src.youtubeSubgen.whisperBin);
if (whisperBin !== undefined) {
resolved.youtubeSubgen.whisperBin = whisperBin;
} else if (src.youtubeSubgen.whisperBin !== undefined) {
warn(
'youtubeSubgen.whisperBin',
src.youtubeSubgen.whisperBin,
resolved.youtubeSubgen.whisperBin,
'Expected string.',
);
}
const whisperModel = asString(src.youtubeSubgen.whisperModel);
if (whisperModel !== undefined) {
resolved.youtubeSubgen.whisperModel = whisperModel;
} else if (src.youtubeSubgen.whisperModel !== undefined) {
warn(
'youtubeSubgen.whisperModel',
src.youtubeSubgen.whisperModel,
resolved.youtubeSubgen.whisperModel,
'Expected string.',
);
}
const whisperVadModel = asString(src.youtubeSubgen.whisperVadModel);
if (whisperVadModel !== undefined) {
resolved.youtubeSubgen.whisperVadModel = whisperVadModel;
} else if (src.youtubeSubgen.whisperVadModel !== undefined) {
warn(
'youtubeSubgen.whisperVadModel',
src.youtubeSubgen.whisperVadModel,
resolved.youtubeSubgen.whisperVadModel,
'Expected string.',
);
}
const whisperThreads = asNumber(src.youtubeSubgen.whisperThreads);
if (whisperThreads !== undefined && Number.isInteger(whisperThreads) && whisperThreads > 0) {
resolved.youtubeSubgen.whisperThreads = whisperThreads;
} else if (src.youtubeSubgen.whisperThreads !== undefined) {
warn(
'youtubeSubgen.whisperThreads',
src.youtubeSubgen.whisperThreads,
resolved.youtubeSubgen.whisperThreads,
'Expected positive integer.',
);
}
const fixWithAi = asBoolean(src.youtubeSubgen.fixWithAi);
if (fixWithAi !== undefined) {
resolved.youtubeSubgen.fixWithAi = fixWithAi;
} else if (src.youtubeSubgen.fixWithAi !== undefined) {
warn(
'youtubeSubgen.fixWithAi',
src.youtubeSubgen.fixWithAi,
resolved.youtubeSubgen.fixWithAi,
'Expected boolean.',
);
}
if (isObject(src.youtubeSubgen.ai)) {
const aiModel = asString(src.youtubeSubgen.ai.model);
if (aiModel !== undefined) {
resolved.youtubeSubgen.ai.model = aiModel;
} else if (src.youtubeSubgen.ai.model !== undefined) {
warn(
'youtubeSubgen.ai.model',
src.youtubeSubgen.ai.model,
resolved.youtubeSubgen.ai.model,
'Expected string.',
);
}
const aiSystemPrompt = asString(src.youtubeSubgen.ai.systemPrompt);
if (aiSystemPrompt !== undefined) {
resolved.youtubeSubgen.ai.systemPrompt = aiSystemPrompt;
} else if (src.youtubeSubgen.ai.systemPrompt !== undefined) {
warn(
'youtubeSubgen.ai.systemPrompt',
src.youtubeSubgen.ai.systemPrompt,
resolved.youtubeSubgen.ai.systemPrompt,
'Expected string.',
);
}
} else if (src.youtubeSubgen.ai !== undefined) {
warn('youtubeSubgen.ai', src.youtubeSubgen.ai, resolved.youtubeSubgen.ai, 'Expected object.');
}
if (src.youtubeSubgen.primarySubLanguages !== undefined) {
warn(
'youtubeSubgen.primarySubLanguages',
src.youtubeSubgen.primarySubLanguages,
undefined,
'Removed. Use youtube.primarySubLanguages instead.',
);
}
}
if (isObject(src.subtitleStyle)) {
const fallbackSubtitleStyleEnableJlpt = resolved.subtitleStyle.enableJlpt;
const fallbackSubtitleStylePrimaryDefaultMode = resolved.subtitleStyle.primaryDefaultMode;
-1
View File
@@ -328,7 +328,6 @@ test('settings registry hides app-managed and inactive config surfaces', () => {
'controller.preferredGamepadId',
'controller.preferredGamepadLabel',
'controller.profiles',
'youtubeSubgen.whisperBin',
'jellyfin.defaultLibraryId',
'subtitleSidebar.toggleKey',
'jellyfin.recentServers',
+2 -11
View File
@@ -61,7 +61,6 @@ export const LEGACY_HIDDEN_CONFIG_PATHS = [
'controller.preferredGamepadId',
'controller.preferredGamepadLabel',
'controller.profiles',
'youtubeSubgen.primarySubLanguages',
'anilist.characterDictionary.enabled',
'anilist.characterDictionary.refreshTtlHours',
'anilist.characterDictionary.evictionPolicy',
@@ -76,12 +75,7 @@ export const LEGACY_HIDDEN_CONFIG_PATHS = [
'jellyfin.recentServers',
] as const;
const EXCLUDED_PREFIXES = [
'ai',
'ankiConnect.ai',
'controller.buttonIndices',
'youtubeSubgen',
] as const;
const EXCLUDED_PREFIXES = ['ai', 'ankiConnect.ai', 'controller.buttonIndices'] as const;
const JSON_OBJECT_FIELDS = new Set([
'keybindings',
@@ -525,7 +519,6 @@ function topSection(path: string): string {
websocket: 'WebSocket server',
yomitan: 'Yomitan',
youtube: 'YouTube Playback Settings',
youtubeSubgen: 'YouTube subtitle generation',
auto_start_overlay: 'Playback Behavior',
};
return labels[top] ?? humanizePath(top);
@@ -725,9 +718,7 @@ function fieldForLeaf(leaf: Leaf): ConfigSettingsField {
...(option?.enumLabels ? { enumLabels: option.enumLabels } : {}),
restartBehavior: getConfigHotReloadField(leaf.path) ? 'hot-reload' : 'restart',
advanced:
leaf.path.startsWith('controller.') ||
leaf.path.startsWith('immersionTracking.retention.') ||
leaf.path.startsWith('youtubeSubgen.'),
leaf.path.startsWith('controller.') || leaf.path.startsWith('immersionTracking.retention.'),
secret: SECRET_PATHS.has(leaf.path),
settingsHidden: SUBTITLE_CSS_MANAGED_CONFIG_PATHS.has(leaf.path),
};
-7
View File
@@ -21,13 +21,6 @@ const SUBTITLE_CSS_SCOPES: SubtitleCssScope[] = ['primary', 'secondary', 'sideba
const HIDDEN_TEMPLATE_PATHS = [
'anilist.characterDictionary.evictionPolicy',
'anilist.characterDictionary.refreshTtlHours',
'youtubeSubgen.ai.model',
'youtubeSubgen.ai.systemPrompt',
'youtubeSubgen.fixWithAi',
'youtubeSubgen.whisperBin',
'youtubeSubgen.whisperModel',
'youtubeSubgen.whisperThreads',
'youtubeSubgen.whisperVadModel',
];
function normalizeCommentText(value: string): string {