fix(launcher): remove youtube subtitle mode

This commit is contained in:
2026-03-08 16:03:24 -07:00
parent 6a44b54b51
commit a6ece5388a
19 changed files with 714 additions and 202 deletions

View File

@@ -34,6 +34,13 @@ test('loads defaults when config is missing', () => {
assert.equal(config.jellyfin.remoteControlAutoConnect, true);
assert.equal(config.jellyfin.autoAnnounce, false);
assert.equal(config.jellyfin.remoteControlDeviceName, 'SubMiner');
assert.equal(config.ai.enabled, false);
assert.equal(config.ai.apiKeyCommand, '');
assert.deepEqual(config.ankiConnect.ai, {
enabled: false,
model: '',
systemPrompt: '',
});
assert.equal(config.startupWarmups.lowPowerMode, false);
assert.equal(config.startupWarmups.mecab, true);
assert.equal(config.startupWarmups.yomitanExtension, true);
@@ -1068,12 +1075,20 @@ test('parses global shortcuts and startup settings', () => {
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"ai": {
"enabled": true,
"apiKeyCommand": "pass show subminer/ai",
"model": "openai/gpt-4o-mini"
},
"shortcuts": {
"toggleVisibleOverlayGlobal": "Alt+Shift+U",
"openJimaku": "Ctrl+Alt+J"
},
"youtubeSubgen": {
"primarySubLanguages": ["ja", "jpn", "jp"]
"primarySubLanguages": ["ja", "jpn", "jp"],
"whisperVadModel": "/models/vad.bin",
"whisperThreads": 12,
"fixWithAi": true
}
}`,
'utf-8',
@@ -1081,9 +1096,14 @@ test('parses global shortcuts and startup settings', () => {
const service = new ConfigService(dir);
const config = service.getConfig();
assert.equal(config.ai.enabled, true);
assert.equal(config.ai.apiKeyCommand, 'pass show subminer/ai');
assert.equal(config.shortcuts.toggleVisibleOverlayGlobal, 'Alt+Shift+U');
assert.equal(config.shortcuts.openJimaku, 'Ctrl+Alt+J');
assert.deepEqual(config.youtubeSubgen.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('runtime options registry is centralized', () => {
@@ -1324,14 +1344,86 @@ test('supports legacy ankiConnect.behavior N+1 settings as fallback', () => {
);
});
test('warns when ankiConnect.openRouter is used and migrates to ai', () => {
test('accepts top-level ai config', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"ai": {
"enabled": true,
"apiKey": "abc123",
"apiKeyCommand": "pass show subminer/ai",
"baseUrl": "https://openrouter.ai/api",
"model": "openrouter/test-model",
"systemPrompt": "Return only fixed subtitles.",
"requestTimeoutMs": 20000
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
const config = service.getConfig();
assert.equal(config.ai.enabled, true);
assert.equal(config.ai.apiKey, 'abc123');
assert.equal(config.ai.apiKeyCommand, 'pass show subminer/ai');
assert.equal(config.ai.baseUrl, 'https://openrouter.ai/api');
assert.equal(config.ai.model, 'openrouter/test-model');
assert.equal(config.ai.systemPrompt, 'Return only fixed subtitles.');
assert.equal(config.ai.requestTimeoutMs, 20000);
});
test('accepts per-feature ai overrides for anki and youtube subtitle generation', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"ai": {
"enabled": true,
"apiKeyCommand": "pass show subminer/ai",
"baseUrl": "https://openrouter.ai/api",
"model": "openrouter/shared-model",
"systemPrompt": "Legacy shared prompt."
},
"ankiConnect": {
"ai": {
"enabled": true,
"model": "openrouter/anki-model",
"systemPrompt": "Translate mined sentence text."
}
},
"youtubeSubgen": {
"ai": {
"model": "openrouter/subgen-model",
"systemPrompt": "Fix subtitle mistakes only."
}
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
const config = service.getConfig();
assert.equal(config.ai.enabled, true);
assert.equal(config.ai.model, 'openrouter/shared-model');
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', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"ankiConnect": {
"openRouter": {
"model": "openrouter/test-model"
"ai": {
"enabled": "yes",
"model": 123,
"systemPrompt": true
}
}
}`,
@@ -1342,13 +1434,10 @@ test('warns when ankiConnect.openRouter is used and migrates to ai', () => {
const config = service.getConfig();
const warnings = service.getWarnings();
assert.equal((config.ankiConnect.ai as Record<string, unknown>).model, 'openrouter/test-model');
assert.ok(
warnings.some(
(warning) =>
warning.path === 'ankiConnect.openRouter' && warning.message.includes('ankiConnect.ai'),
),
);
assert.deepEqual(config.ankiConnect.ai, DEFAULT_CONFIG.ankiConnect.ai);
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.ai.enabled'));
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.ai.model'));
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.ai.systemPrompt'));
});
test('falls back and warns when legacy ankiConnect migration values are invalid', () => {
@@ -1547,6 +1636,7 @@ test('falls back to default when ankiConnect n+1 deck list is invalid', () => {
test('template generator includes known keys', () => {
const output = generateConfigTemplate(DEFAULT_CONFIG);
assert.match(output, /"ai":/);
assert.match(output, /"ankiConnect":/);
assert.match(output, /"logging":/);
assert.match(output, /"websocket":/);
@@ -1577,6 +1667,31 @@ test('template generator includes known keys', () => {
output,
/"enabled": false,? \/\/ Enable AnkiConnect integration\. Values: true \| false/,
);
assert.match(
output,
/"enabled": false,? \/\/ Enable AI provider usage for Anki translation\/enrichment flows\. Values: true \| false/,
);
assert.match(
output,
/"model": "",? \/\/ Optional model override for Anki AI translation\/enrichment flows\./,
);
assert.match(
output,
/"enabled": false,? \/\/ Enable shared OpenAI-compatible AI provider features\. Values: true \| false/,
);
assert.match(
output,
/"fixWithAi": false,? \/\/ Use shared AI provider to post-process whisper-generated YouTube subtitles\. Values: true \| false/,
);
assert.match(
output,
/"systemPrompt": "",? \/\/ Optional system prompt override for YouTube subtitle AI post-processing\./,
);
assert.doesNotMatch(output, /"mode": "automatic"/);
assert.match(
output,
/"whisperThreads": 4,? \/\/ Thread count passed to whisper\.cpp subtitle generation runs\./,
);
assert.match(
output,
/"launchAtStartup": true,? \/\/ Launch texthooker server automatically when SubMiner starts\. Values: true \| false/,

View File

@@ -2,7 +2,7 @@ import { ResolvedConfig } from '../../types';
export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
ResolvedConfig,
'ankiConnect' | 'jimaku' | 'anilist' | 'jellyfin' | 'discordPresence' | 'youtubeSubgen'
'ankiConnect' | 'jimaku' | 'anilist' | 'jellyfin' | 'discordPresence' | 'ai' | 'youtubeSubgen'
> = {
ankiConnect: {
enabled: false,
@@ -24,13 +24,8 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
},
ai: {
enabled: false,
alwaysUseAiTranslation: false,
apiKey: '',
model: 'openai/gpt-4o-mini',
baseUrl: 'https://openrouter.ai/api',
targetLanguage: 'English',
systemPrompt:
'You are a translation engine. Return only the translated text with no explanations.',
model: '',
systemPrompt: '',
},
media: {
generateAudio: true,
@@ -122,10 +117,26 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
updateIntervalMs: 3_000,
debounceMs: 750,
},
ai: {
enabled: false,
apiKey: '',
apiKeyCommand: '',
model: 'openai/gpt-4o-mini',
baseUrl: 'https://openrouter.ai/api',
systemPrompt:
'You are a translation engine. Return only the translated text with no explanations.',
requestTimeoutMs: 15_000,
},
youtubeSubgen: {
mode: 'automatic',
whisperBin: '',
whisperModel: '',
whisperVadModel: '',
whisperThreads: 4,
fixWithAi: false,
ai: {
model: '',
systemPrompt: '',
},
primarySubLanguages: ['ja', 'jpn'],
},
};

View File

@@ -51,6 +51,24 @@ export function buildIntegrationConfigOptionRegistry(
description:
'Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.',
},
{
path: 'ankiConnect.ai.enabled',
kind: 'boolean',
defaultValue: defaultConfig.ankiConnect.ai.enabled,
description: 'Enable AI provider usage for Anki translation/enrichment flows.',
},
{
path: 'ankiConnect.ai.model',
kind: 'string',
defaultValue: defaultConfig.ankiConnect.ai.model,
description: 'Optional model override for Anki AI translation/enrichment flows.',
},
{
path: 'ankiConnect.ai.systemPrompt',
kind: 'string',
defaultValue: defaultConfig.ankiConnect.ai.systemPrompt,
description: 'Optional system prompt override for Anki AI translation/enrichment flows.',
},
{
path: 'ankiConnect.behavior.autoUpdateNewCards',
kind: 'boolean',
@@ -291,11 +309,34 @@ export function buildIntegrationConfigOptionRegistry(
description: 'Debounce delay used to collapse bursty presence updates.',
},
{
path: 'youtubeSubgen.mode',
kind: 'enum',
enumValues: ['automatic', 'preprocess', 'off'],
defaultValue: defaultConfig.youtubeSubgen.mode,
description: 'YouTube subtitle generation mode for the launcher script.',
path: 'ai.enabled',
kind: 'boolean',
defaultValue: defaultConfig.ai.enabled,
description: 'Enable shared OpenAI-compatible AI provider features.',
},
{
path: 'ai.apiKey',
kind: 'string',
defaultValue: defaultConfig.ai.apiKey,
description: 'Static API key for the shared OpenAI-compatible AI provider.',
},
{
path: 'ai.apiKeyCommand',
kind: 'string',
defaultValue: defaultConfig.ai.apiKeyCommand,
description: 'Shell command used to resolve the shared AI provider API key.',
},
{
path: 'ai.baseUrl',
kind: 'string',
defaultValue: defaultConfig.ai.baseUrl,
description: 'Base URL for the shared OpenAI-compatible AI provider.',
},
{
path: 'ai.requestTimeoutMs',
kind: 'number',
defaultValue: defaultConfig.ai.requestTimeoutMs,
description: 'Timeout in milliseconds for shared AI provider requests.',
},
{
path: 'youtubeSubgen.whisperBin',
@@ -309,6 +350,36 @@ export function buildIntegrationConfigOptionRegistry(
defaultValue: defaultConfig.youtubeSubgen.whisperModel,
description: 'Path to whisper model used for fallback transcription.',
},
{
path: 'youtubeSubgen.whisperVadModel',
kind: 'string',
defaultValue: defaultConfig.youtubeSubgen.whisperVadModel,
description: 'Path to optional whisper VAD model used for subtitle generation.',
},
{
path: 'youtubeSubgen.whisperThreads',
kind: 'number',
defaultValue: defaultConfig.youtubeSubgen.whisperThreads,
description: 'Thread count passed to whisper.cpp subtitle generation runs.',
},
{
path: 'youtubeSubgen.fixWithAi',
kind: 'boolean',
defaultValue: defaultConfig.youtubeSubgen.fixWithAi,
description: 'Use shared AI provider to post-process whisper-generated YouTube subtitles.',
},
{
path: 'youtubeSubgen.ai.model',
kind: 'string',
defaultValue: defaultConfig.youtubeSubgen.ai.model,
description: 'Optional model override for YouTube subtitle AI post-processing.',
},
{
path: 'youtubeSubgen.ai.systemPrompt',
kind: 'string',
defaultValue: defaultConfig.youtubeSubgen.ai.systemPrompt,
description: 'Optional system prompt override for YouTube subtitle AI post-processing.',
},
{
path: 'youtubeSubgen.primarySubLanguages',
kind: 'string',

View File

@@ -91,11 +91,19 @@ const SUBTITLE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
];
const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
{
title: 'Shared AI Provider',
description: [
'Canonical OpenAI-compatible provider transport settings shared by Anki and YouTube subtitle fixing.',
],
key: 'ai',
},
{
title: 'AnkiConnect Integration',
description: ['Automatic Anki updates and media generation options.'],
notes: [
'Hot-reload: AI translation settings update live while SubMiner is running.',
'Hot-reload: ankiConnect.ai.enabled updates live while SubMiner is running.',
'Shared AI provider transport settings are read from top-level ai and typically require restart.',
'Most other AnkiConnect settings still require restart.',
],
key: 'ankiConnect',
@@ -107,7 +115,7 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
},
{
title: 'YouTube Subtitle Generation',
description: ['Defaults for subminer YouTube subtitle extraction/transcription mode.'],
description: ['Defaults for SubMiner YouTube subtitle generation.'],
key: 'youtubeSubgen',
},
{

View File

@@ -46,18 +46,6 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
}
if (isObject(src.youtubeSubgen)) {
const mode = src.youtubeSubgen.mode;
if (mode === 'automatic' || mode === 'preprocess' || mode === 'off') {
resolved.youtubeSubgen.mode = mode;
} else if (mode !== undefined) {
warn(
'youtubeSubgen.mode',
mode,
resolved.youtubeSubgen.mode,
'Expected automatic, preprocess, or off.',
);
}
const whisperBin = asString(src.youtubeSubgen.whisperBin);
if (whisperBin !== undefined) {
resolved.youtubeSubgen.whisperBin = whisperBin;
@@ -82,6 +70,75 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
);
}
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 (Array.isArray(src.youtubeSubgen.primarySubLanguages)) {
resolved.youtubeSubgen.primarySubLanguages = src.youtubeSubgen.primarySubLanguages.filter(
(item): item is string => typeof item === 'string',

View File

@@ -108,3 +108,49 @@ test('initializeOverlayRuntime starts Anki integration when ankiConnect.enabled
assert.equal(startedIntegrations, 1);
assert.equal(setIntegrationCalls, 1);
});
test('initializeOverlayRuntime re-syncs overlay shortcuts when tracker focus changes', () => {
let syncCalls = 0;
const tracker = {
onGeometryChange: null as ((...args: unknown[]) => void) | null,
onWindowFound: null as ((...args: unknown[]) => void) | null,
onWindowLost: null as (() => void) | null,
onWindowFocusChange: null as ((focused: boolean) => void) | null,
start: () => {},
};
initializeOverlayRuntime({
backendOverride: null,
createMainWindow: () => {},
registerGlobalShortcuts: () => {},
updateVisibleOverlayBounds: () => {},
isVisibleOverlayVisible: () => false,
updateVisibleOverlayVisibility: () => {},
getOverlayWindows: () => [],
syncOverlayShortcuts: () => {
syncCalls += 1;
},
setWindowTracker: () => {},
getMpvSocketPath: () => '/tmp/mpv.sock',
createWindowTracker: () => tracker as never,
getResolvedConfig: () => ({
ankiConnect: { enabled: false } as never,
}),
getSubtitleTimingTracker: () => null,
getMpvClient: () => null,
getRuntimeOptionsManager: () => null,
setAnkiIntegration: () => {},
showDesktopNotification: () => {},
createFieldGroupingCallback: () => async () => ({
keepNoteId: 1,
deleteNoteId: 2,
deleteDuplicate: false,
cancelled: false,
}),
getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json',
});
assert.equal(typeof tracker.onWindowFocusChange, 'function');
tracker.onWindowFocusChange?.(true);
assert.equal(syncCalls, 1);
});

View File

@@ -101,6 +101,9 @@ export function initializeOverlayRuntime(options: {
}
options.syncOverlayShortcuts();
};
windowTracker.onWindowFocusChange = () => {
options.syncOverlayShortcuts();
};
windowTracker.start();
}

View File

@@ -969,6 +969,8 @@ const overlayShortcutsRuntime = createOverlayShortcutsRuntimeService(
appState.shortcutsRegistered = registered;
},
isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized,
isMacOSPlatform: () => process.platform === 'darwin',
isTrackedMpvWindowFocused: () => appState.windowTracker?.isFocused() ?? false,
showMpvOsd: (text: string) => showMpvOsd(text),
openRuntimeOptionsPalette: () => {
openRuntimeOptionsPalette();

View File

@@ -227,24 +227,7 @@ export interface AnkiConnectConfig {
miscInfo?: string;
translation?: string;
};
ai?: {
enabled?: boolean;
alwaysUseAiTranslation?: boolean;
apiKey?: string;
model?: string;
baseUrl?: string;
targetLanguage?: string;
systemPrompt?: string;
};
openRouter?: {
enabled?: boolean;
alwaysUseAiTranslation?: boolean;
apiKey?: string;
model?: string;
baseUrl?: string;
targetLanguage?: string;
systemPrompt?: string;
};
ai?: boolean | AiFeatureConfig;
media?: {
generateAudio?: boolean;
generateImage?: boolean;
@@ -455,12 +438,29 @@ export interface DiscordPresenceConfig {
debounceMs?: number;
}
export type YoutubeSubgenMode = 'automatic' | 'preprocess' | 'off';
export interface AiFeatureConfig {
enabled?: boolean;
model?: string;
systemPrompt?: string;
}
export interface AiConfig {
enabled?: boolean;
apiKey?: string;
apiKeyCommand?: string;
baseUrl?: string;
model?: string;
systemPrompt?: string;
requestTimeoutMs?: number;
}
export interface YoutubeSubgenConfig {
mode?: YoutubeSubgenMode;
whisperBin?: string;
whisperModel?: string;
whisperVadModel?: string;
whisperThreads?: number;
fixWithAi?: boolean;
ai?: AiFeatureConfig;
primarySubLanguages?: string[];
}
@@ -498,6 +498,7 @@ export interface Config {
anilist?: AnilistConfig;
jellyfin?: JellyfinConfig;
discordPresence?: DiscordPresenceConfig;
ai?: AiConfig;
youtubeSubgen?: YoutubeSubgenConfig;
immersionTracking?: ImmersionTrackingConfig;
logging?: {
@@ -531,14 +532,8 @@ export interface ResolvedConfig {
miscInfo: string;
translation: string;
};
ai: {
ai: AiFeatureConfig & {
enabled: boolean;
alwaysUseAiTranslation: boolean;
apiKey: string;
model: string;
baseUrl: string;
targetLanguage: string;
systemPrompt: string;
};
media: {
generateAudio: boolean;
@@ -649,10 +644,22 @@ export interface ResolvedConfig {
updateIntervalMs: number;
debounceMs: number;
};
ai: AiConfig & {
enabled: boolean;
apiKey: string;
apiKeyCommand: string;
baseUrl: string;
model: string;
systemPrompt: string;
requestTimeoutMs: number;
};
youtubeSubgen: YoutubeSubgenConfig & {
mode: YoutubeSubgenMode;
whisperBin: string;
whisperModel: string;
whisperVadModel: string;
whisperThreads: number;
fixWithAi: boolean;
ai: AiFeatureConfig;
primarySubLanguages: string[];
};
immersionTracking: {