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

@@ -27,7 +27,7 @@ SubMiner is an Electron overlay that sits on top of mpv. It turns your video pla
- **Dictionary lookups** — Yomitan popups on subtitles with hover or full keyboard-driven navigation; hover-aware auto-pause keeps playback in sync - **Dictionary lookups** — Yomitan popups on subtitles with hover or full keyboard-driven navigation; hover-aware auto-pause keeps playback in sync
- **One-key mining** — Creates Anki cards with sentence, audio, screenshot, and AI-powered translation - **One-key mining** — Creates Anki cards with sentence, audio, screenshot, and AI-powered translation
- **Reading annotations** — N+1 targeting, frequency highlighting, and JLPT underlining while you watch - **Reading annotations** — N+1 targeting, frequency highlighting, and JLPT underlining while you watch
- **Subtitle tools** — Jimaku downloads, alass/ffsubsync sync, and whisper.cpp transcription for YouTube with optional AI cleanup - **Subtitle tools** — Jimaku downloads, alass/ffsubsync sync, and YouTube subtitle generation via manual-track reuse plus whisper.cpp fallback with optional AI cleanup
- **Texthooker** — Built-in texthooker page and annotated websocket API for external clients - **Texthooker** — Built-in texthooker page and annotated websocket API for external clients
- **Immersion tracking** — SQLite-powered stats on watch time and mining activity - **Immersion tracking** — SQLite-powered stats on watch time and mining activity
- **Integrations** — Jellyfin remote playback, AniList episode progress, and AnkiConnect auto-enrichment - **Integrations** — Jellyfin remote playback, AniList episode progress, and AnkiConnect auto-enrichment

View File

@@ -0,0 +1,4 @@
type: changed
area: launcher
- Removed the YouTube subtitle generation mode switch so YouTube playback always preloads subtitles before mpv starts.

View File

@@ -5,7 +5,6 @@
* Copy to $XDG_CONFIG_HOME/SubMiner/config.jsonc (or ~/.config/SubMiner/config.jsonc) and edit as needed. * Copy to $XDG_CONFIG_HOME/SubMiner/config.jsonc (or ~/.config/SubMiner/config.jsonc) and edit as needed.
*/ */
{ {
// ========================================== // ==========================================
// Overlay Auto-Start // Overlay Auto-Start
// When overlay connects to mpv, automatically show overlay and hide mpv subtitles. // When overlay connects to mpv, automatically show overlay and hide mpv subtitles.
@@ -18,7 +17,7 @@
// ========================================== // ==========================================
"texthooker": { "texthooker": {
"launchAtStartup": true, // Launch texthooker server automatically when SubMiner starts. Values: true | false "launchAtStartup": true, // Launch texthooker server automatically when SubMiner starts. Values: true | false
"openBrowser": true // Open browser setting. Values: true | false "openBrowser": true, // Open browser setting. Values: true | false
}, // Configure texthooker startup launch and browser opening behavior. }, // Configure texthooker startup launch and browser opening behavior.
// ========================================== // ==========================================
@@ -28,7 +27,7 @@
// ========================================== // ==========================================
"websocket": { "websocket": {
"enabled": "auto", // Built-in subtitle websocket server mode. Values: auto | true | false "enabled": "auto", // Built-in subtitle websocket server mode. Values: auto | true | false
"port": 6677 // Built-in subtitle websocket server port. "port": 6677, // Built-in subtitle websocket server port.
}, // Built-in WebSocket server broadcasts subtitle text to connected clients. }, // Built-in WebSocket server broadcasts subtitle text to connected clients.
// ========================================== // ==========================================
@@ -38,7 +37,7 @@
// ========================================== // ==========================================
"annotationWebsocket": { "annotationWebsocket": {
"enabled": true, // Annotated subtitle websocket server enabled state. Values: true | false "enabled": true, // Annotated subtitle websocket server enabled state. Values: true | false
"port": 6678 // Annotated subtitle websocket server port. "port": 6678, // Annotated subtitle websocket server port.
}, // Dedicated annotated subtitle websocket for bundled texthooker and token-aware clients. }, // Dedicated annotated subtitle websocket for bundled texthooker and token-aware clients.
// ========================================== // ==========================================
@@ -47,7 +46,7 @@
// Set to debug for full runtime diagnostics. // Set to debug for full runtime diagnostics.
// ========================================== // ==========================================
"logging": { "logging": {
"level": "info" // Minimum log level for runtime logging. Values: debug | info | warn | error "level": "info", // Minimum log level for runtime logging. Values: debug | info | warn | error
}, // Controls logging verbosity. }, // Controls logging verbosity.
// ========================================== // ==========================================
@@ -61,7 +60,7 @@
"mecab": true, // Warm up MeCab tokenizer at startup. Values: true | false "mecab": true, // Warm up MeCab tokenizer at startup. Values: true | false
"yomitanExtension": true, // Warm up Yomitan extension at startup. Values: true | false "yomitanExtension": true, // Warm up Yomitan extension at startup. Values: true | false
"subtitleDictionaries": true, // Warm up subtitle dictionaries at startup. Values: true | false "subtitleDictionaries": true, // Warm up subtitle dictionaries at startup. Values: true | false
"jellyfinRemoteSession": true // Warm up Jellyfin remote session at startup. Values: true | false "jellyfinRemoteSession": true, // Warm up Jellyfin remote session at startup. Values: true | false
}, // Background warmup controls for MeCab, Yomitan, dictionaries, and Jellyfin session. }, // Background warmup controls for MeCab, Yomitan, dictionaries, and Jellyfin session.
// ========================================== // ==========================================
@@ -82,7 +81,7 @@
"toggleSecondarySub": "CommandOrControl+Shift+V", // Toggle secondary sub setting. "toggleSecondarySub": "CommandOrControl+Shift+V", // Toggle secondary sub setting.
"markAudioCard": "CommandOrControl+Shift+A", // Mark audio card setting. "markAudioCard": "CommandOrControl+Shift+A", // Mark audio card setting.
"openRuntimeOptions": "CommandOrControl+Shift+O", // Open runtime options setting. "openRuntimeOptions": "CommandOrControl+Shift+O", // Open runtime options setting.
"openJimaku": "Ctrl+Shift+J" // Open jimaku setting. "openJimaku": "Ctrl+Shift+J", // Open jimaku setting.
}, // Overlay keyboard shortcuts. Set a shortcut to null to disable. }, // Overlay keyboard shortcuts. Set a shortcut to null to disable.
// ========================================== // ==========================================
@@ -102,7 +101,7 @@
"secondarySub": { "secondarySub": {
"secondarySubLanguages": [], // Secondary sub languages setting. "secondarySubLanguages": [], // Secondary sub languages setting.
"autoLoadSecondarySub": false, // Auto load secondary sub setting. Values: true | false "autoLoadSecondarySub": false, // Auto load secondary sub setting. Values: true | false
"defaultMode": "hover" // Default mode setting. "defaultMode": "hover", // Default mode setting.
}, // Dual subtitle track options. }, // Dual subtitle track options.
// ========================================== // ==========================================
@@ -114,7 +113,7 @@
"alass_path": "", // Alass path setting. "alass_path": "", // Alass path setting.
"ffsubsync_path": "", // Ffsubsync path setting. "ffsubsync_path": "", // Ffsubsync path setting.
"ffmpeg_path": "", // Ffmpeg path setting. "ffmpeg_path": "", // Ffmpeg path setting.
"replace": true // Replace the active subtitle file when sync completes. Values: true | false "replace": true, // Replace the active subtitle file when sync completes. Values: true | false
}, // Subsync engine and executable paths. }, // Subsync engine and executable paths.
// ========================================== // ==========================================
@@ -122,7 +121,7 @@
// Initial vertical subtitle position from the bottom. // Initial vertical subtitle position from the bottom.
// ========================================== // ==========================================
"subtitlePosition": { "subtitlePosition": {
"yPercent": 10 // Y percent setting. "yPercent": 10, // Y percent setting.
}, // Initial vertical subtitle position from the bottom. }, // Initial vertical subtitle position from the bottom.
// ========================================== // ==========================================
@@ -159,7 +158,7 @@
"N2": "#f5a97f", // N2 setting. "N2": "#f5a97f", // N2 setting.
"N3": "#f9e2af", // N3 setting. "N3": "#f9e2af", // N3 setting.
"N4": "#a6e3a1", // N4 setting. "N4": "#a6e3a1", // N4 setting.
"N5": "#8aadf4" // N5 setting. "N5": "#8aadf4", // N5 setting.
}, // Jlpt colors setting. }, // Jlpt colors setting.
"frequencyDictionary": { "frequencyDictionary": {
"enabled": false, // Enable frequency-dictionary-based highlighting based on token rank. Values: true | false "enabled": false, // Enable frequency-dictionary-based highlighting based on token rank. Values: true | false
@@ -168,13 +167,7 @@
"mode": "single", // single: use one color for all matching tokens. banded: use color ramp by frequency band. Values: single | banded "mode": "single", // single: use one color for all matching tokens. banded: use color ramp by frequency band. Values: single | banded
"matchMode": "headword", // headword: frequency lookup uses dictionary form. surface: lookup uses subtitle-visible token text. Values: headword | surface "matchMode": "headword", // headword: frequency lookup uses dictionary form. surface: lookup uses subtitle-visible token text. Values: headword | surface
"singleColor": "#f5a97f", // Color used when frequencyDictionary.mode is `single`. "singleColor": "#f5a97f", // Color used when frequencyDictionary.mode is `single`.
"bandedColors": [ "bandedColors": ["#ed8796", "#f5a97f", "#f9e2af", "#8bd5ca", "#8aadf4"], // Five colors used for rank bands when mode is `banded` (from most common to least within topX).
"#ed8796",
"#f5a97f",
"#f9e2af",
"#8bd5ca",
"#8aadf4"
] // Five colors used for rank bands when mode is `banded` (from most common to least within topX).
}, // Frequency dictionary setting. }, // Frequency dictionary setting.
"secondary": { "secondary": {
"fontFamily": "Inter, Noto Sans, Helvetica Neue, sans-serif", // Font family setting. "fontFamily": "Inter, Noto Sans, Helvetica Neue, sans-serif", // Font family setting.
@@ -189,14 +182,27 @@
"backgroundColor": "rgba(20, 22, 34, 0.78)", // Background color setting. "backgroundColor": "rgba(20, 22, 34, 0.78)", // Background color setting.
"backdropFilter": "blur(6px)", // Backdrop filter setting. "backdropFilter": "blur(6px)", // Backdrop filter setting.
"fontWeight": "600", // Font weight setting. "fontWeight": "600", // Font weight setting.
"fontStyle": "normal" // Font style setting. "fontStyle": "normal", // Font style setting.
} // Secondary setting. }, // Secondary setting.
}, // Primary and secondary subtitle styling. }, // Primary and secondary subtitle styling.
// ==========================================
// Shared AI Provider
// Canonical OpenAI-compatible provider transport settings shared by Anki and YouTube subtitle fixing.
// ==========================================
"ai": {
"enabled": false, // Enable shared OpenAI-compatible AI provider features. Values: true | false
"apiKey": "", // Static API key for the shared OpenAI-compatible AI provider.
"apiKeyCommand": "", // Shell command used to resolve the shared AI provider API key.
"baseUrl": "https://openrouter.ai/api", // Base URL for the shared OpenAI-compatible AI provider.
"requestTimeoutMs": 15000, // Timeout in milliseconds for shared AI provider requests.
}, // Canonical OpenAI-compatible provider transport settings shared by Anki and YouTube subtitle fixing.
// ========================================== // ==========================================
// AnkiConnect Integration // AnkiConnect Integration
// Automatic Anki updates and media generation options. // Automatic Anki updates and media generation options.
// 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. // Most other AnkiConnect settings still require restart.
// ========================================== // ==========================================
"ankiConnect": { "ankiConnect": {
@@ -207,26 +213,20 @@
"enabled": true, // Enable local AnkiConnect-compatible proxy for push-based auto-enrichment. Values: true | false "enabled": true, // Enable local AnkiConnect-compatible proxy for push-based auto-enrichment. Values: true | false
"host": "127.0.0.1", // Bind host for local AnkiConnect proxy. "host": "127.0.0.1", // Bind host for local AnkiConnect proxy.
"port": 8766, // Bind port for local AnkiConnect proxy. "port": 8766, // Bind port for local AnkiConnect proxy.
"upstreamUrl": "http://127.0.0.1:8765" // Upstream AnkiConnect URL proxied by local AnkiConnect proxy. "upstreamUrl": "http://127.0.0.1:8765", // Upstream AnkiConnect URL proxied by local AnkiConnect proxy.
}, // Proxy setting. }, // Proxy setting.
"tags": [ "tags": ["SubMiner"], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.
"SubMiner"
], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.
"fields": { "fields": {
"audio": "ExpressionAudio", // Audio setting. "audio": "ExpressionAudio", // Audio setting.
"image": "Picture", // Image setting. "image": "Picture", // Image setting.
"sentence": "Sentence", // Sentence setting. "sentence": "Sentence", // Sentence setting.
"miscInfo": "MiscInfo", // Misc info setting. "miscInfo": "MiscInfo", // Misc info setting.
"translation": "SelectionText" // Translation setting. "translation": "SelectionText", // Translation setting.
}, // Fields setting. }, // Fields setting.
"ai": { "ai": {
"enabled": false, // Enabled setting. Values: true | false "enabled": false, // Enable AI provider usage for Anki translation/enrichment flows. Values: true | false
"alwaysUseAiTranslation": false, // Always use ai translation setting. Values: true | false "model": "", // Optional model override for Anki AI translation/enrichment flows.
"apiKey": "", // Api key setting. "systemPrompt": "", // Optional system prompt override for Anki AI translation/enrichment flows.
"model": "openai/gpt-4o-mini", // Model setting.
"baseUrl": "https://openrouter.ai/api", // Base url setting.
"targetLanguage": "English", // Target language setting.
"systemPrompt": "You are a translation engine. Return only the translated text with no explanations." // System prompt setting.
}, // Ai setting. }, // Ai setting.
"media": { "media": {
"generateAudio": true, // Generate audio setting. Values: true | false "generateAudio": true, // Generate audio setting. Values: true | false
@@ -239,7 +239,7 @@
"animatedCrf": 35, // Animated crf setting. "animatedCrf": 35, // Animated crf setting.
"audioPadding": 0.5, // Audio padding setting. "audioPadding": 0.5, // Audio padding setting.
"fallbackDuration": 3, // Fallback duration setting. "fallbackDuration": 3, // Fallback duration setting.
"maxMediaDuration": 30 // Max media duration setting. "maxMediaDuration": 30, // Max media duration setting.
}, // Media setting. }, // Media setting.
"behavior": { "behavior": {
"overwriteAudio": true, // Overwrite audio setting. Values: true | false "overwriteAudio": true, // Overwrite audio setting. Values: true | false
@@ -247,7 +247,7 @@
"mediaInsertMode": "append", // Media insert mode setting. "mediaInsertMode": "append", // Media insert mode setting.
"highlightWord": true, // Highlight word setting. Values: true | false "highlightWord": true, // Highlight word setting. Values: true | false
"notificationType": "osd", // Notification type setting. "notificationType": "osd", // Notification type setting.
"autoUpdateNewCards": true // Automatically update newly added cards. Values: true | false "autoUpdateNewCards": true, // Automatically update newly added cards. Values: true | false
}, // Behavior setting. }, // Behavior setting.
"nPlusOne": { "nPlusOne": {
"highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false "highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false
@@ -256,20 +256,20 @@
"decks": [], // Decks used for N+1 known-word cache scope. Supports one or more deck names. "decks": [], // Decks used for N+1 known-word cache scope. Supports one or more deck names.
"minSentenceWords": 3, // Minimum sentence word count required for N+1 targeting (default: 3). "minSentenceWords": 3, // Minimum sentence word count required for N+1 targeting (default: 3).
"nPlusOne": "#c6a0f6", // Color used for the single N+1 target token highlight. "nPlusOne": "#c6a0f6", // Color used for the single N+1 target token highlight.
"knownWord": "#a6da95" // Color used for legacy known-word highlights. "knownWord": "#a6da95", // Color used for legacy known-word highlights.
}, // N plus one setting. }, // N plus one setting.
"metadata": { "metadata": {
"pattern": "[SubMiner] %f (%t)" // Pattern setting. "pattern": "[SubMiner] %f (%t)", // Pattern setting.
}, // Metadata setting. }, // Metadata setting.
"isLapis": { "isLapis": {
"enabled": false, // Enabled setting. Values: true | false "enabled": false, // Enabled setting. Values: true | false
"sentenceCardModel": "Japanese sentences" // Sentence card model setting. "sentenceCardModel": "Japanese sentences", // Sentence card model setting.
}, // Is lapis setting. }, // Is lapis setting.
"isKiku": { "isKiku": {
"enabled": false, // Enabled setting. Values: true | false "enabled": false, // Enabled setting. Values: true | false
"fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled "fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled
"deleteDuplicateInAuto": true // Delete duplicate in auto setting. Values: true | false "deleteDuplicateInAuto": true, // Delete duplicate in auto setting. Values: true | false
} // Is kiku setting. }, // Is kiku setting.
}, // Automatic Anki updates and media generation options. }, // Automatic Anki updates and media generation options.
// ========================================== // ==========================================
@@ -279,22 +279,25 @@
"jimaku": { "jimaku": {
"apiBaseUrl": "https://jimaku.cc", // Api base url setting. "apiBaseUrl": "https://jimaku.cc", // Api base url setting.
"languagePreference": "ja", // Preferred language used in Jimaku search. Values: ja | en | none "languagePreference": "ja", // Preferred language used in Jimaku search. Values: ja | en | none
"maxEntryResults": 10 // Maximum Jimaku search results returned. "maxEntryResults": 10, // Maximum Jimaku search results returned.
}, // Jimaku API configuration and defaults. }, // Jimaku API configuration and defaults.
// ========================================== // ==========================================
// YouTube Subtitle Generation // YouTube Subtitle Generation
// Defaults for subminer YouTube subtitle extraction/transcription mode. // Defaults for SubMiner YouTube subtitle generation.
// ========================================== // ==========================================
"youtubeSubgen": { "youtubeSubgen": {
"mode": "automatic", // YouTube subtitle generation mode for the launcher script. Values: automatic | preprocess | off
"whisperBin": "", // Path to whisper.cpp CLI used as fallback transcription engine. "whisperBin": "", // Path to whisper.cpp CLI used as fallback transcription engine.
"whisperModel": "", // Path to whisper model used for fallback transcription. "whisperModel": "", // Path to whisper model used for fallback transcription.
"primarySubLanguages": [ "whisperVadModel": "", // Path to optional whisper VAD model used for subtitle generation.
"ja", "whisperThreads": 4, // Thread count passed to whisper.cpp subtitle generation runs.
"jpn" "fixWithAi": false, // Use shared AI provider to post-process whisper-generated YouTube subtitles. Values: true | false
] // Comma-separated primary subtitle language priority used by the launcher. "ai": {
}, // Defaults for subminer YouTube subtitle extraction/transcription mode. "model": "", // Optional model override for YouTube subtitle AI post-processing.
"systemPrompt": "", // Optional system prompt override for YouTube subtitle AI post-processing.
}, // Ai setting.
"primarySubLanguages": ["ja", "jpn"], // Comma-separated primary subtitle language priority used by the launcher.
}, // Defaults for SubMiner YouTube subtitle generation.
// ========================================== // ==========================================
// Anilist // Anilist
@@ -314,9 +317,9 @@
"collapsibleSections": { "collapsibleSections": {
"description": false, // Open the Description section by default in character dictionary glossary entries. Values: true | false "description": false, // Open the Description section by default in character dictionary glossary entries. Values: true | false
"characterInformation": false, // Open the Character Information section by default in character dictionary glossary entries. Values: true | false "characterInformation": false, // Open the Character Information section by default in character dictionary glossary entries. Values: true | false
"voicedBy": false // Open the Voiced by section by default in character dictionary glossary entries. Values: true | false "voicedBy": false, // Open the Voiced by section by default in character dictionary glossary entries. Values: true | false
} // Collapsible sections setting. }, // Collapsible sections setting.
} // Character dictionary setting. }, // Character dictionary setting.
}, // Anilist API credentials and update behavior. }, // Anilist API credentials and update behavior.
// ========================================== // ==========================================
@@ -340,16 +343,8 @@
"pullPictures": false, // Enable Jellyfin poster/icon fetching for launcher menus. Values: true | false "pullPictures": false, // Enable Jellyfin poster/icon fetching for launcher menus. Values: true | false
"iconCacheDir": "/tmp/subminer-jellyfin-icons", // Directory used by launcher for cached Jellyfin poster icons. "iconCacheDir": "/tmp/subminer-jellyfin-icons", // Directory used by launcher for cached Jellyfin poster icons.
"directPlayPreferred": true, // Try direct play before server-managed transcoding when possible. Values: true | false "directPlayPreferred": true, // Try direct play before server-managed transcoding when possible. Values: true | false
"directPlayContainers": [ "directPlayContainers": ["mkv", "mp4", "webm", "mov", "flac", "mp3", "aac"], // Container allowlist for direct play decisions.
"mkv", "transcodeVideoCodec": "h264", // Preferred transcode video codec when direct play is unavailable.
"mp4",
"webm",
"mov",
"flac",
"mp3",
"aac"
], // Container allowlist for direct play decisions.
"transcodeVideoCodec": "h264" // Preferred transcode video codec when direct play is unavailable.
}, // Optional Jellyfin integration for auth, browsing, and playback launch. }, // Optional Jellyfin integration for auth, browsing, and playback launch.
// ========================================== // ==========================================
@@ -360,7 +355,7 @@
"discordPresence": { "discordPresence": {
"enabled": false, // Enable optional Discord Rich Presence updates. Values: true | false "enabled": false, // Enable optional Discord Rich Presence updates. Values: true | false
"updateIntervalMs": 3000, // Minimum interval between presence payload updates. "updateIntervalMs": 3000, // Minimum interval between presence payload updates.
"debounceMs": 750 // Debounce delay used to collapse bursty presence updates. "debounceMs": 750, // Debounce delay used to collapse bursty presence updates.
}, // Optional Discord Rich Presence activity card updates for current playback/study session. }, // Optional Discord Rich Presence activity card updates for current playback/study session.
// ========================================== // ==========================================
@@ -382,7 +377,7 @@
"telemetryDays": 30, // Telemetry retention window in days. "telemetryDays": 30, // Telemetry retention window in days.
"dailyRollupsDays": 365, // Daily rollup retention window in days. "dailyRollupsDays": 365, // Daily rollup retention window in days.
"monthlyRollupsDays": 1825, // Monthly rollup retention window in days. "monthlyRollupsDays": 1825, // Monthly rollup retention window in days.
"vacuumIntervalDays": 7 // Minimum days between VACUUM runs. "vacuumIntervalDays": 7, // Minimum days between VACUUM runs.
} // Retention setting. }, // Retention setting.
} // Enable/disable immersion tracking. }, // Enable/disable immersion tracking.
} }

View File

@@ -34,12 +34,7 @@ function checkDependencies(args: Args): void {
missing.push('yt-dlp'); missing.push('yt-dlp');
} }
if ( if (args.targetKind === 'url' && isYoutubeTarget(args.target) && !commandExists('ffmpeg')) {
args.targetKind === 'url' &&
isYoutubeTarget(args.target) &&
args.youtubeSubgenMode !== 'off' &&
!commandExists('ffmpeg')
) {
missing.push('ffmpeg'); missing.push('ffmpeg');
} }
@@ -164,22 +159,28 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi
const isYoutubeUrl = selectedTarget.kind === 'url' && isYoutubeTarget(selectedTarget.target); const isYoutubeUrl = selectedTarget.kind === 'url' && isYoutubeTarget(selectedTarget.target);
let preloadedSubtitles: { primaryPath?: string; secondaryPath?: string } | undefined; let preloadedSubtitles: { primaryPath?: string; secondaryPath?: string } | undefined;
if (isYoutubeUrl && args.youtubeSubgenMode === 'preprocess') { if (isYoutubeUrl) {
log('info', args.logLevel, 'YouTube subtitle mode: preprocess'); log('info', args.logLevel, 'YouTube subtitle generation: preload before mpv');
const generated = await generateYoutubeSubtitles(selectedTarget.target, args); const generated = await generateYoutubeSubtitles(selectedTarget.target, args);
preloadedSubtitles = { preloadedSubtitles = {
primaryPath: generated.primaryPath, primaryPath: generated.primaryPath,
secondaryPath: generated.secondaryPath, secondaryPath: generated.secondaryPath,
}; };
const primaryStatus = generated.primaryPath
? 'ready'
: generated.primaryNative
? 'native'
: 'missing';
const secondaryStatus = generated.secondaryPath
? 'ready'
: generated.secondaryNative
? 'native'
: 'missing';
log( log(
'info', 'info',
args.logLevel, args.logLevel,
`YouTube preprocess result: primary=${generated.primaryPath ? 'ready' : 'missing'}, secondary=${generated.secondaryPath ? 'ready' : 'missing'}`, `YouTube subtitle result: primary=${primaryStatus}, secondary=${secondaryStatus}`,
); );
} else if (isYoutubeUrl && args.youtubeSubgenMode === 'automatic') {
log('info', args.logLevel, 'YouTube subtitle mode: automatic (background)');
} else if (isYoutubeUrl) {
log('info', args.logLevel, 'YouTube subtitle mode: off');
} }
const shouldPauseUntilOverlayReady = const shouldPauseUntilOverlayReady =
@@ -201,26 +202,6 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi
{ startPaused: shouldPauseUntilOverlayReady }, { startPaused: shouldPauseUntilOverlayReady },
); );
if (isYoutubeUrl && args.youtubeSubgenMode === 'automatic') {
void generateYoutubeSubtitles(selectedTarget.target, args, async (lang, subtitlePath) => {
try {
await loadSubtitleIntoMpv(mpvSocketPath, subtitlePath, lang === 'primary', args.logLevel);
} catch (error) {
log(
'warn',
args.logLevel,
`Generated subtitle ready but failed to load in mpv: ${(error as Error).message}`,
);
}
}).catch((error) => {
log(
'warn',
args.logLevel,
`Background subtitle generation failed: ${(error as Error).message}`,
);
});
}
const ready = await waitForUnixSocketReady(mpvSocketPath, 10000); const ready = await waitForUnixSocketReady(mpvSocketPath, 10000);
const pluginAutoStartEnabled = pluginRuntimeConfig.autoStart; const pluginAutoStartEnabled = pluginRuntimeConfig.autoStart;
const shouldStartOverlay = args.startOverlay || args.autoStartOverlay; const shouldStartOverlay = args.startOverlay || args.autoStartOverlay;

View File

@@ -6,10 +6,24 @@ import { parsePluginRuntimeConfigContent } from './config/plugin-runtime-config.
test('parseLauncherYoutubeSubgenConfig keeps only valid typed values', () => { test('parseLauncherYoutubeSubgenConfig keeps only valid typed values', () => {
const parsed = parseLauncherYoutubeSubgenConfig({ const parsed = parseLauncherYoutubeSubgenConfig({
ai: {
enabled: true,
apiKey: 'shared-key',
baseUrl: 'https://openrouter.ai/api',
model: 'openrouter/shared-model',
systemPrompt: 'Legacy shared prompt.',
requestTimeoutMs: 12000,
},
youtubeSubgen: { youtubeSubgen: {
mode: 'preprocess',
whisperBin: '/usr/bin/whisper', whisperBin: '/usr/bin/whisper',
whisperModel: '/models/base.bin', whisperModel: '/models/base.bin',
whisperVadModel: '/models/vad.bin',
whisperThreads: 6.8,
fixWithAi: true,
ai: {
model: 'openrouter/subgen-model',
systemPrompt: 'Fix subtitles only.',
},
primarySubLanguages: ['ja', 42, 'en'], primarySubLanguages: ['ja', 42, 'en'],
}, },
secondarySub: { secondarySub: {
@@ -24,9 +38,17 @@ test('parseLauncherYoutubeSubgenConfig keeps only valid typed values', () => {
}, },
}); });
assert.equal(parsed.mode, 'preprocess'); assert.equal('mode' in parsed, false);
assert.deepEqual(parsed.primarySubLanguages, ['ja', 'en']); assert.deepEqual(parsed.primarySubLanguages, ['ja', 'en']);
assert.deepEqual(parsed.secondarySubLanguages, ['eng', 'deu']); assert.deepEqual(parsed.secondarySubLanguages, ['eng', 'deu']);
assert.equal(parsed.whisperVadModel, '/models/vad.bin');
assert.equal(parsed.whisperThreads, 6);
assert.equal(parsed.fixWithAi, true);
assert.equal(parsed.ai?.enabled, true);
assert.equal(parsed.ai?.apiKey, 'shared-key');
assert.equal(parsed.ai?.model, 'openrouter/subgen-model');
assert.equal(parsed.ai?.systemPrompt, 'Fix subtitles only.');
assert.equal(parsed.ai?.requestTimeoutMs, 12000);
assert.equal(parsed.jimakuLanguagePreference, 'ja'); assert.equal(parsed.jimakuLanguagePreference, 'ja');
assert.equal(parsed.jimakuMaxEntryResults, 8); assert.equal(parsed.jimakuMaxEntryResults, 8);
}); });

View File

@@ -1,13 +1,7 @@
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { fail } from '../log.js'; import { fail } from '../log.js';
import type { import type { Args, Backend, LauncherYoutubeSubgenConfig, LogLevel } from '../types.js';
Args,
Backend,
LauncherYoutubeSubgenConfig,
LogLevel,
YoutubeSubgenMode,
} from '../types.js';
import { import {
DEFAULT_JIMAKU_API_BASE_URL, DEFAULT_JIMAKU_API_BASE_URL,
DEFAULT_YOUTUBE_PRIMARY_SUB_LANGS, DEFAULT_YOUTUBE_PRIMARY_SUB_LANGS,
@@ -54,14 +48,6 @@ function parseLogLevel(value: string): LogLevel {
fail(`Invalid log level: ${value} (must be debug, info, warn, or error)`); fail(`Invalid log level: ${value} (must be debug, info, warn, or error)`);
} }
function parseYoutubeMode(value: string): YoutubeSubgenMode {
const normalized = value.toLowerCase();
if (normalized === 'automatic' || normalized === 'preprocess' || normalized === 'off') {
return normalized as YoutubeSubgenMode;
}
fail(`Invalid yt-subgen mode: ${value} (must be automatic, preprocess, or off)`);
}
function parseBackend(value: string): Backend { function parseBackend(value: string): Backend {
if (value === 'auto' || value === 'hyprland' || value === 'x11' || value === 'macos') { if (value === 'auto' || value === 'hyprland' || value === 'x11' || value === 'macos') {
return value as Backend; return value as Backend;
@@ -91,13 +77,6 @@ function parseDictionaryTarget(value: string): string {
} }
export function createDefaultArgs(launcherConfig: LauncherYoutubeSubgenConfig): Args { export function createDefaultArgs(launcherConfig: LauncherYoutubeSubgenConfig): Args {
const envMode = (process.env.SUBMINER_YT_SUBGEN_MODE || '').toLowerCase();
const defaultMode: YoutubeSubgenMode =
envMode === 'preprocess' || envMode === 'off' || envMode === 'automatic'
? (envMode as YoutubeSubgenMode)
: launcherConfig.mode
? launcherConfig.mode
: 'automatic';
const configuredSecondaryLangs = uniqueNormalizedLangCodes( const configuredSecondaryLangs = uniqueNormalizedLangCodes(
launcherConfig.secondarySubLanguages ?? [], launcherConfig.secondarySubLanguages ?? [],
); );
@@ -120,12 +99,18 @@ export function createDefaultArgs(launcherConfig: LauncherYoutubeSubgenConfig):
recursive: false, recursive: false,
profile: 'subminer', profile: 'subminer',
startOverlay: false, startOverlay: false,
youtubeSubgenMode: defaultMode,
whisperBin: process.env.SUBMINER_WHISPER_BIN || launcherConfig.whisperBin || '', whisperBin: process.env.SUBMINER_WHISPER_BIN || launcherConfig.whisperBin || '',
whisperModel: process.env.SUBMINER_WHISPER_MODEL || launcherConfig.whisperModel || '', whisperModel: process.env.SUBMINER_WHISPER_MODEL || launcherConfig.whisperModel || '',
whisperVadModel: process.env.SUBMINER_WHISPER_VAD_MODEL || launcherConfig.whisperVadModel || '',
whisperThreads: (() => {
const envValue = Number.parseInt(process.env.SUBMINER_WHISPER_THREADS || '', 10);
if (Number.isInteger(envValue) && envValue > 0) return envValue;
return launcherConfig.whisperThreads || 4;
})(),
youtubeSubgenOutDir: process.env.SUBMINER_YT_SUBGEN_OUT_DIR || DEFAULT_YOUTUBE_SUBGEN_OUT_DIR, youtubeSubgenOutDir: process.env.SUBMINER_YT_SUBGEN_OUT_DIR || DEFAULT_YOUTUBE_SUBGEN_OUT_DIR,
youtubeSubgenAudioFormat: process.env.SUBMINER_YT_SUBGEN_AUDIO_FORMAT || 'm4a', youtubeSubgenAudioFormat: process.env.SUBMINER_YT_SUBGEN_AUDIO_FORMAT || 'm4a',
youtubeSubgenKeepTemp: process.env.SUBMINER_YT_SUBGEN_KEEP_TEMP === '1', youtubeSubgenKeepTemp: process.env.SUBMINER_YT_SUBGEN_KEEP_TEMP === '1',
youtubeFixWithAi: launcherConfig.fixWithAi === true,
jimakuApiKey: process.env.SUBMINER_JIMAKU_API_KEY || '', jimakuApiKey: process.env.SUBMINER_JIMAKU_API_KEY || '',
jimakuApiKeyCommand: process.env.SUBMINER_JIMAKU_API_KEY_COMMAND || '', jimakuApiKeyCommand: process.env.SUBMINER_JIMAKU_API_KEY_COMMAND || '',
jimakuApiBaseUrl: process.env.SUBMINER_JIMAKU_API_BASE_URL || DEFAULT_JIMAKU_API_BASE_URL, jimakuApiBaseUrl: process.env.SUBMINER_JIMAKU_API_BASE_URL || DEFAULT_JIMAKU_API_BASE_URL,
@@ -152,6 +137,15 @@ export function createDefaultArgs(launcherConfig: LauncherYoutubeSubgenConfig):
youtubeSecondarySubLangs: secondarySubLangs, youtubeSecondarySubLangs: secondarySubLangs,
youtubeAudioLangs, youtubeAudioLangs,
youtubeWhisperSourceLanguage: inferWhisperLanguage(primarySubLangs, 'ja'), youtubeWhisperSourceLanguage: inferWhisperLanguage(primarySubLangs, 'ja'),
aiConfig: {
enabled: launcherConfig.ai?.enabled,
apiKey: launcherConfig.ai?.apiKey,
apiKeyCommand: launcherConfig.ai?.apiKeyCommand,
baseUrl: launcherConfig.ai?.baseUrl,
model: launcherConfig.ai?.model,
systemPrompt: launcherConfig.ai?.systemPrompt,
requestTimeoutMs: launcherConfig.ai?.requestTimeoutMs,
},
useTexthooker: true, useTexthooker: true,
autoStartOverlay: false, autoStartOverlay: false,
texthookerOnly: false, texthookerOnly: false,
@@ -242,8 +236,6 @@ export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations
if (invocations.ytInvocation) { if (invocations.ytInvocation) {
if (invocations.ytInvocation.logLevel) if (invocations.ytInvocation.logLevel)
parsed.logLevel = parseLogLevel(invocations.ytInvocation.logLevel); parsed.logLevel = parseLogLevel(invocations.ytInvocation.logLevel);
if (invocations.ytInvocation.mode)
parsed.youtubeSubgenMode = parseYoutubeMode(invocations.ytInvocation.mode);
if (invocations.ytInvocation.outDir) if (invocations.ytInvocation.outDir)
parsed.youtubeSubgenOutDir = invocations.ytInvocation.outDir; parsed.youtubeSubgenOutDir = invocations.ytInvocation.outDir;
if (invocations.ytInvocation.keepTemp) parsed.youtubeSubgenKeepTemp = true; if (invocations.ytInvocation.keepTemp) parsed.youtubeSubgenKeepTemp = true;
@@ -251,6 +243,10 @@ export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations
parsed.whisperBin = invocations.ytInvocation.whisperBin; parsed.whisperBin = invocations.ytInvocation.whisperBin;
if (invocations.ytInvocation.whisperModel) if (invocations.ytInvocation.whisperModel)
parsed.whisperModel = invocations.ytInvocation.whisperModel; parsed.whisperModel = invocations.ytInvocation.whisperModel;
if (invocations.ytInvocation.whisperVadModel)
parsed.whisperVadModel = invocations.ytInvocation.whisperVadModel;
if (invocations.ytInvocation.whisperThreads)
parsed.whisperThreads = invocations.ytInvocation.whisperThreads;
if (invocations.ytInvocation.ytSubgenAudioFormat) { if (invocations.ytInvocation.ytSubgenAudioFormat) {
parsed.youtubeSubgenAudioFormat = invocations.ytInvocation.ytSubgenAudioFormat; parsed.youtubeSubgenAudioFormat = invocations.ytInvocation.ytSubgenAudioFormat;
} }

View File

@@ -16,11 +16,12 @@ export interface JellyfinInvocation {
export interface YtInvocation { export interface YtInvocation {
target?: string; target?: string;
mode?: string;
outDir?: string; outDir?: string;
keepTemp?: boolean; keepTemp?: boolean;
whisperBin?: string; whisperBin?: string;
whisperModel?: string; whisperModel?: string;
whisperVadModel?: string;
whisperThreads?: number;
ytSubgenAudioFormat?: string; ytSubgenAudioFormat?: string;
logLevel?: string; logLevel?: string;
} }
@@ -201,21 +202,27 @@ export function parseCliPrograms(
.alias('youtube') .alias('youtube')
.description('YouTube workflows') .description('YouTube workflows')
.argument('[target]', 'YouTube URL or ytsearch: query') .argument('[target]', 'YouTube URL or ytsearch: query')
.option('-m, --mode <mode>', 'Subtitle generation mode')
.option('-o, --out-dir <dir>', 'Subtitle output dir') .option('-o, --out-dir <dir>', 'Subtitle output dir')
.option('--keep-temp', 'Keep temp files') .option('--keep-temp', 'Keep temp files')
.option('--whisper-bin <path>', 'whisper.cpp CLI path') .option('--whisper-bin <path>', 'whisper.cpp CLI path')
.option('--whisper-model <path>', 'whisper model path') .option('--whisper-model <path>', 'whisper model path')
.option('--whisper-vad-model <path>', 'whisper.cpp VAD model path')
.option('--whisper-threads <n>', 'whisper.cpp thread count')
.option('--yt-subgen-audio-format <format>', 'Audio extraction format') .option('--yt-subgen-audio-format <format>', 'Audio extraction format')
.option('--log-level <level>', 'Log level') .option('--log-level <level>', 'Log level')
.action((target: string | undefined, options: Record<string, unknown>) => { .action((target: string | undefined, options: Record<string, unknown>) => {
ytInvocation = { ytInvocation = {
target, target,
mode: typeof options.mode === 'string' ? options.mode : undefined,
outDir: typeof options.outDir === 'string' ? options.outDir : undefined, outDir: typeof options.outDir === 'string' ? options.outDir : undefined,
keepTemp: options.keepTemp === true, keepTemp: options.keepTemp === true,
whisperBin: typeof options.whisperBin === 'string' ? options.whisperBin : undefined, whisperBin: typeof options.whisperBin === 'string' ? options.whisperBin : undefined,
whisperModel: typeof options.whisperModel === 'string' ? options.whisperModel : undefined, whisperModel: typeof options.whisperModel === 'string' ? options.whisperModel : undefined,
whisperVadModel:
typeof options.whisperVadModel === 'string' ? options.whisperVadModel : undefined,
whisperThreads:
typeof options.whisperThreads === 'number' && Number.isFinite(options.whisperThreads)
? Math.floor(options.whisperThreads)
: undefined,
ytSubgenAudioFormat: ytSubgenAudioFormat:
typeof options.ytSubgenAudioFormat === 'string' ? options.ytSubgenAudioFormat : undefined, typeof options.ytSubgenAudioFormat === 'string' ? options.ytSubgenAudioFormat : undefined,
logLevel: typeof options.logLevel === 'string' ? options.logLevel : undefined, logLevel: typeof options.logLevel === 'string' ? options.logLevel : undefined,

View File

@@ -1,4 +1,5 @@
import type { LauncherYoutubeSubgenConfig } from '../types.js'; import type { LauncherYoutubeSubgenConfig } from '../types.js';
import { mergeAiConfig } from '../../src/ai/config.js';
function asStringArray(value: unknown): string[] | undefined { function asStringArray(value: unknown): string[] | undefined {
if (!Array.isArray(value)) return undefined; if (!Array.isArray(value)) return undefined;
@@ -21,17 +22,58 @@ export function parseLauncherYoutubeSubgenConfig(
const jimakuRaw = root.jimaku; const jimakuRaw = root.jimaku;
const jimaku = const jimaku =
jimakuRaw && typeof jimakuRaw === 'object' ? (jimakuRaw as Record<string, unknown>) : null; jimakuRaw && typeof jimakuRaw === 'object' ? (jimakuRaw as Record<string, unknown>) : null;
const aiRaw = root.ai;
const ai = aiRaw && typeof aiRaw === 'object' ? (aiRaw as Record<string, unknown>) : null;
const youtubeAiRaw = youtubeSubgen?.ai;
const youtubeAi =
youtubeAiRaw && typeof youtubeAiRaw === 'object'
? (youtubeAiRaw as Record<string, unknown>)
: null;
const mode = youtubeSubgen?.mode;
const jimakuLanguagePreference = jimaku?.languagePreference; const jimakuLanguagePreference = jimaku?.languagePreference;
const jimakuMaxEntryResults = jimaku?.maxEntryResults; const jimakuMaxEntryResults = jimaku?.maxEntryResults;
return { return {
mode: mode === 'automatic' || mode === 'preprocess' || mode === 'off' ? mode : undefined,
whisperBin: whisperBin:
typeof youtubeSubgen?.whisperBin === 'string' ? youtubeSubgen.whisperBin : undefined, typeof youtubeSubgen?.whisperBin === 'string' ? youtubeSubgen.whisperBin : undefined,
whisperModel: whisperModel:
typeof youtubeSubgen?.whisperModel === 'string' ? youtubeSubgen.whisperModel : undefined, typeof youtubeSubgen?.whisperModel === 'string' ? youtubeSubgen.whisperModel : undefined,
whisperVadModel:
typeof youtubeSubgen?.whisperVadModel === 'string'
? youtubeSubgen.whisperVadModel
: undefined,
whisperThreads:
typeof youtubeSubgen?.whisperThreads === 'number' &&
Number.isFinite(youtubeSubgen.whisperThreads) &&
youtubeSubgen.whisperThreads > 0
? Math.floor(youtubeSubgen.whisperThreads)
: undefined,
fixWithAi: typeof youtubeSubgen?.fixWithAi === 'boolean' ? youtubeSubgen.fixWithAi : undefined,
ai: mergeAiConfig(
ai
? {
enabled: typeof ai.enabled === 'boolean' ? ai.enabled : undefined,
apiKey: typeof ai.apiKey === 'string' ? ai.apiKey : undefined,
apiKeyCommand: typeof ai.apiKeyCommand === 'string' ? ai.apiKeyCommand : undefined,
baseUrl: typeof ai.baseUrl === 'string' ? ai.baseUrl : undefined,
model: typeof ai.model === 'string' ? ai.model : undefined,
systemPrompt: typeof ai.systemPrompt === 'string' ? ai.systemPrompt : undefined,
requestTimeoutMs:
typeof ai.requestTimeoutMs === 'number' &&
Number.isFinite(ai.requestTimeoutMs) &&
ai.requestTimeoutMs > 0
? Math.floor(ai.requestTimeoutMs)
: undefined,
}
: undefined,
youtubeAi
? {
model: typeof youtubeAi.model === 'string' ? youtubeAi.model : undefined,
systemPrompt:
typeof youtubeAi.systemPrompt === 'string' ? youtubeAi.systemPrompt : undefined,
}
: undefined,
),
primarySubLanguages: asStringArray(youtubeSubgen?.primarySubLanguages), primarySubLanguages: asStringArray(youtubeSubgen?.primarySubLanguages),
secondarySubLanguages: asStringArray(secondarySub?.secondarySubLanguages), secondarySubLanguages: asStringArray(secondarySub?.secondarySubLanguages),
jimakuApiKey: typeof jimaku?.apiKey === 'string' ? jimaku.apiKey : undefined, jimakuApiKey: typeof jimaku?.apiKey === 'string' ? jimaku.apiKey : undefined,

View File

@@ -162,6 +162,134 @@ test('doctor reports checks and exits non-zero without hard dependencies', () =>
}); });
}); });
test('youtube command rejects removed --mode option', () => {
withTempDir((root) => {
const homeDir = path.join(root, 'home');
const xdgConfigHome = path.join(root, 'xdg');
const appPath = path.join(root, 'fake-subminer.sh');
fs.writeFileSync(appPath, '#!/bin/sh\nexit 0\n');
fs.chmodSync(appPath, 0o755);
const env = {
...makeTestEnv(homeDir, xdgConfigHome),
SUBMINER_APPIMAGE_PATH: appPath,
};
const result = runLauncher(
['youtube', 'https://www.youtube.com/watch?v=test123', '--mode', 'automatic'],
env,
);
assert.equal(result.status, 1);
assert.match(result.stderr, /unknown option '--mode'/i);
});
});
test('youtube playback generates subtitles before mpv launch', () => {
withTempDir((root) => {
const homeDir = path.join(root, 'home');
const xdgConfigHome = path.join(root, 'xdg');
const binDir = path.join(root, 'bin');
const appPath = path.join(root, 'fake-subminer.sh');
const ytdlpLogPath = path.join(root, 'yt-dlp.log');
const mpvCapturePath = path.join(root, 'mpv-order.txt');
const mpvArgsPath = path.join(root, 'mpv-args.txt');
const socketPath = path.join(root, 'mpv.sock');
fs.mkdirSync(binDir, { recursive: true });
fs.mkdirSync(path.join(xdgConfigHome, 'SubMiner'), { recursive: true });
fs.mkdirSync(path.join(xdgConfigHome, 'mpv', 'script-opts'), { recursive: true });
fs.writeFileSync(
path.join(xdgConfigHome, 'SubMiner', 'setup-state.json'),
JSON.stringify({
version: 1,
status: 'completed',
completedAt: '2026-03-08T00:00:00.000Z',
completionSource: 'user',
lastSeenYomitanDictionaryCount: 0,
pluginInstallStatus: 'installed',
pluginInstallPathSummary: null,
}),
);
fs.writeFileSync(
path.join(xdgConfigHome, 'mpv', 'script-opts', 'subminer.conf'),
`socket_path=${socketPath}\nauto_start=no\nauto_start_visible_overlay=no\nauto_start_pause_until_ready=no\n`,
);
fs.writeFileSync(appPath, '#!/bin/sh\nexit 0\n');
fs.chmodSync(appPath, 0o755);
fs.writeFileSync(
path.join(binDir, 'yt-dlp'),
`#!/bin/sh
set -eu
printf '%s\\n' "$*" >> "$SUBMINER_TEST_YTDLP_LOG"
if printf '%s\\n' "$*" | grep -q -- '--dump-single-json'; then
printf '{"id":"video123"}\\n'
exit 0
fi
out_dir=""
prev=""
for arg in "$@"; do
if [ "$prev" = "-o" ]; then
out_dir=$(dirname "$arg")
break
fi
prev="$arg"
done
mkdir -p "$out_dir"
printf '1\\n00:00:00,000 --> 00:00:01,000\\nこんにちは\\n' > "$out_dir/video123.ja.srt"
printf '1\\n00:00:00,000 --> 00:00:01,000\\nhello\\n' > "$out_dir/video123.en.srt"
`,
'utf8',
);
fs.chmodSync(path.join(binDir, 'yt-dlp'), 0o755);
fs.writeFileSync(path.join(binDir, 'ffmpeg'), '#!/bin/sh\nexit 0\n', 'utf8');
fs.chmodSync(path.join(binDir, 'ffmpeg'), 0o755);
fs.writeFileSync(
path.join(binDir, 'mpv'),
`#!/bin/sh
set -eu
if [ -s "$SUBMINER_TEST_YTDLP_LOG" ]; then
printf 'generated-before-mpv\\n' > "$SUBMINER_TEST_MPV_ORDER"
else
printf 'mpv-before-generation\\n' > "$SUBMINER_TEST_MPV_ORDER"
fi
printf '%s\\n' "$@" > "$SUBMINER_TEST_MPV_ARGS"
socket_path=""
for arg in "$@"; do
case "$arg" in
--input-ipc-server=*)
socket_path="\${arg#--input-ipc-server=}"
;;
esac
done
bun -e "const net=require('node:net'); const fs=require('node:fs'); const socket=process.argv[1]; try { fs.rmSync(socket,{force:true}); } catch {} const server=net.createServer((conn)=>conn.end()); server.listen(socket,()=>setTimeout(()=>server.close(()=>process.exit(0)),250));" "$socket_path"
`,
'utf8',
);
fs.chmodSync(path.join(binDir, 'mpv'), 0o755);
const env = {
...makeTestEnv(homeDir, xdgConfigHome),
PATH: `${binDir}${path.delimiter}${process.env.PATH || ''}`,
SUBMINER_APPIMAGE_PATH: appPath,
SUBMINER_TEST_YTDLP_LOG: ytdlpLogPath,
SUBMINER_TEST_MPV_ORDER: mpvCapturePath,
SUBMINER_TEST_MPV_ARGS: mpvArgsPath,
};
const result = runLauncher(['youtube', 'https://www.youtube.com/watch?v=test123'], env);
assert.equal(result.status, 0);
assert.equal(fs.readFileSync(mpvCapturePath, 'utf8').trim(), 'generated-before-mpv');
assert.match(
fs.readFileSync(mpvArgsPath, 'utf8'),
/https:\/\/www\.youtube\.com\/watch\?v=test123/,
);
assert.match(fs.readFileSync(ytdlpLogPath, 'utf8'), /--dump-single-json/);
});
});
test('dictionary command forwards --dictionary and --dictionary-target to app command path', () => { test('dictionary command forwards --dictionary and --dictionary-target to app command path', () => {
withTempDir((root) => { withTempDir((root) => {
const homeDir = path.join(root, 'home'); const homeDir = path.join(root, 'home');

View File

@@ -42,26 +42,38 @@ export const DEFAULT_MPV_SUBMINER_ARGS = [
] as const; ] as const;
export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
export type YoutubeSubgenMode = 'automatic' | 'preprocess' | 'off';
export type Backend = 'auto' | 'hyprland' | 'x11' | 'macos'; export type Backend = 'auto' | 'hyprland' | 'x11' | 'macos';
export type JimakuLanguagePreference = 'ja' | 'en' | 'none'; export type JimakuLanguagePreference = 'ja' | 'en' | 'none';
export interface LauncherAiConfig {
enabled?: boolean;
apiKey?: string;
apiKeyCommand?: string;
baseUrl?: string;
model?: string;
systemPrompt?: string;
requestTimeoutMs?: number;
}
export interface Args { export interface Args {
backend: Backend; backend: Backend;
directory: string; directory: string;
recursive: boolean; recursive: boolean;
profile: string; profile: string;
startOverlay: boolean; startOverlay: boolean;
youtubeSubgenMode: YoutubeSubgenMode;
whisperBin: string; whisperBin: string;
whisperModel: string; whisperModel: string;
whisperVadModel: string;
whisperThreads: number;
youtubeSubgenOutDir: string; youtubeSubgenOutDir: string;
youtubeSubgenAudioFormat: string; youtubeSubgenAudioFormat: string;
youtubeSubgenKeepTemp: boolean; youtubeSubgenKeepTemp: boolean;
youtubeFixWithAi: boolean;
youtubePrimarySubLangs: string[]; youtubePrimarySubLangs: string[];
youtubeSecondarySubLangs: string[]; youtubeSecondarySubLangs: string[];
youtubeAudioLangs: string[]; youtubeAudioLangs: string[];
youtubeWhisperSourceLanguage: string; youtubeWhisperSourceLanguage: string;
aiConfig: LauncherAiConfig;
useTexthooker: boolean; useTexthooker: boolean;
autoStartOverlay: boolean; autoStartOverlay: boolean;
texthookerOnly: boolean; texthookerOnly: boolean;
@@ -96,9 +108,12 @@ export interface Args {
} }
export interface LauncherYoutubeSubgenConfig { export interface LauncherYoutubeSubgenConfig {
mode?: YoutubeSubgenMode;
whisperBin?: string; whisperBin?: string;
whisperModel?: string; whisperModel?: string;
whisperVadModel?: string;
whisperThreads?: number;
fixWithAi?: boolean;
ai?: LauncherAiConfig;
primarySubLanguages?: string[]; primarySubLanguages?: string[];
secondarySubLanguages?: string[]; secondarySubLanguages?: string[];
jimakuApiKey?: string; jimakuApiKey?: string;
@@ -144,13 +159,15 @@ export interface SubtitleCandidate {
lang: 'primary' | 'secondary'; lang: 'primary' | 'secondary';
ext: string; ext: string;
size: number; size: number;
source: 'manual' | 'auto' | 'whisper' | 'whisper-translate'; source: 'manual' | 'whisper' | 'whisper-fixed' | 'whisper-translate' | 'whisper-translate-fixed';
} }
export interface YoutubeSubgenOutputs { export interface YoutubeSubgenOutputs {
basename: string; basename: string;
primaryPath?: string; primaryPath?: string;
secondaryPath?: string; secondaryPath?: string;
primaryNative?: boolean;
secondaryNative?: boolean;
} }
export interface MpvTrack { export interface MpvTrack {

View File

@@ -34,6 +34,13 @@ test('loads defaults when config is missing', () => {
assert.equal(config.jellyfin.remoteControlAutoConnect, true); assert.equal(config.jellyfin.remoteControlAutoConnect, true);
assert.equal(config.jellyfin.autoAnnounce, false); assert.equal(config.jellyfin.autoAnnounce, false);
assert.equal(config.jellyfin.remoteControlDeviceName, 'SubMiner'); 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.lowPowerMode, false);
assert.equal(config.startupWarmups.mecab, true); assert.equal(config.startupWarmups.mecab, true);
assert.equal(config.startupWarmups.yomitanExtension, true); assert.equal(config.startupWarmups.yomitanExtension, true);
@@ -1068,12 +1075,20 @@ test('parses global shortcuts and startup settings', () => {
fs.writeFileSync( fs.writeFileSync(
path.join(dir, 'config.jsonc'), path.join(dir, 'config.jsonc'),
`{ `{
"ai": {
"enabled": true,
"apiKeyCommand": "pass show subminer/ai",
"model": "openai/gpt-4o-mini"
},
"shortcuts": { "shortcuts": {
"toggleVisibleOverlayGlobal": "Alt+Shift+U", "toggleVisibleOverlayGlobal": "Alt+Shift+U",
"openJimaku": "Ctrl+Alt+J" "openJimaku": "Ctrl+Alt+J"
}, },
"youtubeSubgen": { "youtubeSubgen": {
"primarySubLanguages": ["ja", "jpn", "jp"] "primarySubLanguages": ["ja", "jpn", "jp"],
"whisperVadModel": "/models/vad.bin",
"whisperThreads": 12,
"fixWithAi": true
} }
}`, }`,
'utf-8', 'utf-8',
@@ -1081,9 +1096,14 @@ test('parses global shortcuts and startup settings', () => {
const service = new ConfigService(dir); const service = new ConfigService(dir);
const config = service.getConfig(); 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.toggleVisibleOverlayGlobal, 'Alt+Shift+U');
assert.equal(config.shortcuts.openJimaku, 'Ctrl+Alt+J'); assert.equal(config.shortcuts.openJimaku, 'Ctrl+Alt+J');
assert.deepEqual(config.youtubeSubgen.primarySubLanguages, ['ja', 'jpn', 'jp']); 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', () => { 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(); const dir = makeTempDir();
fs.writeFileSync( fs.writeFileSync(
path.join(dir, 'config.jsonc'), path.join(dir, 'config.jsonc'),
`{ `{
"ankiConnect": { "ankiConnect": {
"openRouter": { "ai": {
"model": "openrouter/test-model" "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 config = service.getConfig();
const warnings = service.getWarnings(); const warnings = service.getWarnings();
assert.equal((config.ankiConnect.ai as Record<string, unknown>).model, 'openrouter/test-model'); assert.deepEqual(config.ankiConnect.ai, DEFAULT_CONFIG.ankiConnect.ai);
assert.ok( assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.ai.enabled'));
warnings.some( assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.ai.model'));
(warning) => assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.ai.systemPrompt'));
warning.path === 'ankiConnect.openRouter' && warning.message.includes('ankiConnect.ai'),
),
);
}); });
test('falls back and warns when legacy ankiConnect migration values are invalid', () => { 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', () => { test('template generator includes known keys', () => {
const output = generateConfigTemplate(DEFAULT_CONFIG); const output = generateConfigTemplate(DEFAULT_CONFIG);
assert.match(output, /"ai":/);
assert.match(output, /"ankiConnect":/); assert.match(output, /"ankiConnect":/);
assert.match(output, /"logging":/); assert.match(output, /"logging":/);
assert.match(output, /"websocket":/); assert.match(output, /"websocket":/);
@@ -1577,6 +1667,31 @@ test('template generator includes known keys', () => {
output, output,
/"enabled": false,? \/\/ Enable AnkiConnect integration\. Values: true \| false/, /"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( assert.match(
output, output,
/"launchAtStartup": true,? \/\/ Launch texthooker server automatically when SubMiner starts\. Values: true \| false/, /"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< export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
ResolvedConfig, ResolvedConfig,
'ankiConnect' | 'jimaku' | 'anilist' | 'jellyfin' | 'discordPresence' | 'youtubeSubgen' 'ankiConnect' | 'jimaku' | 'anilist' | 'jellyfin' | 'discordPresence' | 'ai' | 'youtubeSubgen'
> = { > = {
ankiConnect: { ankiConnect: {
enabled: false, enabled: false,
@@ -24,13 +24,8 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
}, },
ai: { ai: {
enabled: false, enabled: false,
alwaysUseAiTranslation: false, model: '',
apiKey: '', systemPrompt: '',
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.',
}, },
media: { media: {
generateAudio: true, generateAudio: true,
@@ -122,10 +117,26 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
updateIntervalMs: 3_000, updateIntervalMs: 3_000,
debounceMs: 750, 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: { youtubeSubgen: {
mode: 'automatic',
whisperBin: '', whisperBin: '',
whisperModel: '', whisperModel: '',
whisperVadModel: '',
whisperThreads: 4,
fixWithAi: false,
ai: {
model: '',
systemPrompt: '',
},
primarySubLanguages: ['ja', 'jpn'], primarySubLanguages: ['ja', 'jpn'],
}, },
}; };

View File

@@ -51,6 +51,24 @@ export function buildIntegrationConfigOptionRegistry(
description: description:
'Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.', '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', path: 'ankiConnect.behavior.autoUpdateNewCards',
kind: 'boolean', kind: 'boolean',
@@ -291,11 +309,34 @@ export function buildIntegrationConfigOptionRegistry(
description: 'Debounce delay used to collapse bursty presence updates.', description: 'Debounce delay used to collapse bursty presence updates.',
}, },
{ {
path: 'youtubeSubgen.mode', path: 'ai.enabled',
kind: 'enum', kind: 'boolean',
enumValues: ['automatic', 'preprocess', 'off'], defaultValue: defaultConfig.ai.enabled,
defaultValue: defaultConfig.youtubeSubgen.mode, description: 'Enable shared OpenAI-compatible AI provider features.',
description: 'YouTube subtitle generation mode for the launcher script.', },
{
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', path: 'youtubeSubgen.whisperBin',
@@ -309,6 +350,36 @@ export function buildIntegrationConfigOptionRegistry(
defaultValue: defaultConfig.youtubeSubgen.whisperModel, defaultValue: defaultConfig.youtubeSubgen.whisperModel,
description: 'Path to whisper model used for fallback transcription.', 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', path: 'youtubeSubgen.primarySubLanguages',
kind: 'string', kind: 'string',

View File

@@ -91,11 +91,19 @@ const SUBTITLE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
]; ];
const INTEGRATION_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', title: 'AnkiConnect Integration',
description: ['Automatic Anki updates and media generation options.'], description: ['Automatic Anki updates and media generation options.'],
notes: [ 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.', 'Most other AnkiConnect settings still require restart.',
], ],
key: 'ankiConnect', key: 'ankiConnect',
@@ -107,7 +115,7 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
}, },
{ {
title: 'YouTube Subtitle Generation', title: 'YouTube Subtitle Generation',
description: ['Defaults for subminer YouTube subtitle extraction/transcription mode.'], description: ['Defaults for SubMiner YouTube subtitle generation.'],
key: 'youtubeSubgen', key: 'youtubeSubgen',
}, },
{ {

View File

@@ -46,18 +46,6 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
} }
if (isObject(src.youtubeSubgen)) { 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); const whisperBin = asString(src.youtubeSubgen.whisperBin);
if (whisperBin !== undefined) { if (whisperBin !== undefined) {
resolved.youtubeSubgen.whisperBin = whisperBin; 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)) { if (Array.isArray(src.youtubeSubgen.primarySubLanguages)) {
resolved.youtubeSubgen.primarySubLanguages = src.youtubeSubgen.primarySubLanguages.filter( resolved.youtubeSubgen.primarySubLanguages = src.youtubeSubgen.primarySubLanguages.filter(
(item): item is string => typeof item === 'string', (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(startedIntegrations, 1);
assert.equal(setIntegrationCalls, 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(); options.syncOverlayShortcuts();
}; };
windowTracker.onWindowFocusChange = () => {
options.syncOverlayShortcuts();
};
windowTracker.start(); windowTracker.start();
} }

View File

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

View File

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