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

- Add `subminer youtube`/`yt` and tray "Browse YouTube" window that plays or queues picked videos in mpv
- Add `youtube.subtitleSource` (`youtube` | `whisper`) to transcribe YouTube audio via subtitle generation
- Allow the subtitle generation modal on YouTube videos
- Pick the real Japanese track over machine-translated ones and fall back to yt-dlp on HTTP 429
- Remove legacy launcher youtubeSubgen/whisper/AI args and `--youtube-mode`
- Fix EPIPE crash when the launching terminal is closed with Ctrl+C
- Thin media timing review handles with orange original-boundary bars
- Update docs and change fragments
This commit is contained in:
2026-09-28 20:46:11 -07:00
105 changed files with 2297 additions and 623 deletions
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: app
- SubMiner no longer crashes with "write EPIPE" when the terminal command that started it is stopped with Ctrl+C.
+5
View File
@@ -0,0 +1,5 @@
type: fixed
area: overlay
- Media timing review trim handles are now thin lines centered on the clip edge, so it's clear where the clip starts and ends and less of the waveform is covered.
- Original subtitle boundaries use orange bars without text tags, leaving the waveform unobstructed.
+7
View File
@@ -0,0 +1,7 @@
type: added
area: youtube
- Added a YouTube browser window (`subminer youtube`/`subminer yt`, or tray **Browse YouTube**) that keeps your YouTube login across restarts.
- Clicking a video plays it in mpv instead of the page; middle-click, Shift/Ctrl-click, or the right-click menu queues it in mpv's playlist.
- Queued YouTube videos get the same Japanese subtitle setup when mpv reaches them.
- Closing the YouTube window quits a SubMiner started by `subminer youtube` (after mpv closes, if a video is still playing).
@@ -0,0 +1,4 @@
type: fixed
area: youtube
- YouTube Japanese subtitles no longer fail with "HTTP 429" on videos where YouTube also lists a machine-translated Japanese track; SubMiner now picks the real track and falls back to yt-dlp if a direct subtitle download is refused.
+7
View File
@@ -0,0 +1,7 @@
type: added
area: youtube
- Added `youtube.subtitleSource`: set it to `whisper` to transcribe YouTube videos with Whisper instead of downloading YouTube's captions. The default stays `youtube`.
- Whisper mode keeps the video paused while it transcribes, with progress in the subtitle generation modal; close the modal to keep watching, or cancel to continue without subtitles.
- The subtitle generation modal (`Ctrl+Shift+G`) now also works on YouTube videos, whatever `youtube.subtitleSource` is set to.
- Whisper mode downloads a small audio-only stream and deletes it once generation ends, or when you switch videos, close mpv, or quit.
+2 -1
View File
@@ -698,13 +698,14 @@
// ==========================================
// YouTube Playback Settings
// Defaults for managed subtitle language preferences and YouTube subtitle loading.
// Hot-reload: primarySubLanguages applies to the next YouTube subtitle load.
// Hot-reload: primarySubLanguages and subtitleSource apply to the next YouTube subtitle load.
// ==========================================
"youtube": {
"primarySubLanguages": [
"ja",
"jpn"
], // Comma-separated primary subtitle language priority for managed subtitle auto-selection.
"subtitleSource": "youtube", // Where primary YouTube subtitles come from. Whisper transcribes the audio locally using the subtitleGeneration settings. Values: youtube | whisper
"mediaCache": {
"mode": "direct", // How YouTube card audio/images are extracted. Values: direct | background
"maxHeight": 720 // Maximum video height downloaded for the YouTube background media cache. Set to 0 for unlimited.
+1 -1
View File
@@ -140,7 +140,7 @@ With `media.reviewTiming` on, SubMiner pauses before making media for word, sent
Playback stays paused while the dialog is open, even if the popup or hover that paused it goes away. When the dialog closes, playback resumes if it was playing before, or if the popup closed in the meantime. A popup that is still open keeps it paused.
The dialog shows the clip over a speech waveform. When the waveform loads, an untouched clip end moves back to just after the last speech in the line. The Line end rail still marks the subtitle's own end.
The dialog shows the clip over a speech waveform. When the waveform loads, an untouched clip end moves back to just after the last speech in the line. The orange bars still mark the subtitle's original start and end.
| Action | How |
| ------------------------ | --------------------------------------------------------------------- |
+3 -2
View File
@@ -54,7 +54,7 @@ These apply live:
- `subtitleStyle`, `subtitleSidebar`, `subtitleSelection`, `keybindings`, `shortcuts`
- `logging.level`, `logging.rotation`, `logging.files`
- `secondarySub.defaultMode`, `youtube.primarySubLanguages`
- `secondarySub.defaultMode`, `youtube.primarySubLanguages`, `youtube.subtitleSource`
- `mpv.aniskipEnabled`, `mpv.aniskipButtonKey`, `stats.toggleKey`, `stats.markWatchedKey`
- `ankiConnect.deck`, `ankiConnect.fields.*`, `ankiConnect.behavior.autoUpdateNewCards`
- `ankiConnect.media.normalizeAudio`, `media.mirrorMpvVolume`, `media.reviewTiming`
@@ -602,11 +602,12 @@ Settings for mpv instances that SubMiner starts, and for the bundled mpv plugin.
### YouTube playback settings
Language and card-media settings for YouTube playback. YouTube always loads a Japanese primary and English secondary track, preferring manual uploads over auto captions. See [YouTube integration](/youtube-integration).
Subtitle, language, and card-media settings for YouTube playback. With YouTube captions, SubMiner loads a Japanese primary and English secondary track, preferring manual uploads over auto captions. See [YouTube integration](/youtube-integration).
| Key | Default | What it does |
| ------------------------------ | --------------- | -------------------------------------------------------------------------------------------- |
| `youtube.primarySubLanguages` | `["ja", "jpn"]` | Languages that count as a valid primary track, also used for local playback |
| `youtube.subtitleSource` | `"youtube"` | `youtube` downloads YouTube's captions. `whisper` transcribes the audio with Whisper |
| `youtube.mediaCache.mode` | `"direct"` | `direct` cuts card media from the stream. `background` downloads the video with yt-dlp first |
| `youtube.mediaCache.maxHeight` | `720` | Maximum download height in `background` mode. `0` is unlimited |
+1
View File
@@ -48,6 +48,7 @@ App flags such as `--setup` and `--dev` are not launcher flags. Pass them throug
| `subminer doctor` | Check the app, mpv, ffmpeg, yt-dlp, pickers, config, and mpv socket |
| `subminer doctor --refresh-known-words` | Refresh the known-word cache from Anki |
| `subminer settings` | Open the settings window |
| `subminer youtube` / `yt` | Open the [YouTube browser](/youtube-integration#browse-youtube-in-subminer). Videos you pick play in mpv |
| `subminer generate-subs [video]` | Generate [Japanese subtitles](/subtitle-generation) with whisper.cpp |
| `subminer jellyfin` / `jf` | [Jellyfin](/jellyfin-integration) actions: `setup`, `login`, `logout`, `play`, `discovery` |
| `subminer dictionary <path>` / `dict` | Build a [character dictionary](/character-dictionary) for a file or directory |
+1 -1
View File
@@ -124,7 +124,7 @@ script-message subminer-start backend=hyprland socket=/custom/path texthooker=no
`log-level` sets SubMiner's log verbosity. Do not use `--debug` for this; it turns on the app's dev mode.
The plugin also handles messages the SubMiner app sends it (`subminer-autoplay-ready`, `subminer-visible-overlay-shown`, `subminer-visible-overlay-hidden`, `subminer-managed-subtitles-loading`, `subminer-overlay-loading-ready`, `subminer-reload-session-bindings`). You do not need to send these yourself. The AniSkip messages are listed on the [AniSkip page](/aniskip-integration#triggering-from-mpv).
The plugin also handles messages the SubMiner app sends it (`subminer-autoplay-ready`, `subminer-autoplay-hold`, `subminer-visible-overlay-shown`, `subminer-visible-overlay-hidden`, `subminer-managed-subtitles-loading`, `subminer-overlay-loading-ready`, `subminer-reload-session-bindings`). You do not need to send these yourself. The AniSkip messages are listed on the [AniSkip page](/aniskip-integration#triggering-from-mpv).
## Auto-start behavior
+2 -1
View File
@@ -698,13 +698,14 @@
// ==========================================
// YouTube Playback Settings
// Defaults for managed subtitle language preferences and YouTube subtitle loading.
// Hot-reload: primarySubLanguages applies to the next YouTube subtitle load.
// Hot-reload: primarySubLanguages and subtitleSource apply to the next YouTube subtitle load.
// ==========================================
"youtube": {
"primarySubLanguages": [
"ja",
"jpn"
], // Comma-separated primary subtitle language priority for managed subtitle auto-selection.
"subtitleSource": "youtube", // Where primary YouTube subtitles come from. Whisper transcribes the audio locally using the subtitleGeneration settings. Values: youtube | whisper
"mediaCache": {
"mode": "direct", // How YouTube card audio/images are extracted. Values: direct | background
"maxHeight": 720 // Maximum video height downloaded for the YouTube background media cache. Set to 0 for unlimited.
+5 -1
View File
@@ -10,11 +10,13 @@ When a video has no Japanese subtitles, SubMiner can transcribe its audio into a
Downloaded models go to `models/whisper/` next to your SubMiner config file. A configured `modelPath` always wins over the modal's choice.
YouTube videos can use the same setup automatically in place of YouTube's captions. See [Generate subtitles with Whisper](/youtube-integration#generate-subtitles-with-whisper).
The modal's **Local tools** section lists anything missing. After you install a tool or change a path, click **Check again**.
## Generating from the overlay
1. Open a local video in mpv and select its Japanese audio track.
1. Open a local video in mpv and select its Japanese audio track, or play a YouTube video.
2. Press `Ctrl+Shift+G`. If the subtitle sidebar is empty, its **Generate Japanese subtitles** button opens the same modal.
3. Pick a model and download it if needed.
4. Optionally check **Focus on spoken dialogue** (see below).
@@ -24,6 +26,8 @@ The modal shows progress. **Cancel** stops the job. Closing the modal lets the j
SubMiner saves `<video>.ja.generated.srt` next to the video and adds a number if that name is taken. If the same file is still playing, it loads the subtitles and resets the subtitle delay.
For a YouTube video, SubMiner downloads its audio first and pauses the video until the subtitles load. The subtitles are temporary, and the audio is deleted when generation ends or you switch videos. See [YouTube integration](/youtube-integration#generate-subtitles-with-whisper).
Change the shortcut with `shortcuts.openSubtitleGeneration`.
## Generating from the launcher
+1
View File
@@ -99,6 +99,7 @@ Language preferences live under `youtube` and `secondarySub` in the config. See
```bash
subminer stats # start the immersion stats dashboard
subminer settings # open the settings window
subminer yt # browse YouTube; picked videos play in mpv
subminer doctor # check dependencies, config, and the mpv socket
subminer generate-subs video.mkv # make Japanese subtitles from the audio
subminer logs -e # export a log ZIP for bug reports
+1 -1
View File
@@ -227,7 +227,7 @@ The mpv plugin accepts these script messages:
script-message subminer-start backend=hyprland socket=/custom/path texthooker=no log-level=debug
```
The plugin also registers `subminer-autoplay-ready`, `subminer-visible-overlay-shown`, `subminer-visible-overlay-hidden`, `subminer-managed-subtitles-loading`, `subminer-overlay-loading-ready`, and `subminer-reload-session-bindings`. The SubMiner app sends these to keep the plugin in sync, so do not send them from your own scripts.
The plugin also registers `subminer-autoplay-ready`, `subminer-autoplay-hold`, `subminer-visible-overlay-shown`, `subminer-visible-overlay-hidden`, `subminer-managed-subtitles-loading`, `subminer-overlay-loading-ready`, and `subminer-reload-session-bindings`. The SubMiner app sends these to keep the plugin in sync, so do not send them from your own scripts.
While the app is connected to mpv, it also handles two AniSkip messages over the mpv IPC socket: `subminer-skip-intro` skips the intro, and `subminer-aniskip-refresh` reloads intro data, for example after your script changes title or episode metadata.
+37
View File
@@ -24,6 +24,41 @@ SubMiner picks tracks in this order. Manual (uploaded) tracks win over auto-gene
Press `Ctrl+Alt+C` during playback to open the subtitle picker. It lists every track with its language and kind, and lets you choose different primary and secondary tracks or retry a failed load.
## Generate subtitles with Whisper
SubMiner can transcribe a YouTube video's audio on your computer. For one video, press `Ctrl+Shift+G` and click **Generate subtitles**. To always use Whisper instead of YouTube's captions, set `youtube.subtitleSource` to `whisper`, or choose **Generate with Whisper** under **Settings > Behavior > YouTube Playback Settings**. The change applies to the next video.
```jsonc
{
"youtube": { "subtitleSource": "whisper" },
}
```
Whisper uses the model and tools from [Japanese subtitle generation](/subtitle-generation), so set those up first.
- The video stays paused while the subtitles are generated, and the subtitle generation modal shows the progress. Close the modal to keep watching while generation continues. **Cancel** stops it and resumes the video without subtitles.
- The subtitles load and playback resumes as soon as they are ready.
- SubMiner downloads the smallest audio stream of at least 48 kbps, in the video's original language, never an auto-dub.
- The audio is deleted as soon as generation finishes or fails, and when you switch to another video, close mpv, or quit SubMiner. Switching videos also stops the generation.
- No secondary subtitles load. The picker (`Ctrl+Alt+C`) still loads YouTube tracks by hand.
## Browse YouTube in SubMiner
Open a YouTube window with `subminer youtube` (or `subminer yt`), or from the tray (**Browse YouTube**). Sign in once and the login is kept across restarts. Videos you pick play in mpv with the same subtitle setup as above, and SubMiner starts mpv if it is not running.
When SubMiner was started by `subminer youtube`, closing the window quits it. If a video is still playing, SubMiner quits when you close mpv instead.
| Action | Result |
| ------------------------------------------------------------ | ----------------------------------- |
| Click a video | Play it now |
| Middle-click, or `Shift`/`Ctrl`+click | Add it to the end of mpv's playlist |
| Right-click a video | **Play in mpv** or **Queue in mpv** |
| `Alt+Left` / `Alt+Right`, mouse back/forward, or right-click | Go back or forward |
Queued videos get their subtitles loaded when mpv reaches them. Open the queue with the playlist browser (`Ctrl+Alt+P`) to reorder or skip entries.
If Google refuses the sign-in, try again once. The window uses a standard Chrome user agent, but Google can still block embedded browsers.
## Secondary subtitle languages
YouTube secondary selection is fixed to English. `secondarySub.secondarySubLanguages` and `secondarySub.autoLoadSecondarySub` apply only to local files and Jellyfin. `secondarySub.defaultMode` still controls how the secondary bar is shown. Use the picker to load a different secondary language.
@@ -56,6 +91,8 @@ See [Configuration](/configuration#youtube-playback-settings) for all `youtube`
**Poor subtitle quality.** Auto-generated captions are often inaccurate. SubMiner uses a manual track when one exists.
**Subtitles fail with HTTP 429.** YouTube is refusing caption requests from your network. It can last hours or days, and waiting or signing in does not always help. [Generate them with Whisper](#generate-subtitles-with-whisper) instead.
A missing or failed secondary track never blocks playback.
## Stats
+14
View File
@@ -4,6 +4,7 @@ import {
launchTexthookerOnly,
runAppCommandWithInherit,
} from '../mpv.js';
import { shouldForwardLogLevel } from '../types.js';
import type { LauncherCommandContext } from './context.js';
type AppCommandDeps = {
@@ -12,6 +13,10 @@ type AppCommandDeps = {
appPath: string,
logLevel: LauncherCommandContext['args']['logLevel'],
) => void;
launchYoutubeBrowserDetached: (
appPath: string,
logLevel: LauncherCommandContext['args']['logLevel'],
) => void;
launchAppBackgroundDetached: (
appPath: string,
logLevel: LauncherCommandContext['args']['logLevel'],
@@ -22,6 +27,11 @@ const defaultAppCommandDeps: AppCommandDeps = {
runAppCommandWithInherit,
launchSyncUiDetached: (appPath, logLevel) =>
launchAppCommandDetached(appPath, ['--sync-window'], logLevel, 'sync-ui'),
launchYoutubeBrowserDetached: (appPath, logLevel) => {
const appArgs = ['--youtube-browser'];
if (shouldForwardLogLevel(logLevel)) appArgs.push('--log-level', logLevel);
launchAppCommandDetached(appPath, appArgs, logLevel, 'youtube-browser');
},
launchAppBackgroundDetached,
};
@@ -41,6 +51,10 @@ export function runAppPassthroughCommand(
deps.launchSyncUiDetached(appPath, args.logLevel);
return true;
}
if (args.youtubeBrowser) {
deps.launchYoutubeBrowserDetached(appPath, args.logLevel);
return true;
}
if (!args.appPassthrough) {
return false;
}
+21
View File
@@ -207,6 +207,7 @@ test('app command starts default macOS background app detached from launcher', (
calls.push('attached');
},
launchSyncUiDetached: () => calls.push('sync-ui'),
launchYoutubeBrowserDetached: () => calls.push('youtube'),
launchAppBackgroundDetached: (appPath, logLevel) => {
calls.push(`detached:${appPath}:${logLevel}`);
},
@@ -227,6 +228,7 @@ test('app command starts default Linux background app detached from launcher', (
calls.push('attached');
},
launchSyncUiDetached: () => calls.push('sync-ui'),
launchYoutubeBrowserDetached: () => calls.push('youtube'),
launchAppBackgroundDetached: (appPath, logLevel) => {
calls.push(`detached:${appPath}:${logLevel}`);
},
@@ -248,6 +250,7 @@ test('app command keeps explicit passthrough args attached', () => {
forwarded.push(appArgs);
},
launchSyncUiDetached: () => detached.push('sync-ui'),
launchYoutubeBrowserDetached: () => detached.push('youtube'),
launchAppBackgroundDetached: () => {
detached.push('detached');
},
@@ -266,6 +269,7 @@ test('sync UI command launches the app detached from the terminal', () => {
const handled = runAppPassthroughCommand(context, {
runAppCommandWithInherit: () => calls.push('piped'),
launchSyncUiDetached: (appPath, logLevel) => calls.push(`sync-ui:${appPath}:${logLevel}`),
launchYoutubeBrowserDetached: () => calls.push('youtube'),
launchAppBackgroundDetached: () => calls.push('detached'),
});
@@ -273,6 +277,23 @@ test('sync UI command launches the app detached from the terminal', () => {
assert.deepEqual(calls, ['sync-ui:/tmp/subminer.app:warn']);
});
test('youtube command launches the YouTube browser detached from the terminal', () => {
const context = createContext();
context.args.youtubeBrowser = true;
const calls: string[] = [];
const handled = runAppPassthroughCommand(context, {
runAppCommandWithInherit: () => calls.push('piped'),
launchSyncUiDetached: () => calls.push('sync-ui'),
launchYoutubeBrowserDetached: (appPath, logLevel) =>
calls.push(`youtube:${appPath}:${logLevel}`),
launchAppBackgroundDetached: () => calls.push('detached'),
});
assert.equal(handled, true);
assert.deepEqual(calls, ['youtube:/tmp/subminer.app:warn']);
});
test('mpv pre-app command exits non-zero when socket is not ready', async () => {
const context = createContext();
context.args.mpvStatus = true;
+2 -12
View File
@@ -16,20 +16,9 @@ function createContext(): LauncherCommandContext {
recursive: false,
profile: '',
startOverlay: false,
youtubeMode: 'download',
whisperBin: '',
whisperModel: '',
whisperVadModel: '',
whisperThreads: 0,
youtubeSubgenOutDir: '',
youtubeSubgenAudioFormat: '',
youtubeSubgenKeepTemp: false,
youtubeFixWithAi: false,
youtubePrimarySubLangs: [],
youtubeSecondarySubLangs: [],
youtubeAudioLangs: [],
youtubeWhisperSourceLanguage: '',
aiConfig: {},
useTexthooker: false,
autoStartOverlay: false,
texthookerOnly: false,
@@ -63,6 +52,7 @@ function createContext(): LauncherCommandContext {
logsExport: false,
version: false,
settings: false,
youtubeBrowser: false,
configPath: false,
configShow: false,
mpvIdle: false,
@@ -162,7 +152,7 @@ test('youtube playback launches overlay with app-owned youtube flow args', async
assert.deepEqual(calls, [
'startMpv',
'startOverlay:--youtube-play https://www.youtube.com/watch?v=65Ovd7t8sNw --youtube-mode download',
'startOverlay:--youtube-play https://www.youtube.com/watch?v=65Ovd7t8sNw',
]);
assert.equal(receivedStartMpvOptions[0]?.startPaused, true);
assert.equal(receivedStartMpvOptions[0]?.disableYoutubeSubtitleAutoLoad, true);
+1 -2
View File
@@ -246,7 +246,6 @@ export async function runPlaybackCommandWithDeps(
const isYoutubeUrl = selectedTarget.kind === 'url' && isYoutubeTarget(selectedTarget.target);
const isAppOwnedYoutubeFlow = isYoutubeUrl;
const youtubeMode = args.youtubeMode ?? 'download';
const configDir = getLauncherConfigDir();
if (isYoutubeUrl) {
@@ -331,7 +330,7 @@ export async function runPlaybackCommandWithDeps(
);
}
const extraAppArgs = isAppOwnedYoutubeFlow
? ['--youtube-play', selectedTarget.target, '--youtube-mode', youtubeMode]
? ['--youtube-play', selectedTarget.target]
: shouldLauncherAttachRunningApp
? [
pluginRuntimeConfig.autoStartVisibleOverlay
-27
View File
@@ -12,25 +12,6 @@ import { getDefaultSocketPath } from './types.js';
test('parseLauncherYoutubeSubgenConfig keeps only valid typed values', () => {
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: {
whisperBin: '/usr/bin/whisper',
whisperModel: '/models/base.bin',
whisperVadModel: '/models/vad.bin',
whisperThreads: 6.8,
fixWithAi: true,
ai: {
model: 'openrouter/subgen-model',
systemPrompt: 'Fix subtitles only.',
},
},
youtube: {
primarySubLanguages: ['ja', 42, 'en'],
},
@@ -49,14 +30,6 @@ test('parseLauncherYoutubeSubgenConfig keeps only valid typed values', () => {
assert.equal('mode' in parsed, false);
assert.deepEqual(parsed.primarySubLanguages, ['ja', 'en']);
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.jimakuMaxEntryResults, 8);
});
+5 -15
View File
@@ -19,30 +19,16 @@ function withTempDir<T>(fn: (dir: string) => T): T {
}
}
test('createDefaultArgs normalizes configured language codes and env thread override', () => {
const originalThreads = process.env.SUBMINER_WHISPER_THREADS;
process.env.SUBMINER_WHISPER_THREADS = '7';
try {
test('createDefaultArgs normalizes configured language codes', () => {
const parsed = createDefaultArgs({
primarySubLanguages: [' JA ', 'jpn', 'ja'],
secondarySubLanguages: ['en', 'ENG', ''],
whisperThreads: 2,
});
assert.deepEqual(parsed.youtubePrimarySubLangs, ['ja', 'jpn']);
assert.deepEqual(parsed.youtubeSecondarySubLangs, ['en', 'eng']);
assert.deepEqual(parsed.youtubeAudioLangs, ['ja', 'jpn', 'en', 'eng']);
assert.equal(parsed.whisperThreads, 7);
assert.equal(parsed.youtubeWhisperSourceLanguage, 'ja');
assert.equal(parsed.profile, '');
} finally {
if (originalThreads === undefined) {
delete process.env.SUBMINER_WHISPER_THREADS;
} else {
process.env.SUBMINER_WHISPER_THREADS = originalThreads;
}
}
});
test('createDefaultArgs seeds mpv profile from launcher config', () => {
@@ -120,6 +106,7 @@ test('applyInvocationsToArgs maps config and jellyfin invocation state', () => {
logLevel: 'warn',
},
settingsInvocation: null,
youtubeBrowserInvocation: null,
mpvInvocation: null,
appInvocation: null,
dictionaryTriggered: false,
@@ -174,6 +161,7 @@ test('applyInvocationsToArgs maps settings invocation to settings window', () =>
settingsInvocation: {
logLevel: undefined,
},
youtubeBrowserInvocation: null,
mpvInvocation: null,
appInvocation: null,
dictionaryTriggered: false,
@@ -221,6 +209,7 @@ test('applyInvocationsToArgs fails when config invocation has no action', () =>
action: undefined,
},
settingsInvocation: null,
youtubeBrowserInvocation: null,
mpvInvocation: null,
appInvocation: null,
dictionaryTriggered: false,
@@ -266,6 +255,7 @@ test('applyInvocationsToArgs maps texthooker browser-open request', () => {
jellyfinInvocation: null,
configInvocation: null,
settingsInvocation: null,
youtubeBrowserInvocation: null,
mpvInvocation: null,
appInvocation: null,
dictionaryTriggered: false,
+9 -29
View File
@@ -13,14 +13,8 @@ import {
DEFAULT_JIMAKU_API_BASE_URL,
DEFAULT_YOUTUBE_PRIMARY_SUB_LANGS,
DEFAULT_YOUTUBE_SECONDARY_SUB_LANGS,
DEFAULT_YOUTUBE_SUBGEN_OUT_DIR,
} from '../types.js';
import {
inferWhisperLanguage,
isUrlTarget,
resolvePathMaybe,
uniqueNormalizedLangCodes,
} from '../util.js';
import { isUrlTarget, resolvePathMaybe, uniqueNormalizedLangCodes } from '../util.js';
import type { CliInvocations } from './cli-parser-builder.js';
function ensureTarget(target: string, parsed: Args): void {
@@ -131,18 +125,6 @@ export function createDefaultArgs(
recursive: false,
profile: mpvConfig.profile ?? '',
startOverlay: false,
whisperBin: process.env.SUBMINER_WHISPER_BIN || launcherConfig.whisperBin || '',
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,
youtubeSubgenAudioFormat: process.env.SUBMINER_YT_SUBGEN_AUDIO_FORMAT || 'm4a',
youtubeSubgenKeepTemp: process.env.SUBMINER_YT_SUBGEN_KEEP_TEMP === '1',
youtubeFixWithAi: launcherConfig.fixWithAi === true,
jimakuApiKey: process.env.SUBMINER_JIMAKU_API_KEY || '',
jimakuApiKeyCommand: process.env.SUBMINER_JIMAKU_API_KEY_COMMAND || '',
jimakuApiBaseUrl: process.env.SUBMINER_JIMAKU_API_BASE_URL || DEFAULT_JIMAKU_API_BASE_URL,
@@ -170,6 +152,7 @@ export function createDefaultArgs(
version: false,
update: false,
settings: false,
youtubeBrowser: false,
configPath: false,
configShow: false,
mpvIdle: false,
@@ -185,16 +168,6 @@ export function createDefaultArgs(
youtubePrimarySubLangs: primarySubLangs,
youtubeSecondarySubLangs: secondarySubLangs,
youtubeAudioLangs,
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,
autoStartOverlay: false,
texthookerOnly: false,
@@ -367,6 +340,13 @@ export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations
parsed.settings = true;
}
if (invocations.youtubeBrowserInvocation) {
if (invocations.youtubeBrowserInvocation.logLevel) {
parsed.logLevel = parseLogLevel(invocations.youtubeBrowserInvocation.logLevel);
}
parsed.youtubeBrowser = true;
}
if (invocations.mpvInvocation) {
if (invocations.mpvInvocation.logLevel) {
parsed.logLevel = parseLogLevel(invocations.mpvInvocation.logLevel);
+16
View File
@@ -29,6 +29,7 @@ export interface CliInvocations {
jellyfinInvocation: JellyfinInvocation | null;
configInvocation: CommandActionInvocation | null;
settingsInvocation: CommandActionInvocation | null;
youtubeBrowserInvocation: CommandActionInvocation | null;
mpvInvocation: CommandActionInvocation | null;
appInvocation: { appArgs: string[] } | null;
dictionaryTriggered: boolean;
@@ -123,6 +124,8 @@ function getTopLevelCommand(argv: string[]): { name: string; index: number } | n
'doctor',
'config',
'settings',
'youtube',
'yt',
'mpv',
'logs',
'dictionary',
@@ -177,6 +180,7 @@ export function parseCliPrograms(
let jellyfinInvocation: JellyfinInvocation | null = null;
let configInvocation: CommandActionInvocation | null = null;
let settingsInvocation: CommandActionInvocation | null = null;
let youtubeBrowserInvocation: CommandActionInvocation | null = null;
let mpvInvocation: CommandActionInvocation | null = null;
let appInvocation: { appArgs: string[] } | null = null;
let dictionaryTriggered = false;
@@ -514,6 +518,17 @@ export function parseCliPrograms(
};
});
commandProgram
.command('youtube')
.alias('yt')
.description('Open the YouTube browser (videos play in mpv)')
.option('--log-level <level>', 'Log level')
.action((options: Record<string, unknown>) => {
youtubeBrowserInvocation = {
logLevel: typeof options.logLevel === 'string' ? options.logLevel : undefined,
};
});
commandProgram
.command('mpv')
.description('MPV helpers')
@@ -569,6 +584,7 @@ export function parseCliPrograms(
jellyfinInvocation,
configInvocation,
settingsInvocation,
youtubeBrowserInvocation,
mpvInvocation,
appInvocation,
dictionaryTriggered,
-53
View File
@@ -1,5 +1,4 @@
import type { LauncherYoutubeSubgenConfig } from '../types.js';
import { mergeAiConfig } from '../../src/ai/config.js';
function asStringArray(value: unknown): string[] | undefined {
if (!Array.isArray(value)) return undefined;
@@ -9,11 +8,6 @@ function asStringArray(value: unknown): string[] | undefined {
export function parseLauncherYoutubeSubgenConfig(
root: Record<string, unknown>,
): LauncherYoutubeSubgenConfig {
const youtubeSubgenRaw = root.youtubeSubgen;
const youtubeSubgen =
youtubeSubgenRaw && typeof youtubeSubgenRaw === 'object'
? (youtubeSubgenRaw as Record<string, unknown>)
: null;
const youtubeRaw = root.youtube;
const youtube =
youtubeRaw && typeof youtubeRaw === 'object' ? (youtubeRaw as Record<string, unknown>) : null;
@@ -25,58 +19,11 @@ export function parseLauncherYoutubeSubgenConfig(
const jimakuRaw = root.jimaku;
const jimaku =
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 jimakuLanguagePreference = jimaku?.languagePreference;
const jimakuMaxEntryResults = jimaku?.maxEntryResults;
return {
whisperBin:
typeof youtubeSubgen?.whisperBin === 'string' ? youtubeSubgen.whisperBin : undefined,
whisperModel:
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(youtube?.primarySubLanguages),
secondarySubLanguages: asStringArray(secondarySub?.secondarySubLanguages),
jimakuApiKey: typeof jimaku?.apiKey === 'string' ? jimaku.apiKey : undefined,
+1 -11
View File
@@ -10,20 +10,9 @@ function createArgs(): Args {
recursive: false,
profile: '',
startOverlay: false,
youtubeMode: 'download',
whisperBin: '',
whisperModel: '',
whisperVadModel: '',
whisperThreads: 0,
youtubeSubgenOutDir: '',
youtubeSubgenAudioFormat: '',
youtubeSubgenKeepTemp: false,
youtubeFixWithAi: false,
youtubePrimarySubLangs: [],
youtubeSecondarySubLangs: [],
youtubeAudioLangs: [],
youtubeWhisperSourceLanguage: '',
aiConfig: {},
useTexthooker: false,
autoStartOverlay: false,
texthookerOnly: false,
@@ -57,6 +46,7 @@ function createArgs(): Args {
logsExport: false,
version: false,
settings: false,
youtubeBrowser: false,
configPath: false,
configShow: false,
mpvIdle: false,
+21
View File
@@ -332,6 +332,27 @@ test('launcher settings command forwards app settings window command', () => {
});
});
test('launcher youtube command forwards the YouTube browser flag and log level', () => {
withTempDir((root) => {
const homeDir = path.join(root, 'home');
const xdgConfigHome = path.join(root, 'xdg');
const appPath = path.join(root, 'fake-subminer.sh');
const capturePath = path.join(root, 'captured-args.txt');
fs.writeFileSync(appPath, `#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}exit 0\n`);
fs.chmodSync(appPath, 0o755);
const env = {
...makeTestEnv(homeDir, xdgConfigHome),
SUBMINER_APPIMAGE_PATH: appPath,
SUBMINER_TEST_CAPTURE: capturePath,
};
const result = runLauncher(['yt', '--log-level', 'debug'], env);
assert.equal(result.status, 0);
assert.equal(fs.readFileSync(capturePath, 'utf8'), '--youtube-browser\n--log-level\ndebug\n');
});
});
test('launcher settings command suppresses known Electron macOS menu diagnostics', () => {
withTempDir((root) => {
const homeDir = path.join(root, 'home');
+2 -20
View File
@@ -581,19 +581,9 @@ function makeArgs(overrides: Partial<Args> = {}): Args {
recursive: false,
profile: '',
startOverlay: false,
whisperBin: '',
whisperModel: '',
whisperVadModel: '',
whisperThreads: 4,
youtubeSubgenOutDir: '',
youtubeSubgenAudioFormat: 'wav',
youtubeSubgenKeepTemp: false,
youtubeFixWithAi: false,
youtubePrimarySubLangs: [],
youtubeSecondarySubLangs: [],
youtubeAudioLangs: [],
youtubeWhisperSourceLanguage: 'ja',
aiConfig: {},
useTexthooker: false,
autoStartOverlay: false,
texthookerOnly: false,
@@ -627,6 +617,7 @@ function makeArgs(overrides: Partial<Args> = {}): Args {
logsExport: false,
version: false,
settings: false,
youtubeBrowser: false,
configPath: false,
configShow: false,
mpvIdle: false,
@@ -1084,30 +1075,21 @@ test('cleanupPlaybackSession stops launcher-managed overlay app and mpv-owned ch
return true;
},
} as unknown as NonNullable<typeof state.mpvProc>;
const helperProc = {
killed: false,
kill: () => {
calls.push('helper-kill');
return true;
},
} as unknown as NonNullable<typeof state.overlayProc>;
state.stopRequested = false;
state.appPath = appPath;
state.overlayManagedByLauncher = true;
state.overlayProc = overlayProc;
state.mpvProc = mpvProc;
state.youtubeSubgenChildren.add(helperProc);
try {
await cleanupPlaybackSession(makeArgs());
assert.deepEqual(calls, ['overlay-kill', 'mpv-kill', 'helper-kill']);
assert.deepEqual(calls, ['overlay-kill', 'mpv-kill']);
assert.match(fs.readFileSync(appInvocationsPath, 'utf8'), /--stop/);
} finally {
state.overlayProc = null;
state.mpvProc = null;
state.youtubeSubgenChildren.clear();
state.overlayManagedByLauncher = false;
state.appPath = '';
state.stopRequested = false;
-23
View File
@@ -47,7 +47,6 @@ import {
export const state = {
overlayProc: null as ReturnType<typeof spawn> | null,
mpvProc: null as ReturnType<typeof spawn> | null,
youtubeSubgenChildren: new Set<ReturnType<typeof spawn>>(),
appPath: '' as string,
overlayManagedByLauncher: false,
stopRequested: false,
@@ -1186,17 +1185,6 @@ export function stopOverlay(args: Args): void {
}
}
for (const child of state.youtubeSubgenChildren) {
if (!child.killed) {
try {
child.kill('SIGTERM');
} catch {
// ignore
}
}
}
state.youtubeSubgenChildren.clear();
void terminateTrackedDetachedMpv(args.logLevel);
}
@@ -1211,17 +1199,6 @@ export async function cleanupPlaybackSession(args: Args): Promise<void> {
}
}
for (const child of state.youtubeSubgenChildren) {
if (!child.killed) {
try {
child.kill('SIGTERM');
} catch {
// ignore
}
}
}
state.youtubeSubgenChildren.clear();
await terminateTrackedDetachedMpv(args.logLevel);
}
+9
View File
@@ -107,6 +107,15 @@ test('parseArgs maps settings command to settings window', () => {
assert.equal(parsed.configShow, false);
});
test('parseArgs maps youtube command and yt alias to the YouTube browser', () => {
assert.equal(parseArgs(['youtube'], 'subminer', {}).youtubeBrowser, true);
const alias = parseArgs(['yt', '--log-level', 'debug'], 'subminer', {});
assert.equal(alias.youtubeBrowser, true);
assert.equal(alias.logLevel, 'debug');
assert.equal(alias.target, '');
});
test('parseArgs maps config path action to config path output', () => {
const parsed = parseArgs(['config', 'path'], 'subminer', {});
+1 -42
View File
@@ -1,4 +1,3 @@
import path from 'node:path';
import os from 'node:os';
import type { MpvBackend, MpvLaunchMode } from '../src/types/config.js';
import type { SubtitleGenerationConfig } from '../src/shared/subtitle-generation.js';
@@ -27,12 +26,6 @@ export const YOUTUBE_AUDIO_EXTENSIONS = new Set([
'.aac',
'.flac',
]);
export const DEFAULT_YOUTUBE_SUBGEN_OUT_DIR = path.join(
os.homedir(),
'.cache',
'subminer',
'youtube-subs',
);
export function getDefaultLauncherLogFile(options?: {
platform?: NodeJS.Platform;
homeDir?: string;
@@ -78,16 +71,6 @@ export function shouldForwardLogLevel(level: LogLevel): boolean {
export type Backend = 'auto' | 'hyprland' | 'sway' | 'x11' | 'macos' | 'windows';
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 {
generateSubtitles?: {
mediaPath?: string;
@@ -102,20 +85,9 @@ export interface Args {
recursive: boolean;
profile: string;
startOverlay: boolean;
youtubeMode?: 'download' | 'generate';
whisperBin: string;
whisperModel: string;
whisperVadModel: string;
whisperThreads: number;
youtubeSubgenOutDir: string;
youtubeSubgenAudioFormat: string;
youtubeSubgenKeepTemp: boolean;
youtubeFixWithAi: boolean;
youtubePrimarySubLangs: string[];
youtubeSecondarySubLangs: string[];
youtubeAudioLangs: string[];
youtubeWhisperSourceLanguage: string;
aiConfig: LauncherAiConfig;
useTexthooker: boolean;
autoStartOverlay: boolean;
texthookerOnly: boolean;
@@ -161,6 +133,7 @@ export interface Args {
version: boolean;
update?: boolean;
settings: boolean;
youtubeBrowser: boolean;
configPath: boolean;
configShow: boolean;
mpvIdle: boolean;
@@ -176,12 +149,6 @@ export interface Args {
}
export interface LauncherYoutubeSubgenConfig {
whisperBin?: string;
whisperModel?: string;
whisperVadModel?: string;
whisperThreads?: number;
fixWithAi?: boolean;
ai?: LauncherAiConfig;
primarySubLanguages?: string[];
secondarySubLanguages?: string[];
jimakuApiKey?: string;
@@ -252,14 +219,6 @@ export interface SubtitleCandidate {
source: 'manual' | 'whisper' | 'whisper-fixed' | 'whisper-translate' | 'whisper-translate-fixed';
}
export interface YoutubeSubgenOutputs {
basename: string;
primaryPath?: string;
secondaryPath?: string;
primaryNative?: boolean;
secondaryNative?: boolean;
}
export interface MpvTrack {
type?: string;
id?: number;
-8
View File
@@ -236,14 +236,6 @@ export function parseBoolLike(value: string): boolean | null {
return null;
}
export function inferWhisperLanguage(langCodes: string[], fallback: string): string {
for (const lang of uniqueNormalizedLangCodes(langCodes)) {
if (lang === 'jpn') return 'ja';
if (lang.length >= 2) return lang.slice(0, 2);
}
return fallback;
}
export interface CommandInvocationOptions {
normalizeWindowsShellArgs?: boolean;
}
+3
View File
@@ -43,6 +43,9 @@ function M.create(ctx)
mp.register_script_message("subminer-autoplay-ready", function()
process.notify_auto_play_ready()
end)
mp.register_script_message("subminer-autoplay-hold", function()
process.hold_auto_play_ready_gate()
end)
mp.register_script_message("subminer-overlay-loading-ready", function()
process.stop_overlay_loading_osd()
end)
+12
View File
@@ -351,6 +351,17 @@ function M.create(ctx)
return true
end
-- The app is running a long managed subtitle load (Whisper generation): drop the timeout so it
-- cannot resume playback early, but stay armed so the app's ready signal still releases it.
local function hold_auto_play_ready_gate()
if not state.auto_play_ready_gate_armed then
return
end
clear_auto_play_ready_timeout()
clear_auto_play_ready_osd_timer()
subminer_log("info", "process", "Holding pause-until-ready for app-managed subtitle loading")
end
local function arm_auto_play_ready_gate()
local was_armed = state.auto_play_ready_gate_armed
if was_armed then
@@ -1020,6 +1031,7 @@ function M.create(ctx)
check_status = check_status,
check_binary_available = check_binary_available,
notify_auto_play_ready = notify_auto_play_ready,
hold_auto_play_ready_gate = hold_auto_play_ready_gate,
disarm_auto_play_ready_gate = disarm_auto_play_ready_gate,
start_overlay_loading_osd = start_overlay_loading_osd,
stop_overlay_loading_osd = stop_overlay_loading_osd,
+1
View File
@@ -15,6 +15,7 @@ const REQUIRED_APP_FILES = [
'dist/preload-syncui.js',
'dist/preload-stats.js',
'dist/preload-jellyfin-setup.js',
'dist/preload-youtube-browser.js',
'dist/fonts/MPLUS1[wght].ttf',
'stats/dist/index.html',
'vendor/texthooker-ui/docs/index.html',
+37
View File
@@ -2476,4 +2476,41 @@ do
)
end
do
local recorded, err = run_plugin_scenario({
process_list = "",
defer_timeouts = true,
option_overrides = {
binary_path = binary_path,
auto_start = "yes",
auto_start_visible_overlay = "yes",
auto_start_pause_until_ready = "yes",
socket_path = "/tmp/subminer-socket",
},
input_ipc_server = "/tmp/subminer-socket",
path = "https://www.youtube.com/watch?v=abcdefghijk",
media_title = "Whisper video",
files = {
[binary_path] = true,
},
})
assert_true(recorded ~= nil, "plugin failed to load for autoplay hold scenario: " .. tostring(err))
fire_event(recorded, "file-loaded")
assert_true(
count_property_set(recorded.property_sets, "pause", true) == 1,
"pause-until-ready should pause playback on load"
)
recorded.script_messages["subminer-autoplay-hold"]()
fire_pending_timeouts(recorded)
assert_true(
count_property_set(recorded.property_sets, "pause", false) == 0,
"a held gate should not resume playback when its timeout would have fired"
)
recorded.script_messages["subminer-autoplay-ready"]()
assert_true(
count_property_set(recorded.property_sets, "pause", false) == 1,
"the ready signal should still release a held gate"
)
end
print("plugin start gate regression tests: OK")
+8 -7
View File
@@ -98,15 +98,9 @@ test('parseArgs captures launch-mpv targets and keeps it out of app startup', ()
});
test('parseArgs captures youtube startup forwarding flags', () => {
const args = parseArgs([
'--youtube-play',
'https://youtube.com/watch?v=abc',
'--youtube-mode',
'generate',
]);
const args = parseArgs(['--youtube-play', 'https://youtube.com/watch?v=abc']);
assert.equal(args.youtubePlay, 'https://youtube.com/watch?v=abc');
assert.equal(args.youtubeMode, 'generate');
assert.equal(hasExplicitCommand(args), true);
assert.equal(shouldStartApp(args), true);
});
@@ -267,6 +261,13 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
assert.equal(commandNeedsOverlayRuntime(settings), false);
assert.equal(commandNeedsOverlayStartupPrereqs(settings), false);
const youtubeBrowser = parseArgs(['--youtube-browser']);
assert.equal(youtubeBrowser.youtubeBrowser, true);
assert.equal(hasExplicitCommand(youtubeBrowser), true);
assert.equal(shouldStartApp(youtubeBrowser), true);
assert.equal(shouldRunYomitanOnlyStartup(youtubeBrowser), false);
assert.equal(commandNeedsOverlayRuntime(youtubeBrowser), false);
const yomitanWithOverlay = parseArgs(['--yomitan', '--toggle-visible-overlay']);
assert.equal(yomitanWithOverlay.yomitan, true);
assert.equal(yomitanWithOverlay.toggleVisibleOverlay, true);
+7 -8
View File
@@ -7,7 +7,6 @@ export interface CliArgs {
launchMpv: boolean;
launchMpvTargets: string[];
youtubePlay?: string;
youtubeMode?: 'download' | 'generate';
stop: boolean;
toggle: boolean;
toggleVisibleOverlay: boolean;
@@ -16,6 +15,7 @@ export interface CliArgs {
hachidori: boolean;
settings: boolean;
syncWindow: boolean;
youtubeBrowser: boolean;
setup: boolean;
show: boolean;
hide: boolean;
@@ -129,7 +129,6 @@ export function parseArgs(argv: string[]): CliArgs {
launchMpv: false,
launchMpvTargets: [],
youtubePlay: undefined,
youtubeMode: undefined,
stop: false,
toggle: false,
toggleVisibleOverlay: false,
@@ -138,6 +137,7 @@ export function parseArgs(argv: string[]): CliArgs {
hachidori: false,
settings: false,
syncWindow: false,
youtubeBrowser: false,
setup: false,
show: false,
hide: false,
@@ -272,12 +272,6 @@ export function parseArgs(argv: string[]): CliArgs {
} else if (arg === '--youtube-play') {
const value = readValue(argv[i + 1]);
if (value) args.youtubePlay = value;
} else if (arg.startsWith('--youtube-mode=')) {
const value = arg.split('=', 2)[1];
if (value === 'download' || value === 'generate') args.youtubeMode = value;
} else if (arg === '--youtube-mode') {
const value = readValue(argv[i + 1]);
if (value === 'download' || value === 'generate') args.youtubeMode = value;
} else if (arg === '--launch-mpv') {
args.launchMpv = true;
args.launchMpvTargets = argv.slice(i + 1).filter((value) => value && !value.startsWith('--'));
@@ -290,6 +284,7 @@ export function parseArgs(argv: string[]): CliArgs {
else if (arg === '--hachidori') args.hachidori = true;
else if (arg === '--settings') args.settings = true;
else if (arg === '--sync-window') args.syncWindow = true;
else if (arg === '--youtube-browser') args.youtubeBrowser = true;
else if (arg === '--setup') args.setup = true;
else if (arg === '--show') args.show = true;
else if (arg === '--hide') args.hide = true;
@@ -574,6 +569,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
args.hachidori ||
args.settings ||
args.syncWindow ||
args.youtubeBrowser ||
args.setup ||
args.show ||
args.hide ||
@@ -654,6 +650,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
!args.hachidori &&
!args.settings &&
!args.syncWindow &&
!args.youtubeBrowser &&
!args.setup &&
!args.show &&
!args.hide &&
@@ -727,6 +724,7 @@ export function shouldStartApp(args: CliArgs): boolean {
args.hachidori ||
args.settings ||
args.syncWindow ||
args.youtubeBrowser ||
args.setup ||
args.copySubtitle ||
args.copySubtitleMultiple ||
@@ -789,6 +787,7 @@ export function shouldRunDictionarySettingsOnlyStartup(args: CliArgs): boolean {
!args.togglePrimarySubtitleBar &&
!args.settings &&
!args.syncWindow &&
!args.youtubeBrowser &&
!args.show &&
!args.hide &&
!args.setup &&
+3
View File
@@ -80,6 +80,9 @@ ${B}Jellyfin${R}
--jellyfin-audio-stream-index ${D}N${R} Audio stream override
--jellyfin-subtitle-stream-index ${D}N${R} Subtitle stream override
${B}YouTube${R}
--youtube-browser Open the YouTube browser ${D}(videos play in mpv)${R}
${B}Stats sync${R}
--sync-window Open the stats sync window
--sync-cli sync ${D}[host] [opts]${R} Headless stats sync ${D}(same commands as "subminer sync";${R}
+24 -16
View File
@@ -1757,11 +1757,6 @@ test('parses global shortcuts and startup settings', () => {
},
"youtube": {
"primarySubLanguages": ["ja", "jpn", "jp"]
},
"youtubeSubgen": {
"whisperVadModel": "/models/vad.bin",
"whisperThreads": 12,
"fixWithAi": true
}
}`,
'utf-8',
@@ -1774,9 +1769,30 @@ test('parses global shortcuts and startup settings', () => {
assert.equal(config.shortcuts.toggleVisibleOverlayGlobal, 'Alt+Shift+U');
assert.equal(config.shortcuts.openJimaku, 'Ctrl+Alt+J');
assert.deepEqual(config.youtube.primarySubLanguages, ['ja', 'jpn', 'jp']);
assert.equal(config.youtubeSubgen.whisperVadModel, '/models/vad.bin');
assert.equal(config.youtubeSubgen.whisperThreads, 12);
assert.equal(config.youtubeSubgen.fixWithAi, true);
});
test('parses YouTube subtitle source and warns on invalid values', () => {
assert.equal(DEFAULT_CONFIG.youtube.subtitleSource, 'youtube');
const validDir = makeTempDir();
fs.writeFileSync(
path.join(validDir, 'config.jsonc'),
`{ "youtube": { "subtitleSource": "whisper" } }`,
'utf-8',
);
assert.equal(new ConfigService(validDir).getConfig().youtube.subtitleSource, 'whisper');
const invalidDir = makeTempDir();
fs.writeFileSync(
path.join(invalidDir, 'config.jsonc'),
`{ "youtube": { "subtitleSource": "asr" } }`,
'utf-8',
);
const invalidService = new ConfigService(invalidDir);
assert.equal(invalidService.getConfig().youtube.subtitleSource, 'youtube');
assert.ok(
invalidService.getWarnings().some((warning) => warning.path === 'youtube.subtitleSource'),
);
});
test('parses YouTube media cache config and warns on invalid values', () => {
@@ -2577,12 +2593,6 @@ test('accepts per-feature ai overrides for anki and YouTube subtitles', () => {
"model": "openrouter/anki-model",
"systemPrompt": "Translate mined sentence text."
}
},
"youtubeSubgen": {
"ai": {
"model": "openrouter/subgen-model",
"systemPrompt": "Fix subtitle mistakes only."
}
}
}`,
'utf-8',
@@ -2596,8 +2606,6 @@ test('accepts per-feature ai overrides for anki and YouTube subtitles', () => {
assert.equal(config.ankiConnect.ai.enabled, true);
assert.equal(config.ankiConnect.ai.model, 'openrouter/anki-model');
assert.equal(config.ankiConnect.ai.systemPrompt, 'Translate mined sentence text.');
assert.equal(config.youtubeSubgen.ai.model, 'openrouter/subgen-model');
assert.equal(config.youtubeSubgen.ai.systemPrompt, 'Fix subtitle mistakes only.');
});
test('warns and falls back when ankiConnect.ai override values are invalid', () => {
-2
View File
@@ -51,7 +51,6 @@ const {
jellyfin,
discordPresence,
ai,
youtubeSubgen,
} = INTEGRATIONS_DEFAULT_CONFIG;
const { subtitleStyle, subtitleSidebar } = SUBTITLE_DEFAULT_CONFIG;
const { immersionTracking } = IMMERSION_DEFAULT_CONFIG;
@@ -89,7 +88,6 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
jellyfin,
discordPresence,
ai,
youtubeSubgen,
immersionTracking,
stats,
};
+1
View File
@@ -119,6 +119,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
},
youtube: {
primarySubLanguages: ['ja', 'jpn'],
subtitleSource: 'youtube',
mediaCache: {
mode: 'direct',
maxHeight: 720,
@@ -13,7 +13,6 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
| 'jellyfin'
| 'discordPresence'
| 'ai'
| 'youtubeSubgen'
> = {
ankiConnect: {
enabled: true,
@@ -179,15 +178,4 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
'You are a translation engine. Return only the translated text with no explanations.',
requestTimeoutMs: 15_000,
},
youtubeSubgen: {
whisperBin: '',
whisperModel: '',
whisperVadModel: '',
whisperThreads: 4,
fixWithAi: false,
ai: {
model: '',
systemPrompt: '',
},
},
};
@@ -85,13 +85,6 @@ const UNDOCUMENTED_LEAVES: ReadonlySet<string> = new Set([
'subtitleStyle.textShadow',
'subtitleStyle.WebkitTextStroke',
'subtitleStyle.wordSpacing',
'youtubeSubgen.ai.model',
'youtubeSubgen.ai.systemPrompt',
'youtubeSubgen.fixWithAi',
'youtubeSubgen.whisperBin',
'youtubeSubgen.whisperModel',
'youtubeSubgen.whisperThreads',
'youtubeSubgen.whisperVadModel',
]);
test('config option registry includes critical paths and has unique entries', () => {
+12
View File
@@ -133,6 +133,18 @@ export function buildCoreConfigOptionRegistry(
description:
'Comma-separated primary subtitle language priority for managed subtitle auto-selection.',
},
{
path: 'youtube.subtitleSource',
kind: 'enum',
enumValues: ['youtube', 'whisper'],
enumLabels: {
youtube: 'Download YouTube captions',
whisper: 'Generate with Whisper',
},
defaultValue: defaultConfig.youtube.subtitleSource,
description:
'Where primary YouTube subtitles come from. Whisper transcribes the audio locally using the subtitleGeneration settings.',
},
{
path: 'youtube.mediaCache.mode',
kind: 'enum',
+3 -1
View File
@@ -199,7 +199,9 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
description: [
'Defaults for managed subtitle language preferences and YouTube subtitle loading.',
],
notes: ['Hot-reload: primarySubLanguages applies to the next YouTube subtitle load.'],
notes: [
'Hot-reload: primarySubLanguages and subtitleSource apply to the next YouTube subtitle load.',
],
key: 'youtube',
},
{
+1
View File
@@ -21,6 +21,7 @@ const HOT_RELOAD_EXACT_OR_PREFIX_PATHS = [
'logging.rotation',
'logging.files',
'youtube.primarySubLanguages',
'youtube.subtitleSource',
'ankiConnect.deck',
'ankiConnect.media.normalizeAudio',
'ankiConnect.media.mirrorMpvVolume',
+12
View File
@@ -366,6 +366,18 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
);
}
const subtitleSource = src.youtube.subtitleSource;
if (subtitleSource === 'youtube' || subtitleSource === 'whisper') {
resolved.youtube.subtitleSource = subtitleSource;
} else if (subtitleSource !== undefined) {
warn(
'youtube.subtitleSource',
subtitleSource,
resolved.youtube.subtitleSource,
"Expected 'youtube' or 'whisper'.",
);
}
if (isObject(src.youtube.mediaCache)) {
const mode = src.youtube.mediaCache.mode;
if (mode === 'direct' || mode === 'background') {
-99
View File
@@ -118,105 +118,6 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
}
}
if (isObject(src.youtubeSubgen)) {
const whisperBin = asString(src.youtubeSubgen.whisperBin);
if (whisperBin !== undefined) {
resolved.youtubeSubgen.whisperBin = whisperBin;
} else if (src.youtubeSubgen.whisperBin !== undefined) {
warn(
'youtubeSubgen.whisperBin',
src.youtubeSubgen.whisperBin,
resolved.youtubeSubgen.whisperBin,
'Expected string.',
);
}
const whisperModel = asString(src.youtubeSubgen.whisperModel);
if (whisperModel !== undefined) {
resolved.youtubeSubgen.whisperModel = whisperModel;
} else if (src.youtubeSubgen.whisperModel !== undefined) {
warn(
'youtubeSubgen.whisperModel',
src.youtubeSubgen.whisperModel,
resolved.youtubeSubgen.whisperModel,
'Expected string.',
);
}
const whisperVadModel = asString(src.youtubeSubgen.whisperVadModel);
if (whisperVadModel !== undefined) {
resolved.youtubeSubgen.whisperVadModel = whisperVadModel;
} else if (src.youtubeSubgen.whisperVadModel !== undefined) {
warn(
'youtubeSubgen.whisperVadModel',
src.youtubeSubgen.whisperVadModel,
resolved.youtubeSubgen.whisperVadModel,
'Expected string.',
);
}
const whisperThreads = asNumber(src.youtubeSubgen.whisperThreads);
if (whisperThreads !== undefined && Number.isInteger(whisperThreads) && whisperThreads > 0) {
resolved.youtubeSubgen.whisperThreads = whisperThreads;
} else if (src.youtubeSubgen.whisperThreads !== undefined) {
warn(
'youtubeSubgen.whisperThreads',
src.youtubeSubgen.whisperThreads,
resolved.youtubeSubgen.whisperThreads,
'Expected positive integer.',
);
}
const fixWithAi = asBoolean(src.youtubeSubgen.fixWithAi);
if (fixWithAi !== undefined) {
resolved.youtubeSubgen.fixWithAi = fixWithAi;
} else if (src.youtubeSubgen.fixWithAi !== undefined) {
warn(
'youtubeSubgen.fixWithAi',
src.youtubeSubgen.fixWithAi,
resolved.youtubeSubgen.fixWithAi,
'Expected boolean.',
);
}
if (isObject(src.youtubeSubgen.ai)) {
const aiModel = asString(src.youtubeSubgen.ai.model);
if (aiModel !== undefined) {
resolved.youtubeSubgen.ai.model = aiModel;
} else if (src.youtubeSubgen.ai.model !== undefined) {
warn(
'youtubeSubgen.ai.model',
src.youtubeSubgen.ai.model,
resolved.youtubeSubgen.ai.model,
'Expected string.',
);
}
const aiSystemPrompt = asString(src.youtubeSubgen.ai.systemPrompt);
if (aiSystemPrompt !== undefined) {
resolved.youtubeSubgen.ai.systemPrompt = aiSystemPrompt;
} else if (src.youtubeSubgen.ai.systemPrompt !== undefined) {
warn(
'youtubeSubgen.ai.systemPrompt',
src.youtubeSubgen.ai.systemPrompt,
resolved.youtubeSubgen.ai.systemPrompt,
'Expected string.',
);
}
} else if (src.youtubeSubgen.ai !== undefined) {
warn('youtubeSubgen.ai', src.youtubeSubgen.ai, resolved.youtubeSubgen.ai, 'Expected object.');
}
if (src.youtubeSubgen.primarySubLanguages !== undefined) {
warn(
'youtubeSubgen.primarySubLanguages',
src.youtubeSubgen.primarySubLanguages,
undefined,
'Removed. Use youtube.primarySubLanguages instead.',
);
}
}
if (isObject(src.subtitleStyle)) {
const fallbackSubtitleStyleEnableJlpt = resolved.subtitleStyle.enableJlpt;
const fallbackSubtitleStylePrimaryDefaultMode = resolved.subtitleStyle.primaryDefaultMode;
-1
View File
@@ -328,7 +328,6 @@ test('settings registry hides app-managed and inactive config surfaces', () => {
'controller.preferredGamepadId',
'controller.preferredGamepadLabel',
'controller.profiles',
'youtubeSubgen.whisperBin',
'jellyfin.defaultLibraryId',
'subtitleSidebar.toggleKey',
'jellyfin.recentServers',
+2 -11
View File
@@ -61,7 +61,6 @@ export const LEGACY_HIDDEN_CONFIG_PATHS = [
'controller.preferredGamepadId',
'controller.preferredGamepadLabel',
'controller.profiles',
'youtubeSubgen.primarySubLanguages',
'anilist.characterDictionary.enabled',
'anilist.characterDictionary.refreshTtlHours',
'anilist.characterDictionary.evictionPolicy',
@@ -76,12 +75,7 @@ export const LEGACY_HIDDEN_CONFIG_PATHS = [
'jellyfin.recentServers',
] as const;
const EXCLUDED_PREFIXES = [
'ai',
'ankiConnect.ai',
'controller.buttonIndices',
'youtubeSubgen',
] as const;
const EXCLUDED_PREFIXES = ['ai', 'ankiConnect.ai', 'controller.buttonIndices'] as const;
const JSON_OBJECT_FIELDS = new Set([
'keybindings',
@@ -529,7 +523,6 @@ function topSection(path: string): string {
websocket: 'WebSocket server',
yomitan: 'Yomitan',
youtube: 'YouTube Playback Settings',
youtubeSubgen: 'YouTube subtitle generation',
auto_start_overlay: 'Playback Behavior',
};
return labels[top] ?? humanizePath(top);
@@ -729,9 +722,7 @@ function fieldForLeaf(leaf: Leaf): ConfigSettingsField {
...(option?.enumLabels ? { enumLabels: option.enumLabels } : {}),
restartBehavior: getConfigHotReloadField(leaf.path) ? 'hot-reload' : 'restart',
advanced:
leaf.path.startsWith('controller.') ||
leaf.path.startsWith('immersionTracking.retention.') ||
leaf.path.startsWith('youtubeSubgen.'),
leaf.path.startsWith('controller.') || leaf.path.startsWith('immersionTracking.retention.'),
secret: SECRET_PATHS.has(leaf.path),
settingsHidden: SUBTITLE_CSS_MANAGED_CONFIG_PATHS.has(leaf.path),
};
-7
View File
@@ -21,13 +21,6 @@ const SUBTITLE_CSS_SCOPES: SubtitleCssScope[] = ['primary', 'secondary', 'sideba
const HIDDEN_TEMPLATE_PATHS = [
'anilist.characterDictionary.evictionPolicy',
'anilist.characterDictionary.refreshTtlHours',
'youtubeSubgen.ai.model',
'youtubeSubgen.ai.systemPrompt',
'youtubeSubgen.fixWithAi',
'youtubeSubgen.whisperBin',
'youtubeSubgen.whisperModel',
'youtubeSubgen.whisperThreads',
'youtubeSubgen.whisperVadModel',
];
function normalizeCommentText(value: string): string {
+1
View File
@@ -18,6 +18,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
hachidori: false,
settings: false,
syncWindow: false,
youtubeBrowser: false,
setup: false,
show: false,
hide: false,
+17 -31
View File
@@ -15,7 +15,6 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
launchMpv: false,
launchMpvTargets: [],
youtubePlay: undefined,
youtubeMode: undefined,
stop: false,
toggle: false,
toggleVisibleOverlay: false,
@@ -23,6 +22,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
hachidori: false,
settings: false,
syncWindow: false,
youtubeBrowser: false,
setup: false,
show: false,
hide: false,
@@ -146,6 +146,9 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
openSyncUiWindow: () => {
calls.push('openSyncUiWindow');
},
openYoutubeBrowserWindow: () => {
calls.push('openYoutubeBrowserWindow');
},
openFirstRunSetup: (force?: boolean) => {
calls.push(`openFirstRunSetup:${force === true ? 'force' : 'default'}`);
},
@@ -247,7 +250,7 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
calls.push('runJellyfinCommand');
},
runYoutubePlaybackFlow: async (request) => {
calls.push(`runYoutubePlaybackFlow:${request.url}:${request.mode}:${request.source}`);
calls.push(`runYoutubePlaybackFlow:${request.url}:${request.source}`);
},
runUpdateCommand: async (args) => {
calls.push(`runUpdateCommand:${args.updateLauncherPath ?? ''}`);
@@ -287,46 +290,30 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
test('handleCliCommand starts youtube playback flow on initial launch', () => {
const { deps, calls } = createDeps({
runYoutubePlaybackFlow: async (request) => {
calls.push(`youtube:${request.url}:${request.mode}`);
},
});
handleCliCommand(
makeArgs({ youtubePlay: 'https://youtube.com/watch?v=abc', youtubeMode: 'generate' }),
'initial',
deps,
);
assert.deepEqual(calls, ['youtube:https://youtube.com/watch?v=abc:generate']);
});
test('handleCliCommand defaults youtube mode to download when omitted', () => {
const { deps, calls } = createDeps({
runYoutubePlaybackFlow: async (request) => {
calls.push(`youtube:${request.url}:${request.mode}`);
calls.push(`youtube:${request.url}:${request.source}`);
},
});
handleCliCommand(makeArgs({ youtubePlay: 'https://youtube.com/watch?v=abc' }), 'initial', deps);
assert.deepEqual(calls, ['youtube:https://youtube.com/watch?v=abc:download']);
assert.deepEqual(calls, ['youtube:https://youtube.com/watch?v=abc:initial']);
});
test('handleCliCommand reuses initialized overlay runtime for second-instance youtube playback', () => {
const { deps, calls } = createDeps({
isOverlayRuntimeInitialized: () => true,
runYoutubePlaybackFlow: async (request) => {
calls.push(`youtube:${request.url}:${request.mode}:${request.source}`);
calls.push(`youtube:${request.url}:${request.source}`);
},
});
handleCliCommand(
makeArgs({ youtubePlay: 'https://youtube.com/watch?v=abc', youtubeMode: 'download' }),
makeArgs({ youtubePlay: 'https://youtube.com/watch?v=abc' }),
'second-instance',
deps,
);
assert.deepEqual(calls, ['youtube:https://youtube.com/watch?v=abc:download:second-instance']);
assert.deepEqual(calls, ['youtube:https://youtube.com/watch?v=abc:second-instance']);
});
test('handleCliCommand reports youtube playback flow failures to logs and OSD', async () => {
@@ -336,11 +323,7 @@ test('handleCliCommand reports youtube playback flow failures to logs and OSD',
},
});
handleCliCommand(
makeArgs({ youtubePlay: 'https://youtube.com/watch?v=abc', youtubeMode: 'download' }),
'initial',
deps,
);
handleCliCommand(makeArgs({ youtubePlay: 'https://youtube.com/watch?v=abc' }), 'initial', deps);
await new Promise((resolve) => setImmediate(resolve));
assert.ok(calls.some((value) => value.startsWith('error:runYoutubePlaybackFlow failed:')));
@@ -681,6 +664,7 @@ test('createCliCommandDepsRuntime reconnects MPV client when reconnect hook exis
openYomitanSettings: () => {},
openConfigSettingsWindow: () => {},
openSyncUiWindow: () => {},
openYoutubeBrowserWindow: () => {},
cycleSecondarySubMode: () => {},
openRuntimeOptionsPalette: () => {},
printHelp: () => {},
@@ -794,6 +778,7 @@ test('handleCliCommand handles visibility and utility command dispatches', () =>
}> = [
{ args: { yomitan: true }, expected: 'openYomitanSettingsDelayed:1000' },
{ args: { settings: true }, expected: 'openConfigSettingsWindow' },
{ args: { youtubeBrowser: true }, expected: 'openYoutubeBrowserWindow' },
{
args: { showVisibleOverlay: true },
expected: 'setVisibleOverlayVisible:true',
@@ -827,9 +812,10 @@ test('handleCliCommand handles visibility and utility command dispatches', () =>
for (const entry of cases) {
const { deps, calls } = createDeps();
handleCliCommand(makeArgs(entry.args), 'initial', deps);
assert.ok(
calls.includes(entry.expected),
`expected call missing for args ${JSON.stringify(entry.args)}: ${entry.expected}`,
assert.equal(
calls.filter((value) => value === entry.expected).length,
1,
`expected exactly one call for args ${JSON.stringify(entry.args)}: ${entry.expected}`,
);
}
});
+6 -6
View File
@@ -46,6 +46,7 @@ export interface CliCommandServiceDeps {
openHachidoriSettingsDelayed: (delayMs: number) => void;
openConfigSettingsWindow: () => void;
openSyncUiWindow: () => void;
openYoutubeBrowserWindow: () => void;
setVisibleOverlayVisible: (visible: boolean) => void;
copyCurrentSubtitle: () => void;
startPendingMultiCopy: (timeoutMs: number) => void;
@@ -103,11 +104,7 @@ export interface CliCommandServiceDeps {
args: CliArgs,
source: CliCommandSource,
) => Promise<void>;
runYoutubePlaybackFlow: (request: {
url: string;
mode: NonNullable<CliArgs['youtubeMode']>;
source: CliCommandSource;
}) => Promise<void>;
runYoutubePlaybackFlow: (request: { url: string; source: CliCommandSource }) => Promise<void>;
ensureBackgroundStatsServer?: () => Promise<void> | void;
printHelp: () => void;
hasMainWindow: () => boolean;
@@ -174,6 +171,7 @@ interface UiCliRuntime {
openHachidoriSettings: () => void;
openConfigSettingsWindow: () => void;
openSyncUiWindow: () => void;
openYoutubeBrowserWindow: () => void;
cycleSecondarySubMode: () => void;
openRuntimeOptionsPalette: () => void;
printHelp: () => void;
@@ -284,6 +282,7 @@ export function createCliCommandDepsRuntime(
},
openConfigSettingsWindow: options.ui.openConfigSettingsWindow,
openSyncUiWindow: options.ui.openSyncUiWindow,
openYoutubeBrowserWindow: options.ui.openYoutubeBrowserWindow,
setVisibleOverlayVisible: options.overlay.setVisible,
copyCurrentSubtitle: options.mining.copyCurrentSubtitle,
startPendingMultiCopy: options.mining.startPendingMultiCopy,
@@ -438,6 +437,8 @@ export function handleCliCommand(
deps.openConfigSettingsWindow();
} else if (args.syncWindow) {
deps.openSyncUiWindow();
} else if (args.youtubeBrowser) {
deps.openYoutubeBrowserWindow();
} else if (args.show || args.showVisibleOverlay) {
deps.setVisibleOverlayVisible(true);
} else if (args.hide || args.hideVisibleOverlay) {
@@ -648,7 +649,6 @@ export function handleCliCommand(
() =>
deps.runYoutubePlaybackFlow({
url: youtubeUrl,
mode: args.youtubeMode ?? 'download',
source,
}),
deps,
@@ -18,6 +18,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
hachidori: false,
settings: false,
syncWindow: false,
youtubeBrowser: false,
setup: false,
show: false,
hide: false,
@@ -9,6 +9,8 @@ export function runSubtitleGenerationProcess(input: {
args: string[];
signal?: AbortSignal;
onLine?: (line: string) => void;
/** Shown when the executable is missing; defaults to pointing at subtitleGeneration settings. */
missingMessage?: string;
}): Promise<string> {
input.signal?.throwIfAborted();
return new Promise((resolve, reject) => {
@@ -51,7 +53,8 @@ export function runSubtitleGenerationProcess(input: {
reject(
new Error(
'code' in error && error.code === 'ENOENT'
? `${input.command} was not found. Install it or set its path under subtitleGeneration in Settings.`
? (input.missingMessage ??
`${input.command} was not found. Install it or set its path under subtitleGeneration in Settings.`)
: `Could not run ${input.command}: ${error.message}`,
),
);
+25 -9
View File
@@ -173,6 +173,21 @@ async function writeSubtitles(input: {
}
}
/** Resolve the model and tools, throwing the message to show when generation cannot run. */
export async function requireSubtitleGenerationSetup(
config: SubtitleGenerationConfig,
modelDirectory: string,
) {
const model = await resolveSubtitleGenerationModel(config, modelDirectory);
if (model.kind === 'missing')
throw new Error(
'No Whisper model found. Download a model or configure an existing model path.',
);
if (model.kind === 'invalid') throw new Error(model.message);
const tools = requireSubtitleGenerationTools(await resolveSubtitleGenerationTools(config));
return { modelPath: model.path, tools };
}
export async function generateJapaneseSubtitles(input: {
config: SubtitleGenerationConfig;
modelDirectory: string;
@@ -180,6 +195,8 @@ export async function generateJapaneseSubtitles(input: {
audioStreamIndex?: number;
references?: readonly SubtitleGenerationReference[];
outputPath?: string;
/** Parent for the scratch directory holding the extracted WAV; the system temp dir by default. */
workDirectory?: string;
onProgress?: (progress: SubtitleGenerationProgress) => void;
signal?: AbortSignal;
}): Promise<string> {
@@ -193,13 +210,10 @@ export async function generateJapaneseSubtitles(input: {
await ensureWritableDirectory(
input.outputPath ? path.dirname(path.resolve(input.outputPath)) : path.dirname(mediaPath),
);
const model = await resolveSubtitleGenerationModel(input.config, input.modelDirectory);
if (model.kind === 'missing')
throw new Error(
'No Whisper model found. Download a model or configure an existing model path.',
const { modelPath, tools } = await requireSubtitleGenerationSetup(
input.config,
input.modelDirectory,
);
if (model.kind === 'invalid') throw new Error(model.message);
const tools = requireSubtitleGenerationTools(await resolveSubtitleGenerationTools(input.config));
input.onProgress?.({ stage: 'extract', message: 'Inspecting audio tracks...' });
const probe = await runSubtitleGenerationProcess({
command: tools.ffprobe,
@@ -215,7 +229,9 @@ export async function generateJapaneseSubtitles(input: {
signal: input.signal,
});
const audio = parseAudioProbe(probe, input.audioStreamIndex);
const temporaryDirectory = await mkdtemp(path.join(tmpdir(), 'subminer-whisper-'));
const temporaryDirectory = await mkdtemp(
path.join(input.workDirectory ?? tmpdir(), 'subminer-whisper-'),
);
try {
const wavPath = path.join(temporaryDirectory, 'audio.wav');
const subtitleBase = path.join(temporaryDirectory, 'subtitles');
@@ -277,7 +293,7 @@ export async function generateJapaneseSubtitles(input: {
config: input.config,
tools,
referenceStarts,
modelPath: model.path,
modelPath,
wavPath,
directory: temporaryDirectory,
signal: input.signal,
@@ -288,7 +304,7 @@ export async function generateJapaneseSubtitles(input: {
command: tools.whisper,
args: [
'-m',
model.path,
modelPath,
'-f',
wavPath,
'-l',
@@ -478,6 +478,35 @@ test('downloadYoutubeSubtitleTrack prefers direct download URL when available',
});
});
test('downloadYoutubeSubtitleTrack falls back to yt-dlp when the direct URL is rejected', async () => {
if (process.platform === 'win32') {
return;
}
await withFakeYtDlp('both', async (root) => {
await withStubFetch(
async () => new Response('Too Many Requests', { status: 429 }),
async () => {
const result = await downloadYoutubeSubtitleTrack({
targetUrl: 'https://www.youtube.com/watch?v=abc123',
outputDir: path.join(root, 'out'),
track: {
id: 'auto:ja',
language: 'ja',
sourceLanguage: 'ja',
kind: 'auto',
label: 'Japanese (auto)',
downloadUrl: 'https://www.youtube.com/api/timedtext?v=abc123&lang=ja&fmt=vtt',
fileExtension: 'vtt',
},
});
assert.equal(path.extname(result.path), '.vtt');
},
);
});
});
test('downloadYoutubeSubtitleTrack sanitizes metadata source language in filenames', async () => {
await withTempDir(async (root) => {
await withStubFetch(
@@ -221,11 +221,16 @@ export async function downloadYoutubeSubtitleTrack(input: {
}
}
if (canDownloadSubtitleFromUrl(input.track)) {
try {
return await downloadSubtitleFromUrl({
outputDir: input.outputDir,
prefix,
track: input.track,
});
} catch {
// YouTube rejects some direct timedtext requests (HTTP 429) that yt-dlp still gets
// through with its own client handling, so retry the same track via yt-dlp.
}
}
const outputTemplate = path.join(input.outputDir, `${prefix}.%(ext)s`);
const args = [
@@ -116,6 +116,31 @@ test('probeYoutubeTracks prefers srv3 over vtt for automatic captions', async ()
);
});
test('probeYoutubeTracks skips machine-translated caption URLs when the real track exists', async () => {
const translated = 'https://www.youtube.com/api/timedtext?v=abc123&lang=en-US&tlang=ja&fmt=srv3';
const original = 'https://www.youtube.com/api/timedtext?v=abc123&lang=ja&fmt=srv3';
await withFakeYtDlp(
{
id: 'abc123',
title: 'Example',
automatic_captions: {
ja: [
{ ext: 'srv3', url: translated, name: 'Japanese' },
{ ext: 'srv3', url: original, name: 'Japanese' },
],
fr: [{ ext: 'srv3', url: `${translated}&x=fr`, name: 'French' }],
},
},
async () => {
const result = await probeYoutubeTracks('https://www.youtube.com/watch?v=abc123');
const byLanguage = new Map(result.tracks.map((track) => [track.sourceLanguage, track]));
assert.equal(byLanguage.get('ja')?.downloadUrl, original);
// A translation-only language still gets a track rather than disappearing.
assert.equal(byLanguage.get('fr')?.downloadUrl, `${translated}&x=fr`);
},
);
});
test('probeYoutubeTracks honors SUBMINER_YTDLP_BIN when yt-dlp is not on PATH', async () => {
if (process.platform === 'win32') {
return;
+16 -1
View File
@@ -56,10 +56,25 @@ function runCapture(
});
}
// YouTube machine-translates captions on request (`tlang`) and lists them under the target
// language, next to the real track. Those URLs reliably fail with HTTP 429, so they are only
// used when a language has nothing else.
function isTranslatedCaptionUrl(url: string): boolean {
try {
return new URL(url).searchParams.has('tlang');
} catch {
return false;
}
}
function choosePreferredFormat(
formats: YtDlpSubtitleEntry,
allFormats: YtDlpSubtitleEntry,
kind: YoutubeTrackKind,
): { ext: string; url: string; title?: string } | null {
const untranslated = allFormats.filter(
(format) => typeof format.url === 'string' && !isTranslatedCaptionUrl(format.url),
);
const formats = untranslated.length > 0 ? untranslated : allFormats;
const preferredOrder =
kind === 'auto'
? ['srv3', 'srv2', 'srv1', 'vtt', 'srt', 'ttml', 'json3']
@@ -0,0 +1,168 @@
import assert from 'node:assert/strict';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import test from 'node:test';
import { DEFAULT_SUBTITLE_GENERATION_CONFIG } from '../../../shared/subtitle-generation';
import {
buildYoutubeWhisperAudioArgs,
createYoutubeWhisperSubtitleService,
parseYtDlpDownloadPercent,
YOUTUBE_WHISPER_AUDIO_DIR_PREFIX,
} from './whisper-subtitles';
const URL = 'https://www.youtube.com/watch?v=abcdefghijk';
function makeTempRoot(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-youtube-whisper-test-'));
}
function audioDirs(root: string): string[] {
return fs.readdirSync(root).filter((name) => name.startsWith(YOUTUBE_WHISPER_AUDIO_DIR_PREFIX));
}
/** Stands in for yt-dlp: writes the file its `-o` template names and reports progress. */
function fakeYtDlp(calls: string[][]) {
return async (input: { args: string[]; onLine?: (line: string) => void }) => {
calls.push(input.args);
const template = input.args[input.args.indexOf('-o') + 1]!;
fs.writeFileSync(template.replace('%(ext)s', 'm4a'), 'audio');
input.onLine?.('[download] 50.0% of 3.89MiB at 1.00MiB/s ETA 00:02');
return '';
};
}
function createService(root: string, overrides: Record<string, unknown> = {}) {
return createYoutubeWhisperSubtitleService({
getConfig: () => DEFAULT_SUBTITLE_GENERATION_CONFIG,
getModelDirectory: () => '/models',
tempRoot: root,
getYtDlpCommand: () => 'yt-dlp',
requireSetup: async () => ({
modelPath: '/models/ggml-small.bin',
tools: { ffmpeg: 'ffmpeg', ffprobe: 'ffprobe', whisper: 'whisper-cli', vad: null },
}),
...overrides,
});
}
test('audio download picks a small original-language stream and ignores user embeds', () => {
const args = buildYoutubeWhisperAudioArgs(URL, '/tmp/audio.%(ext)s');
assert.deepEqual(args.slice(args.indexOf('-f'), args.indexOf('-f') + 4), [
'-f',
'ba[abr>=48]/ba',
'-S',
'lang,+abr',
]);
for (const flag of ['--no-playlist', '--no-sponsorblock', '--no-embed-subs']) {
assert.ok(args.includes(flag), flag);
}
assert.equal(args.at(-1), URL);
});
test('parseYtDlpDownloadPercent reads yt-dlp progress lines only', () => {
assert.equal(parseYtDlpDownloadPercent('[download] 42.5% of 3.89MiB'), 42.5);
assert.equal(parseYtDlpDownloadPercent('[download] Destination: audio.m4a'), null);
});
test('generate transcribes the downloaded audio and deletes it afterwards', async () => {
const root = makeTempRoot();
const calls: string[][] = [];
const progress: string[] = [];
let transcribedPath = '';
const service = createService(root, {
runProcess: fakeYtDlp(calls),
generate: async (input: { mediaPath: string; outputPath: string; workDirectory?: string }) => {
transcribedPath = input.mediaPath;
assert.equal(fs.readFileSync(input.mediaPath, 'utf8'), 'audio');
// The extracted WAV must land where cleanup already looks.
assert.equal(input.workDirectory, path.dirname(input.mediaPath));
return input.outputPath;
},
});
const result = await service.generate({
url: URL,
outputPath: '/subs/youtube-whisper.ja.srt',
onProgress: (update) => progress.push(`${update.stage}:${update.percent}`),
});
assert.equal(result, '/subs/youtube-whisper.ja.srt');
assert.equal(calls.length, 1);
assert.equal(path.basename(transcribedPath), 'audio.m4a');
assert.deepEqual(progress, ['download:0', 'download:50']);
assert.deepEqual(audioDirs(root), []);
});
test('generate deletes the audio when transcription fails', async () => {
const root = makeTempRoot();
const service = createService(root, {
runProcess: fakeYtDlp([]),
generate: async () => {
throw new Error('whisper crashed');
},
});
await assert.rejects(
service.generate({ url: URL, outputPath: '/subs/x.srt' }),
/whisper crashed/,
);
assert.deepEqual(audioDirs(root), []);
});
test('generate skips the download when Whisper is not set up', async () => {
const root = makeTempRoot();
const calls: string[][] = [];
const service = createService(root, {
runProcess: fakeYtDlp(calls),
requireSetup: async () => {
throw new Error('No Whisper model found.');
},
});
await assert.rejects(
service.generate({ url: URL, outputPath: '/subs/x.srt' }),
/No Whisper model/,
);
assert.equal(calls.length, 0);
assert.deepEqual(audioDirs(root), []);
});
test('leftover audio from a crashed run is removed before the next download', async () => {
const root = makeTempRoot();
fs.mkdirSync(path.join(root, `${YOUTUBE_WHISPER_AUDIO_DIR_PREFIX}stale`));
let dirsDuringDownload: string[] = [];
const service = createService(root, {
runProcess: async (input: { args: string[] }) => {
dirsDuringDownload = audioDirs(root);
return fakeYtDlp([])(input);
},
generate: async (input: { outputPath: string }) => input.outputPath,
});
await service.generate({ url: URL, outputPath: '/subs/x.srt' });
assert.equal(dirsDuringDownload.length, 1);
assert.notEqual(dirsDuringDownload[0], `${YOUTUBE_WHISPER_AUDIO_DIR_PREFIX}stale`);
});
test('removeActiveAudio deletes audio for a job that is still running', async () => {
const root = makeTempRoot();
let release: (() => void) | null = null;
const service = createService(root, {
runProcess: fakeYtDlp([]),
generate: async (input: { outputPath: string }) => {
await new Promise<void>((resolve) => {
release = resolve;
});
return input.outputPath;
},
});
const job = service.generate({ url: URL, outputPath: '/subs/x.srt' });
while (!release) await new Promise((resolve) => setImmediate(resolve));
assert.equal(audioDirs(root).length, 1);
service.removeActiveAudio();
assert.deepEqual(audioDirs(root), []);
(release as () => void)();
await job;
});
@@ -0,0 +1,148 @@
import { mkdtemp, readdir, rm } from 'node:fs/promises';
import { rmSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import type {
SubtitleGenerationConfig,
SubtitleGenerationProgress,
} from '../../../shared/subtitle-generation';
import { generateJapaneseSubtitles, requireSubtitleGenerationSetup } from '../subtitle-generation';
import { runSubtitleGenerationProcess } from '../subtitle-generation-process';
import { getYoutubeYtDlpCommand, YTDLP_SINGLE_VIDEO_ARG } from './ytdlp-command';
export const YOUTUBE_WHISPER_AUDIO_DIR_PREFIX = 'subminer-youtube-audio-';
/**
* Whisper resamples everything to 16 kHz mono, so bitrate beyond plain speech clarity only costs
* download time. Take the smallest audio-only stream of at least 48 kbps, falling back to the
* smallest one. Sorting on `lang` first keeps the original track ahead of auto-dubbed ones.
*/
const AUDIO_FORMAT = 'ba[abr>=48]/ba';
const AUDIO_SORT = 'lang,+abr';
export function buildYoutubeWhisperAudioArgs(url: string, outputTemplate: string): string[] {
return [
YTDLP_SINGLE_VIDEO_ARG,
'--no-warnings',
'--newline',
// Override user yt-dlp config that would change the audio or add requests: SponsorBlock
// removal shifts every subtitle after a cut, and subtitle embedding hits the timedtext API.
'--no-sponsorblock',
'--no-write-subs',
'--no-write-auto-subs',
'--no-embed-subs',
'--no-write-thumbnail',
'--no-embed-thumbnail',
'--no-embed-metadata',
'--no-embed-chapters',
'-f',
AUDIO_FORMAT,
'-S',
AUDIO_SORT,
'-o',
outputTemplate,
url,
];
}
export function parseYtDlpDownloadPercent(line: string): number | null {
const match = /^\[download\]\s+(\d+(?:\.\d+)?)%/.exec(line.trim());
return match ? Math.min(100, Number(match[1])) : null;
}
async function findDownloadedAudio(directory: string): Promise<string> {
const names = await readdir(directory);
const audio = names.find((name) => name.startsWith('audio.') && !/\.(part|ytdl)$/.test(name));
if (!audio) throw new Error('yt-dlp finished without saving the audio.');
return path.join(directory, audio);
}
export type YoutubeWhisperSubtitleServiceDeps = {
getConfig: () => SubtitleGenerationConfig;
getModelDirectory: () => string;
tempRoot?: string;
getYtDlpCommand?: () => string;
requireSetup?: typeof requireSubtitleGenerationSetup;
runProcess?: typeof runSubtitleGenerationProcess;
generate?: typeof generateJapaneseSubtitles;
};
/**
* Generates Japanese subtitles for a YouTube video by transcribing its audio with Whisper.
* The audio lives in a private temp directory that is removed as soon as generation ends,
* whether it succeeds, fails, or is aborted.
*/
export function createYoutubeWhisperSubtitleService(deps: YoutubeWhisperSubtitleServiceDeps) {
const tempRoot = deps.tempRoot ?? os.tmpdir();
const activeDirs = new Set<string>();
// Audio directories only outlive a job when the app crashes mid-generation.
const removeStaleAudioDirs = async (): Promise<void> => {
const names = await readdir(tempRoot).catch(() => []);
await Promise.all(
names
.filter((name) => name.startsWith(YOUTUBE_WHISPER_AUDIO_DIR_PREFIX))
.map((name) => path.join(tempRoot, name))
.filter((dir) => !activeDirs.has(dir))
.map((dir) => rm(dir, { recursive: true, force: true }).catch(() => undefined)),
);
};
async function generate(input: {
url: string;
outputPath: string;
/** Overrides the configured settings, e.g. the modal's per-session model choice. */
config?: SubtitleGenerationConfig;
signal?: AbortSignal;
onProgress?: (progress: SubtitleGenerationProgress) => void;
}): Promise<string> {
input.signal?.throwIfAborted();
const config = input.config ?? deps.getConfig();
// Fail before spending time on the download when Whisper cannot run.
await (deps.requireSetup ?? requireSubtitleGenerationSetup)(config, deps.getModelDirectory());
await removeStaleAudioDirs();
const directory = await mkdtemp(path.join(tempRoot, YOUTUBE_WHISPER_AUDIO_DIR_PREFIX));
activeDirs.add(directory);
try {
input.onProgress?.({ stage: 'download', percent: 0, message: 'Downloading audio...' });
await (deps.runProcess ?? runSubtitleGenerationProcess)({
command: (deps.getYtDlpCommand ?? getYoutubeYtDlpCommand)(),
args: buildYoutubeWhisperAudioArgs(input.url, path.join(directory, 'audio.%(ext)s')),
signal: input.signal,
missingMessage: 'yt-dlp was not found. Install it or set SUBMINER_YTDLP_BIN.',
onLine: (line) => {
const percent = parseYtDlpDownloadPercent(line);
if (percent !== null)
input.onProgress?.({ stage: 'download', percent, message: 'Downloading audio...' });
},
});
return await (deps.generate ?? generateJapaneseSubtitles)({
config,
modelDirectory: deps.getModelDirectory(),
mediaPath: await findDownloadedAudio(directory),
outputPath: input.outputPath,
// Keep the extracted WAV inside the managed directory so every cleanup path removes it.
workDirectory: directory,
onProgress: input.onProgress,
signal: input.signal,
});
} finally {
await rm(directory, { recursive: true, force: true }).catch(() => undefined);
activeDirs.delete(directory);
}
}
/** Synchronous so it still runs while the app is quitting. */
function removeActiveAudio(): void {
for (const directory of activeDirs) {
try {
rmSync(directory, { recursive: true, force: true });
} catch {
// Leftovers are swept before the next download.
}
}
activeDirs.clear();
}
return { generate, removeActiveAudio };
}
+11 -1
View File
@@ -1,7 +1,8 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import path from 'node:path';
import { resolveDefaultLogFilePath, setLogRotation } from './logger';
import { EventEmitter } from 'node:events';
import { ignoreClosedPipeErrors, resolveDefaultLogFilePath, setLogRotation } from './logger';
import { localDateKey } from './shared/log-files';
test('resolveDefaultLogFilePath uses APPDATA on windows', () => {
@@ -56,3 +57,12 @@ test('setLogRotation accepts numeric retention days', () => {
}
}
});
test('ignoreClosedPipeErrors swallows broken-pipe errors but rethrows others', () => {
const stream = new EventEmitter();
ignoreClosedPipeErrors(stream);
const closedPipe = Object.assign(new Error('write EPIPE'), { code: 'EPIPE' });
assert.doesNotThrow(() => stream.emit('error', closedPipe));
const other = Object.assign(new Error('boom'), { code: 'EACCES' });
assert.throws(() => stream.emit('error', other), /boom/);
});
+21
View File
@@ -32,6 +32,27 @@ const LEVEL_PRIORITY: Record<LogLevel, number> = {
const DEFAULT_LOG_LEVEL: LogLevel = 'warn';
const CLOSED_PIPE_ERROR_CODES = new Set(['EPIPE', 'ERR_STREAM_DESTROYED']);
/**
* Keeps the app alive when whoever held its stdout/stderr goes away (e.g. Ctrl+C on the
* launcher that spawned it). Node reports the broken pipe as an async 'error' event on the
* stream, which crashes the process when nothing listens for it. Other errors still throw.
*/
export function ignoreClosedPipeErrors(stream: {
on: (event: 'error', listener: (error: NodeJS.ErrnoException) => void) => unknown;
}): void {
stream.on('error', (error) => {
if (error.code && CLOSED_PIPE_ERROR_CODES.has(error.code)) {
return;
}
throw error;
});
}
ignoreClosedPipeErrors(process.stdout);
ignoreClosedPipeErrors(process.stderr);
let cliLogLevel: LogLevel | undefined;
let configLogLevel: LogLevel | undefined;
let configLogRotation: LogRotation = DEFAULT_LOG_ROTATION;
+110 -4
View File
@@ -338,6 +338,7 @@ import {
} from './core/services/youtube/generate';
import { applyOverlayClickThrough } from './core/services/overlay-click-through';
import { createYoutubeMediaCacheService } from './core/services/youtube/media-cache';
import { createYoutubeWhisperSubtitleService } from './core/services/youtube/whisper-subtitles';
import { resolveYoutubePlaybackUrl } from './core/services/youtube/playback-resolve';
import { probeYoutubeTracks } from './core/services/youtube/track-probe';
import {
@@ -494,6 +495,8 @@ import {
} from './main/runtime/media-timing-review';
import { openMediaTimingReviewModal } from './main/runtime/media-timing-review-open';
import { openYoutubeTrackPicker } from './main/runtime/youtube-picker-open';
import { createYoutubeBrowserPlaybackRuntime } from './main/runtime/youtube-browser-playback';
import { createYoutubeBrowserWindowRuntime } from './main/runtime/youtube-browser-window';
import { openRuntimeOptionsModal as openRuntimeOptionsModalRuntime } from './main/runtime/runtime-options-open';
import { openJimakuModal as openJimakuModalRuntime } from './main/runtime/jimaku-open';
import { openTsukihimeModal as openTsukihimeModalRuntime } from './main/runtime/tsukihime-open';
@@ -675,6 +678,8 @@ const JELLYFIN_MPV_CONNECT_TIMEOUT_MS = 3000;
const JELLYFIN_MPV_AUTO_LAUNCH_TIMEOUT_MS = 20000;
const YOUTUBE_MPV_CONNECT_TIMEOUT_MS = 3000;
const YOUTUBE_MPV_AUTO_LAUNCH_TIMEOUT_MS = 10000;
// mpv's ytdl hook resolves the stream before tracks appear; a cold yt-dlp run takes well over 5s.
const YOUTUBE_MPV_LOAD_TIMEOUT_MS = 20000;
const YOUTUBE_MPV_YTDL_FORMAT = 'bestvideo*+bestaudio/best';
const YOUTUBE_DIRECT_PLAYBACK_FORMAT = 'b';
const MPV_JELLYFIN_DEFAULT_ARGS = [
@@ -1054,6 +1059,13 @@ process.on('SIGTERM', () => {
requestAppQuit();
});
// Managed Whisper models live next to the config file.
const getSubtitleGenerationModelDirectory = (): string =>
path.join(path.dirname(configService.getConfigPath()), 'models', 'whisper');
const youtubeWhisperSubtitles = createYoutubeWhisperSubtitleService({
getConfig: () => configService.getConfig().subtitleGeneration,
getModelDirectory: getSubtitleGenerationModelDirectory,
});
const youtubeFlowRuntime = createYoutubeFlowRuntime({
probeYoutubeTracks: (url: string) => probeYoutubeTracks(url),
acquireYoutubeSubtitleTrack: (input) => acquireYoutubeSubtitleTrack(input),
@@ -1193,6 +1205,25 @@ const youtubeFlowRuntime = createYoutubeFlowRuntime({
fs.rmSync(dir, { recursive: true, force: true });
}
},
getSubtitleSource: () => configService.getConfig().youtube.subtitleSource,
// Runs as the subtitle generation modal's active job, so the modal shows it and can cancel it.
generateWhisperSubtitles: ({ url, outputPath, signal, onProgress }) =>
subtitleGenerationRuntime.generateYoutubeSubtitles({
url,
signal,
generate: (jobSignal, reportProgress) =>
youtubeWhisperSubtitles.generate({
url,
outputPath,
signal: jobSignal,
onProgress: (progress) => {
reportProgress(progress);
onProgress(progress);
},
}),
}),
openSubtitleGenerationModal: () =>
openSubtitleGenerationModal(createOverlayHostedModalOpenDeps()),
});
const prepareYoutubePlaybackInMpv = createPrepareYoutubePlaybackInMpvHandler({
requestPath: async () => {
@@ -1420,7 +1451,8 @@ const youtubePlaybackRuntime = createYoutubePlaybackRuntime({
);
},
waitForYoutubeMpvConnected: (timeoutMs) => waitForYoutubeMpvConnected(timeoutMs),
prepareYoutubePlaybackInMpv: (request) => prepareYoutubePlaybackInMpv(request),
prepareYoutubePlaybackInMpv: (request) =>
prepareYoutubePlaybackInMpv({ ...request, timeoutMs: YOUTUBE_MPV_LOAD_TIMEOUT_MS }),
startYoutubeMediaCache: (url) => {
const mediaCacheConfig = configService.getConfig().youtube.mediaCache;
youtubeMediaCache.start(url, {
@@ -3234,6 +3266,7 @@ const {
runJellyfinCommand,
openJellyfinSetupWindow,
getJellyfinClientInfo,
ensureMpvConnectedForPlayback,
} = composeJellyfinRuntimeHandlers({
getResolvedJellyfinConfigMainDeps: {
getResolvedConfig: () => configService.getConfig(),
@@ -3511,6 +3544,52 @@ const {
},
});
let quitWhenMpvClosesAfterYoutubeBrowser = false;
const isMpvPlayingMedia = (): boolean =>
Boolean(appState.mpvClient?.connected && appState.currentMediaPath?.trim());
const youtubeBrowserPlaybackRuntime = createYoutubeBrowserPlaybackRuntime({
isMpvPlaying: isMpvPlayingMedia,
// Windows playback bootstraps its own mpv inside the playback flow.
ensureMpvReady: async () =>
process.platform === 'win32' ? true : await ensureMpvConnectedForPlayback(),
runPlaybackFlow: (url) =>
youtubePlaybackRuntime.runYoutubePlaybackFlow({ url, source: 'youtube-browser' }),
appendToMpvPlaylist: (url) => {
sendMpvCommandRuntime(appState.mpvClient, ['loadfile', url, 'append']);
},
notifyFailure: (message) =>
overlayNotificationsRuntime.showConfiguredStatusNotification(message, { title: 'YouTube' }),
logWarn: (message) => logger.warn(message),
onStartupFailed: () => quitAfterYoutubeBrowserIfIdle(),
});
const youtubeBrowserWindowRuntime = createYoutubeBrowserWindowRuntime({
createBrowserWindow: (options) => new BrowserWindow(options),
preloadPath: path.join(__dirname, 'preload-youtube-browser.js'),
ipcMain,
showContextMenu: (window, template) => {
Menu.buildFromTemplate(template).popup({ window });
},
openExternal: (url) => {
void shell.openExternal(url);
},
openVideo: (request) => youtubeBrowserPlaybackRuntime.openVideo(request),
logWarn: (message, error) => logger.warn(message, error),
logDebug: (message) => logger.debug(message),
onClosed: () => {
if (!appState.initialArgs?.youtubeBrowser) return;
quitWhenMpvClosesAfterYoutubeBrowser = true;
quitAfterYoutubeBrowserIfIdle();
},
});
// A process started just for the browser (`--youtube-browser`) exits once the window is closed,
// but keeps the overlay alive until mpv closes if a video is playing or still starting. Rechecked
// when a start fails, so a failed start after the window closed does not leave the app running.
const quitAfterYoutubeBrowserIfIdle = (): void => {
if (!quitWhenMpvClosesAfterYoutubeBrowser || youtubeBrowserWindowRuntime.isOpen()) return;
if (isMpvPlayingMedia() || youtubeBrowserPlaybackRuntime.isStartingPlayback()) return;
requestAppQuit();
};
const maybeFocusExistingFirstRunSetupWindow = createMaybeFocusExistingFirstRunSetupWindowHandler({
getSetupWindow: () => appState.firstRunSetupWindow,
});
@@ -4172,7 +4251,12 @@ const {
},
stopJellyfinRemoteSession: () => stopJellyfinRemoteSession(),
cleanupInternalSubtitleTrackCache: () => cachedInternalSubtitleTrackExtractor.clear(),
cleanupYoutubeSubtitleTempDirs: () => youtubeFlowRuntime.cleanupSubtitleTempDirs(),
cleanupYoutubeSubtitleTempDirs: () => {
// Stops yt-dlp/whisper-cli and deletes the audio they were working on.
youtubeFlowRuntime.cancelWhisperGeneration();
youtubeWhisperSubtitles.removeActiveAudio();
youtubeFlowRuntime.cleanupSubtitleTempDirs();
},
cleanupYoutubeMediaCache: () => youtubeMediaCache.cleanup(),
cleanupRemoteMediaWindows: () => getSharedRemoteMediaWindowCache().cleanup(),
cleanupJellyfinSubtitleCache: () => cleanupJellyfinSubtitleCache(),
@@ -4775,6 +4859,9 @@ const {
}
youtubePrimarySubtitleNotificationRuntime.handleMediaPathChange(path);
void youtubeMediaCachePlaybackRuntime.handleMediaPathChange(path);
youtubeBrowserPlaybackRuntime.handleMediaPathChange(path);
youtubeFlowRuntime.handleMediaPathChange(path);
subtitleGenerationRuntime.handleMediaPathChange(path);
if (path) {
ensureImmersionTrackerStarted();
secondarySubtitleTrackController.scheduleRefresh();
@@ -5125,6 +5212,13 @@ function createMpvClientRuntimeService(): MpvIpcClient {
if (connected) {
return;
}
youtubeBrowserPlaybackRuntime.handleMpvDisconnected();
youtubeFlowRuntime.cancelWhisperGeneration();
subtitleGenerationRuntime.handleMediaPathChange(null);
if (quitWhenMpvClosesAfterYoutubeBrowser && !youtubeBrowserWindowRuntime.isOpen()) {
requestAppQuit();
return;
}
if (!youtubeFlowRuntime.hasActiveSession()) {
return;
}
@@ -6339,6 +6433,7 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({
openYomitanSettings: () => openYomitanSettings(),
openConfigSettingsWindow: () => configSettingsRuntime.openWindow(),
openSyncUiWindow: () => openSyncUiWindowHandler(),
openYoutubeBrowserWindow: () => youtubeBrowserWindowRuntime.open(),
cycleSecondarySubMode: () => cycleSecondarySubMode(),
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT),
@@ -6605,6 +6700,7 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } =
openYomitanSettings: () => openYomitanSettings(),
openConfigSettingsWindow: () => configSettingsRuntime.openWindow(),
openSyncUiWindow: () => openSyncUiWindowHandler(),
openYoutubeBrowserWindow: () => youtubeBrowserWindowRuntime.open(),
exportLogs: () => {
void exportLogsFromTray();
},
@@ -6889,8 +6985,18 @@ registerSubtitleSelectionIpc({
});
const subtitleGenerationRuntime = createSubtitleGenerationRuntime({
getConfig: () => configService.getConfig().subtitleGeneration,
getModelDirectory: () =>
path.join(path.dirname(configService.getConfigPath()), 'models', 'whisper'),
getModelDirectory: getSubtitleGenerationModelDirectory,
generateYoutube: async ({ url, config, signal, onProgress }) =>
youtubeWhisperSubtitles.generate({
url,
config,
signal,
onProgress,
outputPath: path.join(
await youtubeFlowRuntime.createSubtitleTempDir(),
'youtube-whisper.ja.srt',
),
}),
getMpvClient: () => appState.mpvClient,
onProgress: (progress) => {
for (const window of [overlayManager.getMainWindow(), overlayManager.getModalWindow()]) {
+2
View File
@@ -52,6 +52,7 @@ export interface CliCommandRuntimeServiceContext {
openHachidoriSettings: () => void;
openConfigSettingsWindow: () => void;
openSyncUiWindow: () => void;
openYoutubeBrowserWindow: () => void;
cycleSecondarySubMode: () => void;
openRuntimeOptionsPalette: () => void;
printHelp: () => void;
@@ -139,6 +140,7 @@ function createCliCommandDepsFromContext(
openHachidoriSettings: context.openHachidoriSettings,
openConfigSettingsWindow: context.openConfigSettingsWindow,
openSyncUiWindow: context.openSyncUiWindow,
openYoutubeBrowserWindow: context.openYoutubeBrowserWindow,
cycleSecondarySubMode: context.cycleSecondarySubMode,
openRuntimeOptionsPalette: context.openRuntimeOptionsPalette,
printHelp: context.printHelp,
+2
View File
@@ -220,6 +220,7 @@ export interface CliCommandRuntimeServiceDepsParams {
openHachidoriSettings: CliCommandDepsRuntimeOptions['ui']['openHachidoriSettings'];
openConfigSettingsWindow: CliCommandDepsRuntimeOptions['ui']['openConfigSettingsWindow'];
openSyncUiWindow: CliCommandDepsRuntimeOptions['ui']['openSyncUiWindow'];
openYoutubeBrowserWindow: CliCommandDepsRuntimeOptions['ui']['openYoutubeBrowserWindow'];
cycleSecondarySubMode: CliCommandDepsRuntimeOptions['ui']['cycleSecondarySubMode'];
openRuntimeOptionsPalette: CliCommandDepsRuntimeOptions['ui']['openRuntimeOptionsPalette'];
printHelp: CliCommandDepsRuntimeOptions['ui']['printHelp'];
@@ -434,6 +435,7 @@ export function createCliCommandRuntimeServiceDeps(
openHachidoriSettings: params.ui.openHachidoriSettings,
openConfigSettingsWindow: params.ui.openConfigSettingsWindow,
openSyncUiWindow: params.ui.openSyncUiWindow,
openYoutubeBrowserWindow: params.ui.openYoutubeBrowserWindow,
cycleSecondarySubMode: params.ui.cycleSecondarySubMode,
openRuntimeOptionsPalette: params.ui.openRuntimeOptionsPalette,
printHelp: params.ui.printHelp,
@@ -76,6 +76,7 @@ test('build cli command context deps maps handlers and values', () => {
openYomitanSettings: () => calls.push('yomitan'),
openConfigSettingsWindow: () => calls.push('config-settings'),
openSyncUiWindow: () => {},
openYoutubeBrowserWindow: () => {},
cycleSecondarySubMode: () => calls.push('cycle-secondary'),
openRuntimeOptionsPalette: () => calls.push('runtime-options'),
printHelp: () => calls.push('help'),
@@ -50,6 +50,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
openHachidoriSettings: () => void;
openConfigSettingsWindow: () => void;
openSyncUiWindow: () => void;
openYoutubeBrowserWindow: () => void;
cycleSecondarySubMode: () => void;
openRuntimeOptionsPalette: () => void;
printHelp: () => void;
@@ -111,6 +112,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
openHachidoriSettings: deps.openHachidoriSettings,
openConfigSettingsWindow: deps.openConfigSettingsWindow,
openSyncUiWindow: deps.openSyncUiWindow,
openYoutubeBrowserWindow: deps.openYoutubeBrowserWindow,
cycleSecondarySubMode: deps.cycleSecondarySubMode,
openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette,
printHelp: deps.printHelp,
@@ -78,6 +78,7 @@ test('cli command context factory composes main deps and context handlers', () =
openYomitanSettings: () => {},
openConfigSettingsWindow: () => {},
openSyncUiWindow: () => {},
openYoutubeBrowserWindow: () => {},
cycleSecondarySubMode: () => {},
openRuntimeOptionsPalette: () => {},
printHelp: () => {},
@@ -107,6 +107,7 @@ test('cli command context main deps builder maps state and callbacks', async ()
openYomitanSettings: () => calls.push('open-yomitan'),
openConfigSettingsWindow: () => calls.push('open-config-settings'),
openSyncUiWindow: () => {},
openYoutubeBrowserWindow: () => {},
cycleSecondarySubMode: () => calls.push('cycle-secondary'),
openRuntimeOptionsPalette: () => calls.push('open-runtime-options'),
printHelp: () => calls.push('help'),
@@ -67,6 +67,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
openHachidoriSettings: () => void;
openConfigSettingsWindow: () => void;
openSyncUiWindow: () => void;
openYoutubeBrowserWindow: () => void;
cycleSecondarySubMode: () => void;
openRuntimeOptionsPalette: () => void;
printHelp: () => void;
@@ -148,6 +149,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
openHachidoriSettings: () => deps.openHachidoriSettings(),
openConfigSettingsWindow: () => deps.openConfigSettingsWindow(),
openSyncUiWindow: () => deps.openSyncUiWindow(),
openYoutubeBrowserWindow: () => deps.openYoutubeBrowserWindow(),
cycleSecondarySubMode: () => deps.cycleSecondarySubMode(),
openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(),
printHelp: () => deps.printHelp(),
@@ -60,6 +60,7 @@ function createDeps() {
openYomitanSettings: () => {},
openConfigSettingsWindow: () => {},
openSyncUiWindow: () => {},
openYoutubeBrowserWindow: () => {},
cycleSecondarySubMode: () => {},
openRuntimeOptionsPalette: () => {},
printHelp: () => {},
+2
View File
@@ -55,6 +55,7 @@ export type CliCommandContextFactoryDeps = {
openHachidoriSettings: () => void;
openConfigSettingsWindow: () => void;
openSyncUiWindow: () => void;
openYoutubeBrowserWindow: () => void;
cycleSecondarySubMode: () => void;
openRuntimeOptionsPalette: () => void;
printHelp: () => void;
@@ -138,6 +139,7 @@ export function createCliCommandContext(
openHachidoriSettings: deps.openHachidoriSettings,
openConfigSettingsWindow: deps.openConfigSettingsWindow,
openSyncUiWindow: deps.openSyncUiWindow,
openYoutubeBrowserWindow: deps.openYoutubeBrowserWindow,
cycleSecondarySubMode: deps.cycleSecondarySubMode,
openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette,
printHelp: deps.printHelp,
@@ -54,6 +54,7 @@ test('composeCliStartupHandlers returns callable CLI startup handlers', () => {
openYomitanSettings: () => {},
openConfigSettingsWindow: () => {},
openSyncUiWindow: () => {},
openYoutubeBrowserWindow: () => {},
cycleSecondarySubMode: () => {},
openRuntimeOptionsPalette: () => {},
printHelp: () => {},
@@ -146,6 +146,8 @@ export type JellyfinRuntimeComposerResult = ComposerOutputs<{
typeof composeJellyfinRemoteHandlers
>['handleJellyfinRemoteGeneralCommand'];
playJellyfinItemInMpv: ReturnType<typeof createPlayJellyfinItemInMpvHandler>;
/** Connects to mpv, launching an idle player when none is running. */
ensureMpvConnectedForPlayback: () => Promise<boolean>;
cleanupJellyfinSubtitleCache: () => void;
startJellyfinRemoteSession: ReturnType<typeof createStartJellyfinRemoteSessionHandler>;
stopJellyfinRemoteSession: ReturnType<typeof createStopJellyfinRemoteSessionHandler>;
@@ -303,6 +305,7 @@ export function composeJellyfinRuntimeHandlers(
handleJellyfinRemotePlaystate,
handleJellyfinRemoteGeneralCommand,
playJellyfinItemInMpv,
ensureMpvConnectedForPlayback: () => ensureMpvConnectedForJellyfinPlayback(),
cleanupJellyfinSubtitleCache: () => preloadJellyfinExternalSubtitles.cleanupCachedSubtitles(),
startJellyfinRemoteSession,
stopJellyfinRemoteSession,
@@ -38,6 +38,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
hachidori: false,
settings: false,
syncWindow: false,
youtubeBrowser: false,
setup: false,
show: false,
hide: false,
@@ -313,3 +313,133 @@ test('speech model downloads share the job lock and cancellation', async () => {
runtime.cancel();
assert.deepEqual(await download, { ok: false, message: 'Cancelled.' });
});
test('a YouTube Whisper job shows in the modal status and returns its subtitle file', async () => {
const { runtime } = fixture();
let release = () => {};
const job = runtime.generateYoutubeSubtitles({
url: 'https://www.youtube.com/watch?v=abcdefghijk',
signal: new AbortController().signal,
generate: async (_signal, onProgress) => {
onProgress({ stage: 'transcribe', percent: 40, message: 'Generating Japanese subtitles...' });
await new Promise<void>((resolve) => {
release = resolve;
});
return '/tmp/subs/youtube-whisper.ja.srt';
},
});
const active = await runtime.getStatus();
assert.equal(active.running, true);
assert.equal(active.mediaPath, 'https://www.youtube.com/watch?v=abcdefghijk');
assert.equal(active.progress?.percent, 40);
assert.equal((await runtime.start()).ok, false);
release();
assert.equal(await job, '/tmp/subs/youtube-whisper.ja.srt');
const done = await runtime.getStatus();
assert.equal(done.running, false);
assert.equal(done.mediaPath, '/video/episode.mkv');
});
test('cancelling a YouTube Whisper job resolves to null, and failures throw', async () => {
const { runtime } = fixture();
const cancelled = runtime.generateYoutubeSubtitles({
url: 'https://www.youtube.com/watch?v=abcdefghijk',
signal: new AbortController().signal,
generate: (signal) =>
new Promise((_, reject) =>
signal.addEventListener('abort', () => reject(new Error('Aborted')), { once: true }),
),
});
runtime.cancel();
assert.equal(await cancelled, null);
assert.deepEqual((await runtime.getStatus()).lastResult, { ok: false, message: 'Cancelled.' });
await assert.rejects(
runtime.generateYoutubeSubtitles({
url: 'https://www.youtube.com/watch?v=abcdefghijk',
signal: new AbortController().signal,
generate: async () => {
throw new Error('No Whisper model found.');
},
}),
/No Whisper model found/,
);
});
function youtubeFixture(generateYoutube: SubtitleGenerationRuntimeDeps['generateYoutube']) {
const player = {
path: 'https://www.youtube.com/watch?v=abcdefghijk&pp=search',
paused: false,
};
const commands: unknown[][] = [];
const client = {
connected: true,
requestProperty: async (name: string): Promise<unknown> =>
name === 'path' ? player.path : name === 'pause' ? player.paused : null,
request: async (command: unknown[]) => {
commands.push(command);
if (command[0] === 'set_property' && command[1] === 'pause')
player.paused = command[2] === true;
return { error: 'success' };
},
};
const { runtime } = fixture({ getMpvClient: () => client, generateYoutube });
return { runtime, player, commands };
}
test('the modal generates for a playing YouTube video, pausing it until the subtitles load', async () => {
let pausedDuringGeneration = false;
let usedModel = '';
const { runtime, player, commands } = youtubeFixture(async (input) => {
pausedDuringGeneration = player.paused;
usedModel = input.config.managedModel;
assert.equal(input.url, 'https://www.youtube.com/watch?v=abcdefghijk');
return '/tmp/subs/youtube-whisper.ja.srt';
});
assert.equal(
(await runtime.getStatus()).mediaPath,
'https://www.youtube.com/watch?v=abcdefghijk',
);
await runtime.selectModel('medium');
const result = await runtime.start();
assert.deepEqual(result, {
ok: true,
outputPath: '/tmp/subs/youtube-whisper.ja.srt',
message: 'Japanese subtitles generated and loaded.',
});
assert.equal(pausedDuringGeneration, true);
assert.equal(usedModel, 'medium');
assert.deepEqual(
commands.find((command) => command[0] === 'sub-add'),
['sub-add', '/tmp/subs/youtube-whisper.ja.srt', 'select', 'Generated Japanese', 'ja'],
);
assert.equal(player.paused, false);
});
test('switching videos cancels a YouTube job from the modal without loading its subtitles', async () => {
let jobSignal: AbortSignal | null = null;
const { runtime, player, commands } = youtubeFixture(
(input) =>
new Promise((_, reject) => {
jobSignal = input.signal;
input.signal.addEventListener('abort', () => reject(new Error('Aborted')), { once: true });
}),
);
const job = runtime.start();
while (!jobSignal) await new Promise((resolve) => setImmediate(resolve));
runtime.handleMediaPathChange('https://www.youtube.com/watch?v=abcdefghijk');
assert.equal((jobSignal as AbortSignal).aborted, false);
player.path = 'https://www.youtube.com/watch?v=zyxwvutsrqp';
runtime.handleMediaPathChange(player.path);
assert.deepEqual(await job, { ok: false, message: 'Cancelled.' });
assert.equal(
commands.some((command) => command[0] === 'sub-add'),
false,
);
});
+109 -12
View File
@@ -21,6 +21,7 @@ import type {
SubtitleGenerationResult,
SubtitleGenerationStatus,
} from '../../shared/subtitle-generation-ipc';
import { extractYoutubeVideoId, toYoutubeWatchUrl } from './youtube-playback';
interface GenerationMpvClient {
connected: boolean;
@@ -40,6 +41,16 @@ export interface SubtitleGenerationRuntimeDeps {
detectAcceleration?: typeof detectSubtitleGenerationAcceleration;
downloadVad?: typeof downloadSubtitleGenerationVadModel;
resolveVadModel?: typeof resolveSubtitleGenerationVadModel;
/**
* Transcribes a YouTube video's audio into a temporary subtitle file and returns its path.
* Without it the modal only generates for local media.
*/
generateYoutube?: (input: {
url: string;
config: SubtitleGenerationConfig;
signal: AbortSignal;
onProgress: (progress: SubtitleGenerationProgress) => void;
}) => Promise<string>;
}
async function currentLocalMedia(client: GenerationMpvClient | null): Promise<string | null> {
@@ -51,6 +62,25 @@ async function currentLocalMedia(client: GenerationMpvClient | null): Promise<st
return typeof directory === 'string' ? path.resolve(directory, media) : null;
}
async function currentYoutubeMedia(client: GenerationMpvClient | null): Promise<string | null> {
if (!client?.connected) return null;
const media = await client.requestProperty('path');
return typeof media === 'string' ? toYoutubeWatchUrl(media) : null;
}
async function loadGeneratedSubtitles(client: GenerationMpvClient, outputPath: string) {
const loaded = await client.request([
'sub-add',
outputPath,
'select',
'Generated Japanese',
'ja',
]);
if (loaded.error && loaded.error !== 'success') throw new Error(loaded.error);
const delay = await client.request(['set_property', 'sub-delay', 0]);
if (delay.error && delay.error !== 'success') throw new Error(delay.error);
}
function selectedAudioIndex(tracks: unknown): number {
if (!Array.isArray(tracks)) throw new Error('Unable to inspect the selected audio track.');
for (const track of tracks) {
@@ -83,6 +113,8 @@ export function createSubtitleGenerationRuntime(deps: SubtitleGenerationRuntimeD
let lastResult: SubtitleGenerationResult | null = null;
let selectedModel: SubtitleGenerationModelId | null = null;
let vadEnabled: boolean | null = null;
// Shown as the modal's media while a YouTube job runs; YouTube is never local media.
let youtubeJobUrl: string | null = null;
let accelerationCheck:
| {
path: string;
@@ -155,7 +187,11 @@ export function createSubtitleGenerationRuntime(deps: SubtitleGenerationRuntimeD
config,
deps.getModelDirectory(),
);
const mediaPath = await currentLocalMedia(deps.getMpvClient()).catch(() => null);
const client = deps.getMpvClient();
const mediaPath =
youtubeJobUrl ??
(await currentLocalMedia(client).catch(() => null)) ??
(deps.generateYoutube ? await currentYoutubeMedia(client).catch(() => null) : null);
return {
model,
vad: {
@@ -177,8 +213,42 @@ export function createSubtitleGenerationRuntime(deps: SubtitleGenerationRuntimeD
};
}
// YouTube subtitles only exist in mpv, so the video pauses while they generate and resumes
// afterwards. Closing the modal leaves the job running; unpausing is up to the user.
async function startYoutube(
client: GenerationMpvClient,
url: string,
config: SubtitleGenerationConfig,
signal: AbortSignal,
generate: NonNullable<SubtitleGenerationRuntimeDeps['generateYoutube']>,
): Promise<SubtitleGenerationResult> {
const resume = (await client.requestProperty('pause')) === false;
if (resume) await client.request(['set_property', 'pause', true]);
youtubeJobUrl = url;
const stillPlaying = async () =>
deps.getMpvClient() === client &&
(await currentYoutubeMedia(client).catch(() => null)) === url;
try {
const outputPath = await generate({ url, config, signal, onProgress: report });
if (signal.aborted || !(await stillPlaying()))
return { ok: false, message: 'Playback changed, so the subtitles were not loaded.' };
await loadGeneratedSubtitles(client, outputPath);
return { ok: true, outputPath, message: 'Japanese subtitles generated and loaded.' };
} finally {
youtubeJobUrl = null;
if (resume && (await stillPlaying()))
await client.request(['set_property', 'pause', false]).catch(() => undefined);
}
}
return {
getStatus,
/** Stops a YouTube job once its video is no longer playing, which also deletes its audio. */
handleMediaPathChange(mediaPath: string | null | undefined): void {
if (!youtubeJobUrl) return;
if (extractYoutubeVideoId(mediaPath) !== extractYoutubeVideoId(youtubeJobUrl))
controller?.abort();
},
async setVadEnabled(enabled: boolean): Promise<SubtitleGenerationStatus> {
if (controller)
throw new Error('Wait for the current operation before changing speech detection.');
@@ -210,6 +280,39 @@ export function createSubtitleGenerationRuntime(deps: SubtitleGenerationRuntimeD
cancel(): void {
controller?.abort();
},
/**
* Runs YouTube Whisper generation as the active operation, so the modal shows its progress
* and its Cancel button stops it. Aborting `signal` stops it too. Resolves to the written
* subtitle file, or null when the job was cancelled.
*/
async generateYoutubeSubtitles(input: {
url: string;
signal: AbortSignal;
generate: (
signal: AbortSignal,
onProgress: (progress: SubtitleGenerationProgress) => void,
) => Promise<string>;
}): Promise<string | null> {
let cancelled = false;
let outputPath: string | null = null;
const result = await run(async (signal) => {
const stop = () => controller?.abort();
input.signal.addEventListener('abort', stop, { once: true });
if (input.signal.aborted) stop();
youtubeJobUrl = input.url;
try {
outputPath = await input.generate(signal, report);
return { ok: true, outputPath, message: 'Japanese subtitles generated.' };
} finally {
cancelled = signal.aborted;
input.signal.removeEventListener('abort', stop);
youtubeJobUrl = null;
}
});
if (result.ok) return outputPath;
if (cancelled) return null;
throw new Error(result.message);
},
download(): Promise<SubtitleGenerationResult> {
return run(async (signal) => {
await (deps.download ?? downloadSubtitleGenerationModel)({
@@ -236,9 +339,12 @@ export function createSubtitleGenerationRuntime(deps: SubtitleGenerationRuntimeD
if (vad.kind === 'invalid') throw new Error(vad.message);
}
const client = deps.getMpvClient();
const youtubeUrl = deps.generateYoutube ? await currentYoutubeMedia(client) : null;
if (client && youtubeUrl && deps.generateYoutube)
return await startYoutube(client, youtubeUrl, config, signal, deps.generateYoutube);
const mediaPath = await currentLocalMedia(client);
if (!client || !mediaPath)
throw new Error('Open a local video or audio file in mpv first.');
throw new Error('Open a local video or audio file, or a YouTube video, in mpv first.');
const tracks = await client.requestProperty('track-list');
const audioStreamIndex = selectedAudioIndex(tracks);
const references = await readSubtitleGenerationReferences(tracks, (name) =>
@@ -260,16 +366,7 @@ export function createSubtitleGenerationRuntime(deps: SubtitleGenerationRuntimeD
try {
const playingMedia = await currentLocalMedia(client);
if (!signal.aborted && deps.getMpvClient() === client && playingMedia === mediaPath) {
const loaded = await client.request([
'sub-add',
outputPath,
'select',
'Generated Japanese',
'ja',
]);
if (loaded.error && loaded.error !== 'success') throw new Error(loaded.error);
const delay = await client.request(['set_property', 'sub-delay', 0]);
if (delay.error && delay.error !== 'success') throw new Error(delay.error);
await loadGeneratedSubtitles(client, outputPath);
return {
ok: true,
outputPath,
@@ -51,6 +51,7 @@ test('build tray template handler wires actions and init guards', () => {
handlers.openYomitanSettings();
handlers.openConfigSettings();
handlers.openSyncUi();
handlers.openYoutubeBrowser();
handlers.exportLogs();
handlers.openJellyfinSetup();
handlers.toggleJellyfinDiscovery(true);
@@ -76,6 +77,7 @@ test('build tray template handler wires actions and init guards', () => {
openYomitanSettings: () => calls.push('yomitan'),
openConfigSettingsWindow: () => calls.push('configuration'),
openSyncUiWindow: () => calls.push('sync-ui'),
openYoutubeBrowserWindow: () => calls.push('youtube'),
exportLogs: () => calls.push('export-logs'),
openJellyfinSetupWindow: () => calls.push('jellyfin'),
isJellyfinConfigured: () => true,
@@ -102,6 +104,7 @@ test('build tray template handler wires actions and init guards', () => {
'yomitan',
'configuration',
'sync-ui',
'youtube',
'export-logs',
'jellyfin',
'jellyfin-discovery:true',
@@ -134,6 +137,7 @@ test('windows mpv launcher tray action force-opens completed setup', () => {
openYomitanSettings: () => calls.push('yomitan'),
openConfigSettingsWindow: () => calls.push('configuration'),
openSyncUiWindow: () => calls.push('configuration'),
openYoutubeBrowserWindow: () => calls.push('youtube'),
exportLogs: () => calls.push('export-logs'),
openJellyfinSetupWindow: () => calls.push('jellyfin'),
isJellyfinConfigured: () => false,
+5
View File
@@ -53,6 +53,7 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
openYomitanSettings: () => void;
openConfigSettings: () => void;
openSyncUi: () => void;
openYoutubeBrowser: () => void;
exportLogs: () => void;
openJellyfinSetup: () => void;
showJellyfinDiscovery: boolean;
@@ -76,6 +77,7 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
openYomitanSettings: () => void;
openConfigSettingsWindow: () => void;
openSyncUiWindow: () => void;
openYoutubeBrowserWindow: () => void;
exportLogs: () => void;
openJellyfinSetupWindow: () => void;
isJellyfinConfigured: () => boolean;
@@ -124,6 +126,9 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
openSyncUi: () => {
deps.openSyncUiWindow();
},
openYoutubeBrowser: () => {
deps.openYoutubeBrowserWindow();
},
exportLogs: () => {
deps.exportLogs();
},
+2
View File
@@ -36,6 +36,7 @@ test('tray main deps builders return mapped handlers', () => {
openYomitanSettings: () => calls.push('yomitan'),
openConfigSettingsWindow: () => calls.push('configuration'),
openSyncUiWindow: () => calls.push('sync-ui'),
openYoutubeBrowserWindow: () => calls.push('youtube'),
exportLogs: () => calls.push('export-logs'),
openJellyfinSetupWindow: () => calls.push('jellyfin'),
isJellyfinConfigured: () => true,
@@ -65,6 +66,7 @@ test('tray main deps builders return mapped handlers', () => {
openYomitanSettings: () => calls.push('open-yomitan'),
openConfigSettings: () => calls.push('open-configuration'),
openSyncUi: () => {},
openYoutubeBrowser: () => {},
exportLogs: () => calls.push('open-export-logs'),
openJellyfinSetup: () => calls.push('open-jellyfin'),
showJellyfinDiscovery: true,
+3
View File
@@ -43,6 +43,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
openYomitanSettings: () => void;
openConfigSettings: () => void;
openSyncUi: () => void;
openYoutubeBrowser: () => void;
exportLogs: () => void;
openJellyfinSetup: () => void;
showJellyfinDiscovery: boolean;
@@ -66,6 +67,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
openYomitanSettings: () => void;
openConfigSettingsWindow: () => void;
openSyncUiWindow: () => void;
openYoutubeBrowserWindow: () => void;
exportLogs: () => void;
openJellyfinSetupWindow: () => void;
isJellyfinConfigured: () => boolean;
@@ -93,6 +95,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
openYomitanSettings: deps.openYomitanSettings,
openConfigSettingsWindow: deps.openConfigSettingsWindow,
openSyncUiWindow: deps.openSyncUiWindow,
openYoutubeBrowserWindow: deps.openYoutubeBrowserWindow,
exportLogs: deps.exportLogs,
openJellyfinSetupWindow: deps.openJellyfinSetupWindow,
isJellyfinConfigured: deps.isJellyfinConfigured,
@@ -36,6 +36,7 @@ test('tray runtime handlers compose resolve/menu/ensure/destroy handlers', () =>
openYomitanSettings: () => {},
openConfigSettingsWindow: () => {},
openSyncUiWindow: () => {},
openYoutubeBrowserWindow: () => {},
exportLogs: () => {},
openJellyfinSetupWindow: () => {},
isJellyfinConfigured: () => false,
+8
View File
@@ -45,6 +45,7 @@ for (const dictionaryBackend of ['yomitan', 'hachidori'] as const) {
openYomitanSettings: () => calls.push('yomitan'),
openConfigSettings: () => calls.push('configuration'),
openSyncUi: () => calls.push('sync-ui'),
openYoutubeBrowser: () => calls.push('youtube'),
exportLogs: () => calls.push('export-logs'),
openJellyfinSetup: () => calls.push('jellyfin'),
showJellyfinDiscovery: true,
@@ -74,6 +75,7 @@ for (const dictionaryBackend of ['yomitan', 'hachidori'] as const) {
settingsLabel,
'Open SubMiner Settings',
'Sync Stats && History',
'Browse YouTube',
'Export Logs',
'Configure Jellyfin',
'Jellyfin Discovery',
@@ -98,6 +100,7 @@ for (const dictionaryBackend of ['yomitan', 'hachidori'] as const) {
entryFor('View Changelog').click?.();
entryFor('Open Texthooker').click?.();
entryFor('Sync Stats && History').click?.();
entryFor('Browse YouTube').click?.();
entryFor('Export Logs').click?.();
entryFor('Check for Updates').click?.();
calls.push(template.some((entry) => entry.type === 'separator') ? 'separator' : 'bad');
@@ -109,6 +112,7 @@ for (const dictionaryBackend of ['yomitan', 'hachidori'] as const) {
'changelog',
'texthooker',
'sync-ui',
'youtube',
'export-logs',
'updates',
'separator',
@@ -132,6 +136,7 @@ test('tray menu template omits first-run setup entry when setup is complete', ()
openYomitanSettings: () => undefined,
openConfigSettings: () => undefined,
openSyncUi: () => undefined,
openYoutubeBrowser: () => undefined,
exportLogs: () => undefined,
openJellyfinSetup: () => undefined,
showJellyfinDiscovery: false,
@@ -164,6 +169,7 @@ test('tray menu template omits texthooker entry when texthooker page is disabled
openYomitanSettings: () => undefined,
openConfigSettings: () => undefined,
openSyncUi: () => undefined,
openYoutubeBrowser: () => undefined,
exportLogs: () => undefined,
openJellyfinSetup: () => undefined,
showJellyfinDiscovery: false,
@@ -194,6 +200,7 @@ test('tray menu template renders active jellyfin discovery checkbox', () => {
openYomitanSettings: () => undefined,
openConfigSettings: () => undefined,
openSyncUi: () => undefined,
openYoutubeBrowser: () => undefined,
exportLogs: () => undefined,
openJellyfinSetup: () => undefined,
showJellyfinDiscovery: true,
@@ -225,6 +232,7 @@ test('tray menu template renders a visible linux discovery check mark when activ
openYomitanSettings: () => undefined,
openConfigSettings: () => undefined,
openSyncUi: () => undefined,
openYoutubeBrowser: () => undefined,
exportLogs: () => undefined,
openJellyfinSetup: () => undefined,
showJellyfinDiscovery: true,
+5
View File
@@ -46,6 +46,7 @@ export type TrayMenuActionHandlers = {
openYomitanSettings: () => void;
openConfigSettings: () => void;
openSyncUi: () => void;
openYoutubeBrowser: () => void;
exportLogs: () => void;
openJellyfinSetup: () => void;
showJellyfinDiscovery: boolean;
@@ -123,6 +124,10 @@ export function buildTrayMenuTemplateRuntime(handlers: TrayMenuActionHandlers):
label: 'Sync Stats && History',
click: handlers.openSyncUi,
},
{
label: 'Browse YouTube',
click: handlers.openYoutubeBrowser,
},
{
label: 'Export Logs',
click: handlers.exportLogs,
@@ -0,0 +1,210 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createYoutubeBrowserPlaybackRuntime,
parseYoutubeBrowserVideoRequest,
type YoutubeBrowserPlaybackDeps,
} from './youtube-browser-playback';
const VIDEO_A = 'https://www.youtube.com/watch?v=aaaaaaaaaaa';
const VIDEO_B = 'https://www.youtube.com/watch?v=bbbbbbbbbbb';
const VIDEO_C = 'https://www.youtube.com/watch?v=ccccccccccc';
function createHarness(overrides: Partial<YoutubeBrowserPlaybackDeps> = {}) {
const calls: string[] = [];
const pendingFlows: Array<() => void> = [];
let mpvPlaying = false;
const runtime = createYoutubeBrowserPlaybackRuntime({
isMpvPlaying: () => mpvPlaying,
ensureMpvReady: async () => true,
runPlaybackFlow: (url) => {
calls.push(`flow:${url}`);
return new Promise<void>((resolve) => pendingFlows.push(resolve));
},
appendToMpvPlaylist: (url) => calls.push(`append:${url}`),
notifyFailure: (message) => calls.push(`notify:${message}`),
logWarn: () => {},
onStartupFailed: () => calls.push('startup-failed'),
...overrides,
});
return {
runtime,
calls,
setMpvPlaying: (next: boolean) => {
mpvPlaying = next;
},
finishFlows: async () => {
for (const resolve of pendingFlows.splice(0)) resolve();
await new Promise((resolve) => setImmediate(resolve));
},
};
}
test('parseYoutubeBrowserVideoRequest accepts play and queue requests only', () => {
assert.deepEqual(parseYoutubeBrowserVideoRequest({ action: 'queue', url: VIDEO_A }), {
action: 'queue',
url: VIDEO_A,
});
assert.equal(parseYoutubeBrowserVideoRequest({ action: 'delete', url: VIDEO_A }), null);
assert.equal(parseYoutubeBrowserVideoRequest({ action: 'play' }), null);
assert.equal(parseYoutubeBrowserVideoRequest(null), null);
});
test('play runs the playback flow with a single-video url', async () => {
const { runtime, calls } = createHarness();
const result = await runtime.openVideo({
action: 'play',
url: `${VIDEO_A}&list=WL&index=2`,
});
assert.deepEqual(result, { ok: true, message: 'Opening in mpv' });
assert.deepEqual(calls, [`flow:${VIDEO_A}`]);
});
test('queue appends to mpv while something is playing and plays otherwise', async () => {
const { runtime, calls, setMpvPlaying } = createHarness();
await runtime.openVideo({ action: 'queue', url: VIDEO_A });
runtime.handleMediaPathChange(VIDEO_A);
setMpvPlaying(true);
const queued = await runtime.openVideo({ action: 'queue', url: VIDEO_B });
assert.deepEqual(queued, { ok: true, message: 'Queued in mpv' });
assert.deepEqual(calls, [`flow:${VIDEO_A}`, `append:${VIDEO_B}`]);
});
test('queue requests while the first video starts append behind it in order', async () => {
const { runtime, calls } = createHarness();
await runtime.openVideo({ action: 'play', url: VIDEO_A });
assert.equal(runtime.isStartingPlayback(), true);
assert.deepEqual(await runtime.openVideo({ action: 'queue', url: VIDEO_B }), {
ok: true,
message: 'Queued in mpv',
});
await runtime.openVideo({ action: 'queue', url: VIDEO_C });
assert.deepEqual(calls, [`flow:${VIDEO_A}`]);
runtime.handleMediaPathChange(VIDEO_A);
await new Promise((resolve) => setImmediate(resolve));
assert.equal(runtime.isStartingPlayback(), false);
assert.deepEqual(calls, [`flow:${VIDEO_A}`, `append:${VIDEO_B}`, `append:${VIDEO_C}`]);
});
test('queue requests play instead when the first video fails to start', async () => {
let failFlow = true;
const calls: string[] = [];
const { runtime } = createHarness({
runPlaybackFlow: async (url) => {
calls.push(`flow:${url}`);
if (failFlow) {
failFlow = false;
throw new Error('no formats');
}
},
// The fallback play for the queued video must already count as starting.
onStartupFailed: () => calls.push(`startup-failed:${runtime.isStartingPlayback()}`),
});
await runtime.openVideo({ action: 'play', url: VIDEO_A });
await runtime.openVideo({ action: 'queue', url: VIDEO_B });
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(calls, [`flow:${VIDEO_A}`, 'startup-failed:true', `flow:${VIDEO_B}`]);
});
test('repeat play clicks while mpv is starting open the video once', async () => {
let releaseMpv: (ready: boolean) => void = () => {};
const { runtime, calls } = createHarness({
ensureMpvReady: () =>
new Promise<boolean>((resolve) => {
releaseMpv = resolve;
}),
});
const first = runtime.openVideo({ action: 'play', url: VIDEO_A });
const second = await runtime.openVideo({ action: 'play', url: VIDEO_A });
releaseMpv(true);
await first;
assert.deepEqual(second, { ok: true, message: 'Already opening in mpv' });
assert.deepEqual(calls, [`flow:${VIDEO_A}`]);
});
test('rejects non-video links and reports when mpv cannot start', async () => {
const { runtime, calls } = createHarness({ ensureMpvReady: async () => false });
assert.equal(
(await runtime.openVideo({ action: 'play', url: 'https://www.youtube.com/' })).ok,
false,
);
assert.deepEqual(await runtime.openVideo({ action: 'play', url: VIDEO_A }), {
ok: false,
message: 'Could not start mpv.',
});
assert.deepEqual(calls, ['startup-failed']);
});
test('advancing onto a queued video runs the playback flow for it', async () => {
const { runtime, calls, setMpvPlaying, finishFlows } = createHarness();
await runtime.openVideo({ action: 'play', url: VIDEO_A });
runtime.handleMediaPathChange(VIDEO_A);
await finishFlows();
setMpvPlaying(true);
await runtime.openVideo({ action: 'queue', url: VIDEO_B });
runtime.handleMediaPathChange('');
runtime.handleMediaPathChange(VIDEO_B);
runtime.handleMediaPathChange(VIDEO_B);
assert.deepEqual(calls, [`flow:${VIDEO_A}`, `append:${VIDEO_B}`, `flow:${VIDEO_B}`]);
});
test('path changes are ignored while a flow is loading its own video', async () => {
const { runtime, calls } = createHarness();
await runtime.openVideo({ action: 'play', url: VIDEO_A });
await runtime.openVideo({ action: 'play', url: VIDEO_B });
runtime.handleMediaPathChange(VIDEO_A);
runtime.handleMediaPathChange(VIDEO_B);
assert.deepEqual(calls, [`flow:${VIDEO_A}`, `flow:${VIDEO_B}`]);
});
test('a stuck flow does not block queued videos from getting theirs', async () => {
const { runtime, calls, setMpvPlaying } = createHarness();
await runtime.openVideo({ action: 'play', url: VIDEO_A });
runtime.handleMediaPathChange(VIDEO_A);
setMpvPlaying(true);
await runtime.openVideo({ action: 'queue', url: VIDEO_B });
runtime.handleMediaPathChange(VIDEO_B);
assert.deepEqual(calls, [`flow:${VIDEO_A}`, `append:${VIDEO_B}`, `flow:${VIDEO_B}`]);
});
test('a queued video gets the flow only the first time mpv reaches it', async () => {
const { runtime, calls, setMpvPlaying, finishFlows } = createHarness();
setMpvPlaying(true);
await runtime.openVideo({ action: 'queue', url: VIDEO_B });
runtime.handleMediaPathChange(VIDEO_B);
await finishFlows();
// A later launcher-started play of the same video runs its own flow.
runtime.handleMediaPathChange(VIDEO_A);
runtime.handleMediaPathChange(VIDEO_B);
assert.deepEqual(calls, [`append:${VIDEO_B}`, `flow:${VIDEO_B}`]);
});
test('videos not sent from the browser never trigger the flow', async () => {
const { runtime, calls } = createHarness();
runtime.handleMediaPathChange(VIDEO_A);
assert.deepEqual(calls, []);
});
test('mpv disconnect forgets queued videos', async () => {
const { runtime, calls, setMpvPlaying, finishFlows } = createHarness();
setMpvPlaying(true);
await runtime.openVideo({ action: 'queue', url: VIDEO_B });
runtime.handleMpvDisconnected();
runtime.handleMediaPathChange(VIDEO_B);
await finishFlows();
assert.deepEqual(calls, [`append:${VIDEO_B}`]);
});
test('flow failures are reported', async () => {
const { runtime, calls } = createHarness({
runPlaybackFlow: async () => {
throw new Error('mpv went away');
},
});
await runtime.openVideo({ action: 'play', url: VIDEO_A });
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(calls, ['notify:YouTube playback failed: mpv went away', 'startup-failed']);
});
@@ -0,0 +1,186 @@
import { extractYoutubeVideoId, toYoutubeWatchUrl } from './youtube-playback';
export type YoutubeBrowserVideoAction = 'play' | 'queue';
export type YoutubeBrowserVideoRequest = {
action: YoutubeBrowserVideoAction;
url: string;
};
export type YoutubeBrowserVideoResult = {
ok: boolean;
message: string;
};
export type YoutubeBrowserPlaybackDeps = {
/** True when mpv is connected and has a file loaded, so appending queues behind it. */
isMpvPlaying: () => boolean;
/** Connect to (or launch) mpv so the playback flow has a player to drive. */
ensureMpvReady: () => Promise<boolean>;
/** SubMiner's YouTube playback flow: load the video, pick subtitles, release the play gate. */
runPlaybackFlow: (url: string) => Promise<void>;
appendToMpvPlaylist: (url: string) => void;
notifyFailure: (message: string) => void;
logWarn: (message: string) => void;
/** A play request ended without loading its video, after queued requests behind it were released. */
onStartupFailed: () => void;
};
export function parseYoutubeBrowserVideoRequest(
payload: unknown,
): YoutubeBrowserVideoRequest | null {
if (typeof payload !== 'object' || payload === null) return null;
const { action, url } = payload as Record<string, unknown>;
if ((action !== 'play' && action !== 'queue') || typeof url !== 'string') return null;
return { action, url };
}
/**
* Hands videos picked in the YouTube browser window to mpv. Queued videos land in mpv's own
* playlist; when mpv advances onto one, `handleMediaPathChange` runs the playback flow for it so
* queued videos get the same subtitle setup as videos played directly.
*/
type PlaybackStartup = {
videoId: string;
/** Resolves true once mpv has the video loaded, false if mpv or the flow failed first. */
loaded: Promise<boolean>;
settle: (loaded: boolean) => void;
};
export function createYoutubeBrowserPlaybackRuntime(deps: YoutubeBrowserPlaybackDeps) {
// Videos queued from the browser that mpv has not reached yet. Only these get the flow on
// playlist advance; launcher-started YouTube playback already runs its own flow.
const queuedVideoIds = new Set<string>();
// Videos whose flow is still loading; the flow issues its own loadfile, so the path change
// that follows must not start a second flow for the same video.
const loadingVideoIds = new Set<string>();
// The latest play request while mpv starts and loads it. Queue requests arriving meanwhile wait
// on it so they append behind that video instead of starting a second flow that replaces it.
let startup: PlaybackStartup | null = null;
let lastSeenVideoId: string | null = null;
const beginStartup = (videoId: string): PlaybackStartup => {
let resolveLoaded: (loaded: boolean) => void = () => {};
let settled = false;
const entry: PlaybackStartup = {
videoId,
loaded: new Promise<boolean>((resolve) => {
resolveLoaded = resolve;
}),
settle: (loaded) => {
if (settled) return;
settled = true;
if (startup === entry) startup = null;
resolveLoaded(loaded);
// Registered after the queued requests' handlers, so one that plays instead has already
// begun its own startup by the time this runs.
if (!loaded) void entry.loaded.then(() => deps.onStartupFailed());
},
};
startup = entry;
return entry;
};
/** Runs the playback flow; resolves false (after reporting) when it fails. */
const startFlow = async (url: string, videoId: string): Promise<boolean> => {
loadingVideoIds.add(videoId);
try {
await deps.runPlaybackFlow(url);
return true;
} catch (error: unknown) {
const reason = error instanceof Error ? error.message : String(error);
deps.logWarn(`YouTube browser playback failed for ${url}: ${reason}`);
deps.notifyFailure(`YouTube playback failed: ${reason}`);
return false;
} finally {
loadingVideoIds.delete(videoId);
}
};
const appendToMpv = (url: string, videoId: string): YoutubeBrowserVideoResult => {
queuedVideoIds.add(videoId);
deps.appendToMpvPlaylist(url);
return { ok: true, message: 'Queued in mpv' };
};
const queueBehindStartup = (
pending: PlaybackStartup,
request: YoutubeBrowserVideoRequest,
url: string,
videoId: string,
): void => {
// Chained in click order. If the earlier video never loaded, this one plays instead.
void pending.loaded
.then((loaded) => (loaded ? appendToMpv(url, videoId) : openVideo(request)))
.then(
(result) => {
if (!result.ok) deps.notifyFailure(`YouTube queue failed: ${result.message}`);
},
(error: unknown) => {
const reason = error instanceof Error ? error.message : String(error);
deps.logWarn(`YouTube browser queue failed for ${url}: ${reason}`);
deps.notifyFailure(`YouTube queue failed: ${reason}`);
},
);
};
const openVideo = async (
request: YoutubeBrowserVideoRequest,
): Promise<YoutubeBrowserVideoResult> => {
const url = toYoutubeWatchUrl(request.url);
const videoId = extractYoutubeVideoId(url);
if (!url || !videoId) {
return { ok: false, message: 'Not a YouTube video link.' };
}
if (request.action === 'queue') {
if (startup) {
queueBehindStartup(startup, request, url, videoId);
return { ok: true, message: 'Queued in mpv' };
}
if (deps.isMpvPlaying()) return appendToMpv(url, videoId);
}
// Starting mpv can take seconds; repeat clicks during that wait must not start a second flow.
if (loadingVideoIds.has(videoId)) {
return { ok: true, message: 'Already opening in mpv' };
}
loadingVideoIds.add(videoId);
const entry = beginStartup(videoId);
let mpvReady = false;
try {
mpvReady = await deps.ensureMpvReady();
} finally {
if (!mpvReady) {
loadingVideoIds.delete(videoId);
entry.settle(false);
}
}
if (!mpvReady) {
return { ok: false, message: 'Could not start mpv.' };
}
void startFlow(url, videoId).then(entry.settle);
return { ok: true, message: 'Opening in mpv' };
};
const handleMediaPathChange = (mediaPath: string | null | undefined): void => {
const videoId = extractYoutubeVideoId(mediaPath);
const previousVideoId = lastSeenVideoId;
lastSeenVideoId = videoId;
if (videoId && startup?.videoId === videoId) startup.settle(true);
if (!videoId || videoId === previousVideoId || loadingVideoIds.has(videoId)) return;
if (!queuedVideoIds.delete(videoId)) return;
const url = toYoutubeWatchUrl(mediaPath);
if (url) void startFlow(url, videoId);
};
const handleMpvDisconnected = (): void => {
queuedVideoIds.clear();
lastSeenVideoId = null;
};
/** True while a browser play request is still starting mpv or loading its video. */
const isStartingPlayback = (): boolean => startup !== null;
return { openVideo, handleMediaPathChange, handleMpvDisconnected, isStartingPlayback };
}
@@ -0,0 +1,12 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildYoutubeBrowserUserAgent } from './youtube-browser-window';
test('buildYoutubeBrowserUserAgent strips Electron and app tokens from the user agent', () => {
assert.equal(
buildYoutubeBrowserUserAgent(
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) SubMiner/0.19.6 Chrome/140.0.7339.41 Electron/42.6.0 Safari/537.36',
),
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.7339.41 Safari/537.36',
);
});
+214
View File
@@ -0,0 +1,214 @@
import type {
BrowserWindow,
BrowserWindowConstructorOptions,
IpcMain,
MenuItemConstructorOptions,
Session,
} from 'electron';
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
import {
parseYoutubeBrowserVideoRequest,
type YoutubeBrowserVideoRequest,
type YoutubeBrowserVideoResult,
} from './youtube-browser-playback';
import { toYoutubeWatchUrl } from './youtube-playback';
/** Persistent partition: cookies (and so the YouTube login) survive app restarts. */
export const YOUTUBE_BROWSER_PARTITION = 'persist:youtube-browser';
export const YOUTUBE_BROWSER_HOME_URL = 'https://www.youtube.com/';
const ALLOWED_PERMISSIONS = new Set(['fullscreen', 'clipboard-sanitized-write']);
const STANDARD_UA_PRODUCTS = '(?:Mozilla|AppleWebKit|Chrome|Safari)';
const NON_BROWSER_UA_TOKEN = new RegExp(
`\\s+(?!${STANDARD_UA_PRODUCTS}/)[A-Za-z][\\w.-]*/\\S+`,
'g',
);
/**
* Plain Chrome user agent. Google refuses sign-in from agents that advertise Electron or an
* embedding app, so every product token other than the standard Chrome ones is dropped.
*/
export function buildYoutubeBrowserUserAgent(defaultUserAgent: string): string {
return defaultUserAgent.replace(NON_BROWSER_UA_TOKEN, '').trim();
}
export type YoutubeBrowserWindowDeps = {
createBrowserWindow: (options: BrowserWindowConstructorOptions) => BrowserWindow;
preloadPath: string;
ipcMain: Pick<IpcMain, 'on'>;
showContextMenu: (window: BrowserWindow, template: MenuItemConstructorOptions[]) => void;
openExternal: (url: string) => void;
openVideo: (request: YoutubeBrowserVideoRequest) => Promise<YoutubeBrowserVideoResult>;
logWarn: (message: string, error?: unknown) => void;
logDebug: (message: string) => void;
onClosed?: () => void;
};
function isHttpUrl(url: string): boolean {
return url.startsWith('https://') || url.startsWith('http://');
}
function configureSession(session: Session): void {
session.setUserAgent(buildYoutubeBrowserUserAgent(session.getUserAgent()));
session.setPermissionRequestHandler((_webContents, permission, callback) => {
callback(ALLOWED_PERMISSIONS.has(permission));
});
}
/**
* A logged-in YouTube window whose video links open in mpv instead of the page player.
* The preload intercepts link clicks; navigation hooks here catch anything that slips past it.
*/
export function createYoutubeBrowserWindowRuntime(deps: YoutubeBrowserWindowDeps) {
let browserWindow: BrowserWindow | null = null;
let ipcRegistered = false;
// `trigger` names the path that caught the video, to tell duplicate requests apart in logs.
const sendVideo = (request: YoutubeBrowserVideoRequest, trigger: string): void => {
deps.logDebug(`YouTube browser ${request.action} via ${trigger}: ${request.url}`);
void deps
.openVideo(request)
.catch((error: unknown): YoutubeBrowserVideoResult => {
deps.logWarn('YouTube browser failed to open video in mpv', error);
return { ok: false, message: 'Could not open the video in mpv.' };
})
.then((result) => {
if (browserWindow && !browserWindow.isDestroyed()) {
browserWindow.webContents.send(IPC_CHANNELS.event.youtubeBrowserToast, result);
}
});
};
const registerIpc = (): void => {
if (ipcRegistered) return;
ipcRegistered = true;
deps.ipcMain.on(IPC_CHANNELS.command.youtubeBrowserOpenVideo, (event, payload: unknown) => {
if (!browserWindow || event.sender !== browserWindow.webContents) return;
const request = parseYoutubeBrowserVideoRequest(payload);
if (request) sendVideo(request, 'click');
});
};
const buildContextMenu = (window: BrowserWindow, linkUrl: string) => {
const history = window.webContents.navigationHistory;
const videoItems: MenuItemConstructorOptions[] = toYoutubeWatchUrl(linkUrl)
? [
{
label: 'Play in mpv',
click: () => sendVideo({ action: 'play', url: linkUrl }, 'context-menu'),
},
{
label: 'Queue in mpv',
click: () => sendVideo({ action: 'queue', url: linkUrl }, 'context-menu'),
},
{ type: 'separator' },
]
: [];
return [
...videoItems,
{ label: 'Back', enabled: history.canGoBack(), click: () => history.goBack() },
{ label: 'Forward', enabled: history.canGoForward(), click: () => history.goForward() },
{ label: 'Reload', click: () => window.webContents.reload() },
] satisfies MenuItemConstructorOptions[];
};
const createWindow = (): BrowserWindow => {
const window = deps.createBrowserWindow({
width: 1280,
height: 860,
title: 'SubMiner YouTube',
autoHideMenuBar: true,
backgroundColor: '#0f0f0f',
webPreferences: {
partition: YOUTUBE_BROWSER_PARTITION,
preload: deps.preloadPath,
nodeIntegration: false,
contextIsolation: true,
sandbox: true,
},
});
const { webContents } = window;
const { session } = webContents;
configureSession(session);
webContents.setWindowOpenHandler(({ url }) => {
if (toYoutubeWatchUrl(url)) {
sendVideo({ action: 'queue', url }, 'window-open');
} else if (isHttpUrl(url)) {
deps.openExternal(url);
}
return { action: 'deny' };
});
// Full page loads of a video (e.g. from a redirect) never reach the preload's click hook.
webContents.on('will-navigate', (event, url) => {
if (!toYoutubeWatchUrl(url)) return;
event.preventDefault();
sendVideo({ action: 'play', url }, 'will-navigate');
});
// In-app navigations the preload missed (keyboard shortcuts, script-driven links): stop the
// page player, hand the video to mpv, and return to the page the user came from.
webContents.on('did-navigate-in-page', (_event, url, isMainFrame) => {
if (!isMainFrame || !toYoutubeWatchUrl(url)) return;
void webContents
.executeJavaScript("document.querySelectorAll('video').forEach((v) => v.pause())")
.catch(() => {});
sendVideo({ action: 'play', url }, 'in-page-navigation');
if (webContents.navigationHistory.canGoBack()) {
webContents.navigationHistory.goBack();
} else {
void webContents.loadURL(YOUTUBE_BROWSER_HOME_URL).catch(() => {});
}
});
webContents.on('context-menu', (_event, params) => {
deps.showContextMenu(window, buildContextMenu(window, params.linkURL));
});
webContents.on('before-input-event', (event, input) => {
if (input.type !== 'keyDown') return;
const history = webContents.navigationHistory;
if (input.alt && input.key === 'ArrowLeft' && history.canGoBack()) {
event.preventDefault();
history.goBack();
} else if (input.alt && input.key === 'ArrowRight' && history.canGoForward()) {
event.preventDefault();
history.goForward();
} else if (input.key === 'F5' || ((input.control || input.meta) && input.key === 'r')) {
event.preventDefault();
webContents.reload();
}
});
window.on('closed', () => {
browserWindow = null;
void session.cookies.flushStore().catch((error: unknown) => {
deps.logWarn('Failed to flush YouTube browser cookies', error);
});
deps.onClosed?.();
});
void window.loadURL(YOUTUBE_BROWSER_HOME_URL).catch((error: unknown) => {
// YouTube redirects its landing page (e.g. `?themeRefresh=1`), which aborts the first load.
if ((error as { code?: unknown }).code === 'ERR_ABORTED') return;
deps.logWarn('Failed to load YouTube in the browser window', error);
});
return window;
};
const open = (): void => {
registerIpc();
if (browserWindow && !browserWindow.isDestroyed()) {
if (browserWindow.isMinimized()) browserWindow.restore();
browserWindow.show();
browserWindow.focus();
return;
}
browserWindow = createWindow();
};
const isOpen = (): boolean => Boolean(browserWindow && !browserWindow.isDestroyed());
return { open, isOpen };
}
+137 -2
View File
@@ -1733,7 +1733,6 @@ test('youtube flow injects downloaded primary even when reusable manual youtube
await runtime.runYoutubePlaybackFlow({
url: 'https://example.com/watch?v=video123',
mode: 'download',
});
assert.equal(selectedPrimarySid, 9);
@@ -1884,9 +1883,145 @@ test('youtube flow falls back to existing auto secondary track when auto seconda
await runtime.runYoutubePlaybackFlow({
url: 'https://example.com/watch?v=video123',
mode: 'download',
});
assert.equal(selectedPrimarySid, 4);
assert.equal(selectedSecondarySid, 1);
});
function createWhisperFlowHarness(
generateWhisperSubtitles: (input: {
url: string;
outputPath: string;
signal: AbortSignal;
}) => Promise<string | null>,
) {
const events: string[] = [];
const failures: string[] = [];
let addedSubtitlePath: string | null = null;
const runtime = createYoutubeFlowRuntime({
probeYoutubeTracks: async () => {
throw new Error('Whisper mode must not probe YouTube captions');
},
acquireYoutubeSubtitleTracks: async () => {
throw new Error('Whisper mode must not download YouTube captions');
},
acquireYoutubeSubtitleTrack: async () => {
throw new Error('Whisper mode must not download YouTube captions');
},
openPicker: async () => false,
pauseMpv: () => events.push('pause'),
resumeMpv: () => events.push('resume'),
sendMpvCommand: (command) => {
if (command[0] === 'sub-add') {
addedSubtitlePath = String(command[1]);
events.push('sub-add');
}
if (command[0] === 'script-message') events.push(String(command[1]));
},
requestMpvProperty: async (name) => {
if (name === 'track-list') {
return addedSubtitlePath
? [{ type: 'sub', id: 3, external: true, 'external-filename': addedSubtitlePath }]
: [];
}
if (name === 'sid') return addedSubtitlePath ? 3 : null;
return null;
},
refreshCurrentSubtitle: () => {},
startTokenizationWarmups: async () => {},
waitForTokenizationReady: async () => {},
waitForAnkiReady: async () => {},
wait: async () => {},
waitForPlaybackWindowReady: async () => {},
waitForOverlayGeometryReady: async () => {},
focusOverlayWindow: () => {},
showMpvOsd: () => {},
reportSubtitleFailure: (message) => failures.push(message),
warn: () => {},
log: () => {},
getYoutubeOutputDir: () => '/tmp',
getSubtitleSource: () => 'whisper',
generateWhisperSubtitles: (input) => {
events.push('generate');
return generateWhisperSubtitles(input);
},
openSubtitleGenerationModal: async () => {
events.push('open-modal');
return true;
},
});
return {
runtime,
failures,
// The modal opens once the player window is ready, so its position in the order varies.
events: () => events.filter((event) => event !== 'open-modal'),
openedModal: () => events.includes('open-modal'),
getAddedSubtitlePath: () => addedSubtitlePath,
};
}
const WHISPER_URL = 'https://www.youtube.com/watch?v=abcdefghijk';
test('whisper subtitle source keeps the video paused until the generated subtitles load', async () => {
const harness = createWhisperFlowHarness(async (input) => input.outputPath);
await harness.runtime.runYoutubePlaybackFlow({ url: WHISPER_URL });
assert.deepEqual(harness.events(), [
'pause',
'subminer-autoplay-hold',
'generate',
'sub-add',
'subminer-autoplay-ready',
'resume',
]);
assert.equal(harness.openedModal(), true);
assert.equal(path.basename(harness.getAddedSubtitlePath() ?? ''), 'youtube-whisper.ja.srt');
assert.deepEqual(harness.failures, []);
});
test('cancelling whisper generation resumes playback without subtitles or an error', async () => {
const harness = createWhisperFlowHarness(async () => null);
await harness.runtime.runYoutubePlaybackFlow({ url: WHISPER_URL });
assert.equal(harness.getAddedSubtitlePath(), null);
assert.deepEqual(harness.failures, []);
assert.equal(harness.events().at(-1), 'resume');
});
test('whisper generation stops quietly and leaves playback alone when the video changes', async () => {
let signal: AbortSignal | null = null;
const harness = createWhisperFlowHarness(
(input) =>
new Promise((_, reject) => {
signal = input.signal;
input.signal.addEventListener('abort', () => reject(new Error('cancelled')));
}),
);
const flow = harness.runtime.runYoutubePlaybackFlow({ url: WHISPER_URL });
while (!signal) await new Promise((resolve) => setImmediate(resolve));
harness.runtime.handleMediaPathChange(WHISPER_URL);
assert.equal((signal as AbortSignal).aborted, false);
harness.runtime.handleMediaPathChange('https://www.youtube.com/watch?v=zyxwvutsrqp');
await flow;
assert.equal((signal as AbortSignal).aborted, true);
assert.equal(harness.getAddedSubtitlePath(), null);
assert.deepEqual(harness.failures, []);
assert.equal(harness.events().includes('resume'), false);
});
test('whisper generation failures are reported and playback resumes', async () => {
const harness = createWhisperFlowHarness(async () => {
throw new Error('No Whisper model found.');
});
await harness.runtime.runYoutubePlaybackFlow({ url: WHISPER_URL });
assert.deepEqual(harness.failures, ['Whisper subtitles failed: No Whisper model found.']);
assert.equal(harness.getAddedSubtitlePath(), null);
assert.equal(harness.events().at(-1), 'resume');
});
+158 -19
View File
@@ -5,6 +5,8 @@ import type {
YoutubePickerResolveRequest,
YoutubePickerResolveResult,
} from '../../types';
import type { YoutubeSubtitleSource } from '../../types/integrations';
import type { SubtitleGenerationProgress } from '../../shared/subtitle-generation';
import type {
YoutubeTrackOption,
YoutubeTrackProbeResult,
@@ -18,9 +20,9 @@ import {
acquireYoutubeSubtitleTracks,
} from '../../core/services/youtube/generate';
import { resolveSubtitleSourcePath } from './subtitle-prefetch-source';
import { extractYoutubeVideoId } from './youtube-playback';
type YoutubeFlowOpenPicker = (payload: YoutubePickerOpenPayload) => Promise<boolean>;
type YoutubeFlowMode = 'download' | 'generate';
type YoutubeFlowDeps = {
probeYoutubeTracks: (url: string) => Promise<YoutubeTrackProbeResult>;
@@ -48,6 +50,20 @@ type YoutubeFlowDeps = {
getYoutubeOutputDir: () => string;
createSubtitleTempDir?: () => Promise<string>;
cleanupSubtitleTempDirs?: (dirs: string[]) => void;
/** Current `youtube.subtitleSource`; YouTube captions when absent. */
getSubtitleSource?: () => YoutubeSubtitleSource;
/**
* Transcribes the video's audio into `outputPath`. Resolves to the written file, or null
* when the user cancelled generation.
*/
generateWhisperSubtitles?: (input: {
url: string;
outputPath: string;
signal: AbortSignal;
onProgress: (progress: SubtitleGenerationProgress) => void;
}) => Promise<string | null>;
/** Shows Whisper progress; closing it leaves generation running. */
openSubtitleGenerationModal?: () => Promise<boolean>;
};
type YoutubeFlowSession = {
@@ -58,6 +74,13 @@ type YoutubeFlowSession = {
const YOUTUBE_PICKER_SETTLE_DELAY_MS = 150;
const YOUTUBE_SECONDARY_RETRY_DELAY_MS = 350;
const WHISPER_TRACK: YoutubeTrackOption = {
id: 'whisper:ja',
language: 'ja',
sourceLanguage: 'ja',
kind: 'auto',
label: 'Japanese (Whisper)',
};
function createSessionId(): string {
return `yt-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
@@ -102,6 +125,21 @@ function createYoutubeFlowOsdProgress(showMpvOsd: (text: string) => void) {
};
}
/** Shows Whisper progress, skipping updates that would not change the text. */
function createWhisperProgressReporter(
showMpvOsd: (text: string) => void,
): (progress: SubtitleGenerationProgress) => void {
let lastText = '';
return (progress) => {
const percent =
progress.percent === undefined ? '' : ` ${Math.floor(progress.percent / 5) * 5}%`;
const text = `Whisper: ${progress.message.replace(/\.+$/, '')}${percent}`;
if (text === lastText) return;
lastText = text;
showMpvOsd(text);
};
}
function releasePlaybackGate(deps: YoutubeFlowDeps): void {
deps.sendMpvCommand(['script-message', 'subminer-autoplay-ready']);
deps.resumeMpv();
@@ -429,6 +467,20 @@ async function injectDownloadedSubtitles(
export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
let activeSession: YoutubeFlowSession | null = null;
const activeSubtitleTempDirs = new Set<string>();
let activeWhisperJob: { videoId: string | null; controller: AbortController } | null = null;
/** Aborting also deletes the job's downloaded audio. */
const cancelWhisperGeneration = (): void => {
activeWhisperJob?.controller.abort();
activeWhisperJob = null;
};
const handleMediaPathChange = (mediaPath: string | null | undefined): void => {
if (!activeWhisperJob) return;
const videoId = extractYoutubeVideoId(mediaPath);
if (videoId && videoId === activeWhisperJob.videoId) return;
cancelWhisperGeneration();
};
const cleanupSubtitleTempDirs = (): void => {
const dirs = [...activeSubtitleTempDirs];
@@ -476,6 +528,14 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
}
};
/** A temp dir for subtitles loaded outside the flow; removed with the flow's own. */
const createSubtitleTempDir = async (): Promise<string> => {
if (!deps.createSubtitleTempDir) return normalizeOutputPath(deps.getYoutubeOutputDir());
const dir = await deps.createSubtitleTempDir();
activeSubtitleTempDirs.add(dir);
return dir;
};
const acquireSelectedTracks = async (input: {
targetUrl: string;
outputDir: string;
@@ -604,7 +664,6 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
const loadTracksIntoMpv = async (input: {
url: string;
mode: YoutubeFlowMode;
outputDir: string;
primaryTrack: YoutubeTrackOption;
secondaryTrack: YoutubeTrackOption | null;
@@ -751,10 +810,7 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
}
};
const openManualPicker = async (input: {
url: string;
mode?: YoutubeFlowMode;
}): Promise<void> => {
const openManualPicker = async (input: { url: string }): Promise<void> => {
deps.showMpvOsd('Opening YouTube subtitle picker...');
let probe: YoutubeTrackProbeResult;
@@ -818,11 +874,12 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
});
const secondaryTrack = getTrackById(probe.tracks, selected.secondaryTrackId);
// A track picked by hand replaces whatever Whisper would have loaded.
cancelWhisperGeneration();
try {
deps.showMpvOsd('Getting subtitles...');
const loaded = await loadTracksIntoMpv({
url: input.url,
mode: input.mode ?? 'download',
outputDir: normalizeOutputPath(deps.getYoutubeOutputDir()),
primaryTrack,
secondaryTrack,
@@ -844,10 +901,86 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
}
};
async function runYoutubePlaybackFlow(input: {
url: string;
mode: YoutubeFlowMode;
}): Promise<void> {
const openWhisperProgressModal = async (jobSignal: AbortSignal): Promise<void> => {
if (!deps.openSubtitleGenerationModal) return;
// Like the picker, wait for mpv's window so the modal lands on top of it.
await deps.waitForPlaybackWindowReady();
await deps.waitForOverlayGeometryReady();
if (jobSignal.aborted) return;
if (!(await deps.openSubtitleGenerationModal().catch(() => false))) {
deps.warn('Unable to open the subtitle generation modal for Whisper progress.');
}
};
const loadWhisperSubtitles = async (
url: string,
generate: NonNullable<YoutubeFlowDeps['generateWhisperSubtitles']>,
tokenizationWarmupPromise: Promise<void>,
): Promise<void> => {
const job = { videoId: extractYoutubeVideoId(url), controller: new AbortController() };
activeWhisperJob = job;
// Playback stays paused until the subtitles load. Generation outlasts the mpv plugin's
// pause-until-ready timeout, so ask it to hold the pause instead of resuming on its own.
deps.sendMpvCommand(['script-message', 'subminer-autoplay-hold']);
deps.showMpvOsd('Generating Japanese subtitles with Whisper...');
try {
const outputDir = await prepareSubtitleOutputDir(
normalizeOutputPath(deps.getYoutubeOutputDir()),
);
const generation = generate({
url,
outputPath: path.join(outputDir, 'youtube-whisper.ja.srt'),
signal: job.controller.signal,
onProgress: createWhisperProgressReporter(deps.showMpvOsd),
});
void openWhisperProgressModal(job.controller.signal);
const subtitlePath = await generation;
job.controller.signal.throwIfAborted();
if (subtitlePath === null) {
deps.log('Whisper subtitle generation was cancelled.');
return;
}
const loaded = await injectDownloadedSubtitles(
deps,
{ track: WHISPER_TRACK, existingTrackId: null, injectedPath: subtitlePath },
null,
null,
);
if (!loaded) {
reportPrimarySubtitleFailure();
return;
}
deps.notifyPrimarySubtitleLoaded?.();
try {
await deps.refreshSubtitleSidebarSource?.(subtitlePath, url);
} catch (error) {
deps.warn(
`Failed to refresh parsed subtitle cues for sidebar: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
await tokenizationWarmupPromise;
await deps.waitForTokenizationReady();
await deps.waitForAnkiReady();
} catch (error) {
if (job.controller.signal.aborted) return;
const message = error instanceof Error ? error.message : String(error);
deps.warn(`Failed to generate YouTube subtitles with Whisper: ${message}`);
deps.reportSubtitleFailure(`Whisper subtitles failed: ${message}`);
} finally {
if (activeWhisperJob === job) activeWhisperJob = null;
if (job.controller.signal.aborted) {
// The video changed or closed; playback now belongs to whatever replaced it.
deps.log('Stopped Whisper subtitle generation because the video changed or closed.');
} else {
releasePlaybackGate(deps);
restoreOverlayInputFocus(deps);
}
}
};
async function runYoutubePlaybackFlow(input: { url: string }): Promise<void> {
deps.showMpvOsd('Opening YouTube video');
const tokenizationWarmupPromise = deps.startTokenizationWarmups().catch((error) => {
deps.warn(
@@ -859,6 +992,15 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
deps.pauseMpv();
suppressYoutubeSubtitleState(deps);
cancelWhisperGeneration();
if (deps.getSubtitleSource?.() === 'whisper' && deps.generateWhisperSubtitles) {
await loadWhisperSubtitles(
input.url,
deps.generateWhisperSubtitles,
tokenizationWarmupPromise,
);
return;
}
const outputDir = normalizeOutputPath(deps.getYoutubeOutputDir());
let probe: YoutubeTrackProbeResult;
@@ -890,14 +1032,10 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
deps.showMpvOsd('Getting subtitles...');
const loaded = await loadTracksIntoMpv({
url: input.url,
mode: input.mode,
outputDir,
primaryTrack,
secondaryTrack,
secondaryFailureLabel:
input.mode === 'generate'
? 'Failed to generate secondary YouTube subtitle track'
: 'Failed to download secondary YouTube subtitle track',
secondaryFailureLabel: 'Failed to download secondary YouTube subtitle track',
tokenizationWarmupPromise,
showDownloadProgress: false,
});
@@ -906,9 +1044,7 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
}
} catch (error) {
deps.warn(
`Failed to ${
input.mode === 'generate' ? 'generate' : 'download'
} primary YouTube subtitle track: ${
`Failed to download primary YouTube subtitle track: ${
error instanceof Error ? error.message : String(error)
}`,
);
@@ -926,5 +1062,8 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
cancelActivePicker,
hasActiveSession: () => Boolean(activeSession),
cleanupSubtitleTempDirs,
createSubtitleTempDir,
cancelWhisperGeneration,
handleMediaPathChange,
};
}
@@ -38,8 +38,8 @@ test('youtube playback runtime resets flow ownership after a successful run', as
calls.push(`prepare:${url}`);
return true;
},
runYoutubePlaybackFlow: async ({ url, mode }) => {
calls.push(`run-flow:${url}:${mode}`);
runYoutubePlaybackFlow: async ({ url }) => {
calls.push(`run-flow:${url}`);
},
logInfo: (message) => {
calls.push(`info:${message}`);
@@ -59,7 +59,6 @@ test('youtube playback runtime resets flow ownership after a successful run', as
await runtime.runYoutubePlaybackFlow({
url: 'https://youtu.be/demo',
mode: 'download',
source: 'initial',
});
@@ -117,8 +116,8 @@ test('youtube playback runtime resolves the socket path lazily for windows start
calls.push(`prepare:${url}`);
return true;
},
runYoutubePlaybackFlow: async ({ url, mode }) => {
calls.push(`run-flow:${url}:${mode}`);
runYoutubePlaybackFlow: async ({ url }) => {
calls.push(`run-flow:${url}`);
},
logInfo: (message) => {
calls.push(`info:${message}`);
@@ -140,7 +139,6 @@ test('youtube playback runtime resolves the socket path lazily for windows start
await runtime.runYoutubePlaybackFlow({
url: 'https://youtu.be/demo',
mode: 'download',
source: 'initial',
});
@@ -185,8 +183,8 @@ test('youtube playback runtime starts media cache without blocking the subtitle
await cachePromise;
calls.push('cache-done');
},
runYoutubePlaybackFlow: async ({ url, mode }) => {
calls.push(`run-flow:${url}:${mode}`);
runYoutubePlaybackFlow: async ({ url }) => {
calls.push(`run-flow:${url}`);
},
logInfo: (message) => {
calls.push(`info:${message}`);
@@ -200,13 +198,12 @@ test('youtube playback runtime starts media cache without blocking the subtitle
await runtime.runYoutubePlaybackFlow({
url: 'https://youtu.be/demo',
mode: 'download',
source: 'second-instance',
});
const prepareIndex = calls.indexOf('prepare:https://youtu.be/demo');
const cacheIndex = calls.indexOf('cache:https://youtu.be/demo');
const runFlowIndex = calls.indexOf('run-flow:https://youtu.be/demo:download');
const runFlowIndex = calls.indexOf('run-flow:https://youtu.be/demo');
assert.notEqual(prepareIndex, -1);
assert.notEqual(cacheIndex, -1);
assert.notEqual(runFlowIndex, -1);
@@ -251,8 +248,8 @@ test('youtube playback runtime logs synchronous media cache startup failures', a
calls.push('cache');
throw new Error('cache exploded');
},
runYoutubePlaybackFlow: async ({ url, mode }) => {
calls.push(`run-flow:${url}:${mode}`);
runYoutubePlaybackFlow: async ({ url }) => {
calls.push(`run-flow:${url}`);
},
logInfo: (message) => {
calls.push(`info:${message}`);
@@ -266,12 +263,11 @@ test('youtube playback runtime logs synchronous media cache startup failures', a
await runtime.runYoutubePlaybackFlow({
url: 'https://youtu.be/demo',
mode: 'download',
source: 'second-instance',
});
await Promise.resolve();
assert.ok(calls.includes('run-flow:https://youtu.be/demo:download'));
assert.ok(calls.includes('run-flow:https://youtu.be/demo'));
assert.ok(
calls.some((entry) =>
entry.startsWith('warn:Failed to start YouTube media cache: cache exploded'),
+7 -11
View File
@@ -1,4 +1,7 @@
import type { CliArgs, CliCommandSource } from '../../cli/args';
import type { CliCommandSource } from '../../cli/args';
/** Where a playback flow request came from; only `initial` arms quit-on-disconnect. */
export type YoutubePlaybackFlowSource = CliCommandSource | 'youtube-browser';
type LaunchResult = {
ok: boolean;
@@ -21,10 +24,7 @@ export type YoutubePlaybackRuntimeDeps = {
waitForYoutubeMpvConnected: (timeoutMs: number) => Promise<boolean>;
prepareYoutubePlaybackInMpv: (request: { url: string }) => Promise<boolean>;
startYoutubeMediaCache?: (url: string) => void | Promise<void>;
runYoutubePlaybackFlow: (request: {
url: string;
mode: NonNullable<CliArgs['youtubeMode']>;
}) => Promise<void>;
runYoutubePlaybackFlow: (request: { url: string }) => Promise<void>;
logInfo: (message: string) => void;
logWarn: (message: string) => void;
schedule: (callback: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
@@ -45,8 +45,7 @@ export function createYoutubePlaybackRuntime(deps: YoutubePlaybackRuntimeDeps) {
const runYoutubePlaybackFlow = async (request: {
url: string;
mode: NonNullable<CliArgs['youtubeMode']>;
source: CliCommandSource;
source: YoutubePlaybackFlowSource;
}): Promise<void> => {
const flowGeneration = ++playbackFlowGeneration;
deps.invalidatePendingAutoplayReadyFallbacks();
@@ -139,10 +138,7 @@ export function createYoutubePlaybackRuntime(deps: YoutubePlaybackRuntimeDeps) {
});
}
await deps.runYoutubePlaybackFlow({
url: request.url,
mode: request.mode,
});
await deps.runYoutubePlaybackFlow({ url: request.url });
flowCompleted = true;
deps.logInfo(`YouTube playback flow completed from ${request.source}.`);
} finally {
+23
View File
@@ -1,6 +1,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
toYoutubeWatchUrl,
isSameYoutubeMediaPath,
isYoutubeMediaPath,
isYoutubePlaybackActive,
@@ -76,3 +77,25 @@ test('shouldUseCachedYoutubeParsedCues requires cached cues for the same youtube
false,
);
});
test('toYoutubeWatchUrl reduces watch, shorts, and short links to a single-video url', () => {
assert.equal(
toYoutubeWatchUrl('https://www.youtube.com/watch?v=dQw4w9WgXcQ&list=WL&index=3&t=42s'),
'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
);
assert.equal(
toYoutubeWatchUrl('https://m.youtube.com/shorts/dQw4w9WgXcQ'),
'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
);
assert.equal(
toYoutubeWatchUrl('https://youtu.be/dQw4w9WgXcQ?si=abc'),
'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
);
});
test('toYoutubeWatchUrl rejects non-video pages and malformed ids', () => {
assert.equal(toYoutubeWatchUrl('https://www.youtube.com/feed/subscriptions'), null);
assert.equal(toYoutubeWatchUrl('https://www.youtube.com/watch?v=bad id'), null);
assert.equal(toYoutubeWatchUrl('https://example.com/watch?v=dQw4w9WgXcQ'), null);
assert.equal(toYoutubeWatchUrl('/tmp/video.mkv'), null);
});
+14 -1
View File
@@ -10,7 +10,7 @@ function matchesYoutubeHost(hostname: string, expectedHost: string): boolean {
return hostname === expectedHost || hostname.endsWith(`.${expectedHost}`);
}
function extractYoutubeVideoId(mediaPath: string | null | undefined): string | null {
export function extractYoutubeVideoId(mediaPath: string | null | undefined): string | null {
const normalized = trimToNull(mediaPath);
if (!normalized) {
return null;
@@ -43,6 +43,19 @@ function extractYoutubeVideoId(mediaPath: string | null | undefined): string | n
return null;
}
const YOUTUBE_VIDEO_ID_PATTERN = /^[A-Za-z0-9_-]{11}$/;
/**
* Single-video watch URL for a YouTube link. Drops `list=`/`index=`/`t=` so mpv's ytdl hook
* loads one video instead of expanding the whole playlist.
*/
export function toYoutubeWatchUrl(mediaPath: string | null | undefined): string | null {
const videoId = extractYoutubeVideoId(mediaPath);
return videoId && YOUTUBE_VIDEO_ID_PATTERN.test(videoId)
? `https://www.youtube.com/watch?v=${videoId}`
: null;
}
export function isYoutubeMediaPath(mediaPath: string | null | undefined): boolean {
const normalized = trimToNull(mediaPath);
if (!normalized) {
+97
View File
@@ -0,0 +1,97 @@
import { ipcRenderer } from 'electron';
// Sandboxed preloads can only require `electron`, so these mirror IPC_CHANNELS in
// src/shared/ipc/contracts.ts (youtubeBrowserOpenVideo / youtubeBrowserToast).
const OPEN_VIDEO_CHANNEL = 'youtube-browser:open-video';
const TOAST_CHANNEL = 'youtube-browser:toast';
const MOUSE_BUTTON_MIDDLE = 1;
const MOUSE_BUTTON_BACK = 3;
const MOUSE_BUTTON_FORWARD = 4;
function isYoutubeVideoHref(href: string): boolean {
let url: URL;
try {
url = new URL(href);
} catch {
return false;
}
const host = url.hostname.toLowerCase();
if (host !== 'youtube.com' && !host.endsWith('.youtube.com')) return false;
return (
(url.pathname === '/watch' && url.searchParams.has('v')) ||
/^\/shorts\/[^/]+/.test(url.pathname)
);
}
function findVideoLink(event: MouseEvent): HTMLAnchorElement | null {
for (const node of event.composedPath()) {
if (node instanceof HTMLAnchorElement && node.href && isYoutubeVideoHref(node.href)) {
return node;
}
}
return null;
}
// Capture phase runs before YouTube's own click handlers, so the page never starts its player.
// Plain click plays now; middle click or a modifier-click queues behind the current video.
function interceptVideoClick(event: MouseEvent): void {
const isPrimary = event.type === 'click' && event.button === 0;
const isMiddle = event.type === 'auxclick' && event.button === MOUSE_BUTTON_MIDDLE;
if (!isPrimary && !isMiddle) return;
const link = findVideoLink(event);
if (!link) return;
event.preventDefault();
event.stopImmediatePropagation();
const queue = isMiddle || event.shiftKey || event.ctrlKey || event.metaKey;
ipcRenderer.send(OPEN_VIDEO_CHANNEL, { action: queue ? 'queue' : 'play', url: link.href });
// Immediate feedback: starting mpv can take a few seconds before the result toast arrives.
showToast(queue ? 'Queueing in mpv…' : 'Starting mpv…', true);
}
window.addEventListener('click', interceptVideoClick, true);
window.addEventListener('auxclick', interceptVideoClick, true);
// Chromium only maps mouse back/forward buttons to history inside a real browser.
window.addEventListener('mouseup', (event) => {
if (event.button === MOUSE_BUTTON_BACK) history.back();
if (event.button === MOUSE_BUTTON_FORWARD) history.forward();
});
let toastTimer: ReturnType<typeof setTimeout> | null = null;
function showToast(message: string, ok: boolean): void {
let toast = document.getElementById('subminer-youtube-toast');
if (!toast) {
toast = document.createElement('div');
toast.id = 'subminer-youtube-toast';
Object.assign(toast.style, {
position: 'fixed',
left: '50%',
bottom: '32px',
transform: 'translateX(-50%)',
zIndex: '2147483647',
padding: '10px 18px',
borderRadius: '8px',
font: '500 14px/1.4 Roboto, Arial, sans-serif',
color: '#fff',
boxShadow: '0 4px 16px rgba(0, 0, 0, 0.4)',
pointerEvents: 'none',
transition: 'opacity 150ms ease',
});
document.body.appendChild(toast);
}
toast.textContent = message;
toast.style.background = ok ? '#1f1f1f' : '#8c1d18';
toast.style.opacity = '1';
if (toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(() => {
if (toast) toast.style.opacity = '0';
}, 2200);
}
ipcRenderer.on(TOAST_CHANNEL, (_event, payload: unknown) => {
if (typeof payload !== 'object' || payload === null) return;
const { message, ok } = payload as { message?: unknown; ok?: unknown };
if (typeof message === 'string') showToast(message, ok !== false);
});
-6
View File
@@ -393,12 +393,6 @@
<span class="media-timing-review-original-boundary is-start"></span>
<span class="media-timing-review-original-boundary is-end"></span>
</div>
<span class="media-timing-review-original-label is-start" aria-hidden="true">
Line start
</span>
<span class="media-timing-review-original-label is-end" aria-hidden="true">
Line end
</span>
<div
id="mediaTimingReviewSelectedRange"
class="media-timing-review-selected-range"
+2 -2
View File
@@ -449,8 +449,8 @@ export function createMediaTimingReviewModal(
}
/**
* Moves an untouched clip end back to where the line's dialogue ends. The Line end
* rail keeps marking the subtitle timing, and Reset restores it.
* Moves an untouched clip end back to where the line's dialogue ends. The orange
* end bar keeps marking the subtitle timing, and Reset restores it.
*/
function trimTrailingSilence(peaks: readonly number[]): void {
if (!payload || !trailingTrimPending || previewPlaying || previewRequest.isInFlight()) {

Some files were not shown because too many files have changed in this diff Show More