feat(youtube): add YouTube browser window and Whisper subtitles

- Add `subminer youtube`/`yt` and tray Browse YouTube window with persistent login; clicks play or queue in mpv
- Add `youtube.subtitleSource` (youtube | whisper) to transcribe YouTube audio via subtitle generation settings
- Drop legacy launcher whisper/AI subgen args and `--youtube-mode`
- Fix YouTube HTTP 429 on machine-translated Japanese tracks with yt-dlp fallback
- Keep Hyprland overlay mapped during fullscreen transitions
- Thin media timing review trim handles; cursor follows mpv time-pos
- Avoid EPIPE crash when the launching terminal is closed
- Bump undici to 7.29.1 and fast-uri to 3.1.7
- Update docs, config example, and changelog fragments
This commit is contained in:
2026-09-29 18:48:20 -07:00
122 changed files with 2718 additions and 850 deletions
+5 -5
View File
@@ -27,7 +27,7 @@
"eslint": "^10.8.0",
"prettier": "^3.8.1",
"typescript": "^5.9.3",
"undici": "7.29.0",
"undici": "7.29.1",
},
},
},
@@ -39,7 +39,7 @@
"app-builder-lib": "26.16.1",
"brace-expansion": "5.0.9",
"electron-builder-squirrel-windows": "26.16.1",
"fast-uri": "3.1.6",
"fast-uri": "3.1.7",
"form-data": "4.0.6",
"ip-address": "10.2.0",
"js-yaml": "4.3.2",
@@ -48,7 +48,7 @@
"picomatch": "4.0.4",
"tar": "7.5.21",
"tmp": "0.2.7",
"undici": "7.29.0",
"undici": "7.29.1",
},
"packages": {
"@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="],
@@ -407,7 +407,7 @@
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
"fast-uri": ["fast-uri@3.1.6", "", {}, "sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q=="],
"fast-uri": ["fast-uri@3.1.7", "", {}, "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
@@ -717,7 +717,7 @@
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="],
"undici": ["undici@7.29.1", "", {}, "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
+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.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Keep the subtitle overlay mapped during fullscreen transitions on Hyprland, avoiding focus changes from repeated hide/show calls that can cancel mpv fullscreen entry. Preserve the hide/show refresh on Sway.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: dependencies
- Updated Electron to 42.10.0 and undici to 7.29.1 to resolve the security advisories blocking CI.
+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
@@ -682,13 +682,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.
+3 -1
View File
@@ -122,7 +122,9 @@ Media settings apply to the next card without a restart.
With `media.reviewTiming` on, SubMiner pauses before making media for word, sentence, and audio cards and opens a review dialog. You can also toggle it for the current session with **Review Media Timing** in the runtime options palette (`Ctrl/Cmd+Shift+O`). Clipboard updates and stats-dashboard mining skip the review.
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.
During preview, the yellow cursor follows the audio player's position through the selected range and stays at the end when playback finishes. Buffering pauses the cursor too. If the player stops reporting progress for 15 seconds, the preview stops so you can retry.
| 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`
@@ -594,11 +594,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
@@ -682,13 +682,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
+2
View File
@@ -235,6 +235,8 @@ bind = ALT SHIFT, Y, pass, class:^(SubMiner)$
If the overlay stays behind fullscreen mpv, check that the mpv socket is connected and that `hyprctl -j clients` works from the environment that launched SubMiner.
SubMiner restacks the existing overlay during fullscreen transitions on Hyprland. It keeps the overlay mapped so repeated hide/show calls do not steal focus and cancel mpv's fullscreen entry. Sway retains the hide/show refresh because SubMiner has no Sway-specific restacking implementation.
See the Hyprland wiki on [global keybinds](https://wiki.hypr.land/Configuring/Binds/#global-keybinds) and [window rules](https://wiki.hypr.land/Configuring/Window-Rules/).
### KDE Plasma and other Wayland compositors
+1
View File
@@ -68,6 +68,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
+1
View File
@@ -24,6 +24,7 @@ Read when: you need to find the owner module for a behavior or test surface
Windows executable lookup and detached process creation are shared in `src/main/runtime/mpv-process.ts`. The Windows launcher and Jellyfin handlers retain their own playback and connection workflows.
- Subtitle/token pipeline: `src/core/services/subtitle-*.ts`, `src/core/services/tokenizer*`, `src/core/services/tokenizer/`, `src/subsync/`
- Anki workflow: `src/anki-integration/`, `src/core/services/anki-jimaku*.ts`
- Media timing review: `src/main/runtime/media-timing-review.ts` owns the review and hidden `MediaTimingPreviewSession`. The session waits for mpv readiness and acknowledges playback commands; its `time-pos` observations reach the modal through `media-timing-review:preview-position`. The renderer places the cursor from those timestamps and uses an inactivity timeout instead of timing the clip itself.
- Immersion tracking: `src/core/services/immersion-tracker/`
Includes stats storage/query schema such as `imm_videos`, `imm_media_art`, and `imm_youtube_videos` for per-video and YouTube-specific library metadata.
Library-entry identity aliases and merge recommendations are persisted alongside this schema; the stats HTTP and SPA layers only expose and present those domain decisions.
+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
@@ -240,7 +240,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) {
@@ -325,7 +324,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);
});
+13 -23
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';
test('createDefaultArgs normalizes configured language codes', () => {
const parsed = createDefaultArgs({
primarySubLanguages: [' JA ', 'jpn', 'ja'],
secondarySubLanguages: ['en', 'ENG', ''],
});
try {
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;
}
}
assert.deepEqual(parsed.youtubePrimarySubLangs, ['ja', 'jpn']);
assert.deepEqual(parsed.youtubeSecondarySubLangs, ['en', 'eng']);
assert.deepEqual(parsed.youtubeAudioLangs, ['ja', 'jpn', 'en', 'eng']);
assert.equal(parsed.profile, '');
});
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,
@@ -363,6 +336,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;
@@ -121,6 +122,8 @@ function getTopLevelCommand(argv: string[]): { name: string; index: number } | n
'doctor',
'config',
'settings',
'youtube',
'yt',
'mpv',
'logs',
'dictionary',
@@ -175,6 +178,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;
@@ -512,6 +516,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')
@@ -567,6 +582,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
@@ -330,6 +330,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 -3
View File
@@ -88,7 +88,7 @@
"app-builder-lib": "26.16.1",
"brace-expansion": "5.0.9",
"electron-builder-squirrel-windows": "26.16.1",
"fast-uri": "3.1.6",
"fast-uri": "3.1.7",
"form-data": "4.0.6",
"ip-address": "10.2.0",
"js-yaml": "4.3.2",
@@ -97,7 +97,7 @@
"picomatch": "4.0.4",
"tar": "7.5.21",
"tmp": "0.2.7",
"undici": "7.29.0"
"undici": "7.29.1"
},
"keywords": [
"anki",
@@ -134,7 +134,7 @@
"eslint": "^10.8.0",
"prettier": "^3.8.1",
"typescript": "^5.9.3",
"undici": "7.29.0"
"undici": "7.29.1"
},
"build": {
"appId": "com.sudacode.SubMiner",
+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,
-159
View File
@@ -1,159 +0,0 @@
## Highlights
### Added
- **Japanese Subtitle Generation**:
- Generate Japanese SRT subtitles locally with whisper.cpp. Start it from a new modal (Ctrl+Shift+G), from the generate button in an empty subtitle sidebar, or with `subminer generate-subs`.
- Generation shows progress, can be cancelled, and loads the finished subtitles into mpv automatically.
- Pick an official multilingual Whisper model, including quantized variants, with size and accuracy guidance. You can download it in the app or point Settings at a model you already have.
- `large-v3-turbo` is recommended when CUDA support is detected, and `small` otherwise.
- whisper-cli, ffmpeg, and ffprobe are found on PATH unless you override them. SubMiner names any missing tools before a download starts.
- An optional "Focus on spoken dialogue" mode uses a separately downloaded Silero VAD model. It keeps audible sections it is unsure about, so dialogue under music is not dropped, but songs may also be transcribed.
- Long passages are split near speech starts or quiet pauses to reduce subtitles that appear too early. When an eligible embedded or external subtitle track is loaded in mpv, it guides the split points.
- Each passage runs in a fresh Whisper process, which prevents repeated-character output.
- **Subtitle Selection Modal**:
- An optional modal for choosing primary and secondary mpv subtitle tracks.
- Turn it on in Settings under Behavior, then press g followed by s to open it. Turning it off restores mpv's own subtitle selection binding.
- Single-key actions take priority over configured key sequence prefixes.
- Conflicting sequences are disabled with a warning, and the existing y commands stay reserved.
- **Subtitle Sidebar Copy**:
- Select dialogue across several sidebar rows and copy it without timestamps using Ctrl/Cmd+C or the Copy button.
- Selecting text does not seek playback and does not require mining a card.
- **Media Timing Review Screenshot Picker**:
- Choose the still screenshot separately from the audio range, with a live preview and its own time slider.
- Step through decoded frames one at a time to get the exact frame you want.
- Works with local video and with seekable remote streams such as Jellyfin.
- **mpv Keybindings in the Overlay**:
- The overlay now picks up keyboard bindings from mpv defaults, `input.conf`, and loaded scripts when they do not conflict with SubMiner.
- SubMiner controls and bindings you explicitly disabled take precedence.
- These bindings apply only to the current session and are not listed in the help menu.
- **Jimaku Live Action Search**: The Jimaku modal has new Anime and Live action tabs, so you can search Jimaku's live action catalogue as well as anime. Use Arrow Left and Arrow Right to switch tabs.
- **TMDB Live-Action Library**:
- Live-action dramas and movies in the stats Library now get posters, synopses, and titles from TMDB.
- Release builds include a project key. Setting `tmdb.apiKey` or `tmdb.apiKeyCommand` overrides it, and one of them is required when running from source.
- Titles that AniList cannot match are looked up on TMDB automatically when the parsed filename exactly matches a Japanese live-action title. For everything else, use the new **Link to TMDB** action.
- Entries linked to the same TMDB title merge into one card, and the Library kind selector has a new Live Action option.
- If a replacement download fails during provider reassignment, the previous link and artwork are kept. Merges and sync keep AniList and TMDB identities separate, and the merge dialog explains mixed selections instead of failing.
- **YouTube Library Kind**:
- YouTube channels are now their own Library media kind. Existing channel entries migrate automatically, and viewing history and manual video assignments are unchanged.
- New All Titles, Anime, and YouTube Library filters.
- Channels are excluded from AniList matching, season repair, and duplicate recommendations.
- Merges and video moves can no longer combine an anime entry with a YouTube channel.
### Changed
- **Launcher Uses Bundled Bun**:
- Every installed and downloadable launcher now runs on the Bun runtime that ships with SubMiner. A system Bun is no longer needed.
- Recognized legacy launchers migrate automatically.
- Windows gets a `subminer.cmd` launcher download.
- First-run setup is reduced to one optional launcher control. Runtime repair guidance appears only when it is needed.
- **Faster Sync Transfers**:
- Sync between compatible macOS and Linux machines now uses compressed, incremental rsync transfers.
- The last snapshot received from each peer is cached, which reduces traffic on later syncs.
- Machines without a compatible rsync, including Windows, fall back to compressed scp.
- Older peers still work without the upload cache.
- Transfers abort after 30 minutes.
- **Stats Server Request Safety**:
- The stats server now accepts loopback hosts only and rejects requests from browser origins other than its own.
- Requests that change data must send an `application/json` body. Scripts that POST to the server need to set a JSON content type.
- The in-app stats overlay now loads from the local server, so it gets the same protection.
- Dashboards served through a reverse proxy or Tailscale Serve are no longer supported.
- **Smaller Downloads**:
- Installers and unpacked apps are smaller. Demo media, source maps, TypeScript sources, test fixtures, and unused Koffi binaries are no longer packaged.
- All windows now share one Japanese UI font.
- Release builds publish package size reports that compare against the previous release.
- **Bundled Yomitan**: Updated with upstream Yomitan 26.9.8 changes, including historical Japanese kana transformations, Ukrainian language support, and improvements to Anki duplicate searches and audio retrieval.
### Fixed
- **Jellyfin 12 Compatibility**:
- Playback, subtitle, artwork, and remote-control requests now authenticate with the `ApiKey` query parameter, so the integration works on Jellyfin 12, where legacy authorization is off by default.
- "Play on SubMiner" stays available. The cast connection now answers keep-alive requests and reconnects when the server stops responding, instead of silently dying after about a minute.
- The Jellyfin "now playing" bar clears when you close or finish a cast video instead of running on to the end of the episode.
- Cards mined during Jellyfin playback get the episode title in the misc info field again instead of "Unknown media".
- **Jellyfin Privacy and Playback**:
- Jellyfin streams no longer leak titles taken from the stream URL, or stream URLs that contain credentials, into metadata lookups, Anki source fields, Discord presence, stats, or AniList retries.
- Previously cached metadata that contained credentials is cleaned up. Watch history and library assignments are not touched.
- Jellyfin playback and casting now use your configured mpv executable, so they work when mpv is installed outside PATH. Portable plugins next to that executable are detected.
- **Anki Mining**:
- New `ankiConnect.fields.wordAudio` setting reads word audio separately from the sentence audio field. This fixes animated images that started moving immediately when `fields.audio` pointed to `SentenceAudio`. Existing animated images need to be regenerated to pick up the fix.
- Sentence furigana on word cards stays in sync with the full stats-search context and with expanded timing review selections. Stale readings are cleared if regeneration fails.
- Closing the overlay while media timing review is still loading now cancels the review, resumes playback if the review paused it, and cleans up the hidden preview player.
- `ankiConnect.media.maxMediaDuration: 0` now means unlimited when mining from the stats dashboard, matching overlay mining.
- Invalid AnkiConnect, Kiku, and Senren settings are now rejected with a warning and fall back to defaults.
- **Stats Server Stability**:
- A port conflict is now reported in a status notification instead of crashing SubMiner.
- Simultaneous startup requests share one server start. Stopping the background server no longer disconnects dashboards open in the foreground.
- Shutdown waits only a limited time for active requests to finish.
- Malformed resource IDs, and ID lists with any invalid entries, are rejected before Library changes or cover backfills run.
- **Subtitle Sidebar**:
- Clicking a cue no longer leaves the row focused, and Space no longer seeks back to a focused cue. Enter still seeks to the focused cue, and Space keeps its configured playback action.
- The sidebar stays near the current playback position during gaps when the subtitle file has a cue that starts at zero.
- **Settings Save Feedback**:
- Settings marked LIVE no longer show false restart warnings, including for notifications and subtitle generation.
- When a save mixes live and restart-only changes, the live changes apply right away and only the changed sections that need a restart are listed.
- **Overlay Windows**:
- On Hyprland, recovery dialogs stay above SubMiner windows so overlay placement updates no longer cover their Wait and Close buttons.
- On Linux, a delayed close callback during teardown can no longer reopen the overlay.
- **First Launch on macOS**: SubMiner no longer exits on first launch when the config directory does not exist yet.
### Docs
- **Launcher**: Documented the launcher install that uses the bundled runtime, migration from legacy launchers, package-managed updates, and the bundled Bun runtime's MIT and LGPL notices. The AUR package installs these notices under `/usr/share/licenses/subminer-bin`, and they are also included in `subminer-assets.tar.gz`.
- **Subtitle Generation**: Documented model choice, VAD behavior, splitting guided by a reference track, fallback behavior, and known limits.
- **Subtitle Selection**: Documented the subtitle selector setting, its shortcut override, and the primary and secondary track controls.
- **Settings**: Clarified save feedback for live settings, warnings for saves that mix live and restart-only changes, and how subtitle generation settings reload.
- **Jellyfin**:
- Clarified that Windows mpv playback and Jellyfin casting can use a configured executable path instead of PATH.
- Documented how Jellyfin media titles and stats identities keep stream credentials out of metadata.
- **Stats Library**:
- Documented TMDB linking, provider reassignment, merge compatibility, and caching of the credential command's output.
- Documented YouTube channel filtering and video statistics in the Library.
- **Mining**:
- Documented choosing the screenshot separately in media timing review.
- Documented the separate word audio field mapping, including that existing animated images need to be regenerated.
- **Sync**: Documented compressed transfers, where the incremental sync cache is stored, and compatibility with older peers.
## What's Changed
- feat(sidebar): add dialogue selection and copying by @ksyasuda in #238
- feat(subtitles): add local Japanese subtitle generation by @ksyasuda in #240
- perf(stats): use compressed incremental snapshot transfers by @ksyasuda in #241
- fix(startup): create config directory before singleton lock by @ksyasuda in #242
- feat(launcher): bundle Bun and use it across all launchers by @ksyasuda in #243
- build(release): reduce package size and report release sizes by @ksyasuda in #244
- fix(overlay): keep Hyprland recovery dialogs above overlays by @ksyasuda in #245
- feat(overlay): discover unclaimed mpv key bindings by @ksyasuda in #246
- fix(sidebar): preserve Space playback after cue seeking by @ksyasuda in #247
- fix(jellyfin): fix jellyfin media metadata by @ksyasuda in #250
- feat(jimaku): add live-action subtitle search by @ksyasuda in #251
- feat(stats): add TMDB metadata for live-action dramas in the Library by @ksyasuda in #252
- feat(stats): separate YouTube channels in the Library by @ksyasuda in #253
- feat(mining): add a screenshot frame picker to media review by @aalhendi in #254
- fix(config): align live save feedback with hot reload policy by @ksyasuda in #255
- fix(anki): separate word audio mapping for animation sync by @ksyasuda in #256
- fix(config): validate AnkiConnect and field grouping settings by @ksyasuda in #257
- fix(anki): honor unlimited duration in stats mining by @ksyasuda in #258
- fix(stats): reject malformed resource IDs before mutations by @ksyasuda in #259
- fix(stats): harden server lifecycle and verify compiled runtime by @ksyasuda in #261
- fix(overlay): cancel pending window transitions and timing reviews by @ksyasuda in #262
- fix(stats): restrict local requests and serve the dashboard over HTTP by @ksyasuda in #263
- fix(jellyfin): support modern authentication by @ksyasuda in #264
- feat(overlay): add optional subtitle selection modal by @ksyasuda in #265
- fix(jellyfin): respect Windows mpv configuration when casting by @aalhendi in #267
- fix(anki): regenerate sentence furigana from the final sentence by @ksyasuda in #268
## New Contributors
- @aalhendi made their first contribution in #254
## Installation
See the README and docs/installation guide for full setup steps.
## Assets
- Linux: `SubMiner.AppImage`
- macOS: `SubMiner-*.dmg` and `SubMiner-*.zip`
- Windows: `SubMiner-*.exe` and `SubMiner-*-win.zip`
- Optional extras: `subminer-assets.tar.gz`, the `subminer` launcher, and the Windows `subminer.cmd` launcher
- Bun corresponding source: `bun-v1.3.5-source.tar.gz` and its `.sha256` file
Both launcher downloads use Bun included with the SubMiner app. Download `subminer` on Linux or macOS and `subminer.cmd` on Windows.
The app bundles an unmodified Bun 1.3.5 runtime. Bun is MIT licensed and statically links JavaScriptCore (LGPL 2.0) and TinyCC (LGPL 2.1). License texts and third-party notices ship inside the app under `resources/bun/licenses`, and the source archive above contains the matching Bun, WebKit, and dependency sources for relinking.
+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
@@ -97,15 +97,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);
});
@@ -266,6 +260,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;
@@ -15,6 +14,7 @@ export interface CliArgs {
yomitan: boolean;
settings: boolean;
syncWindow: boolean;
youtubeBrowser: boolean;
setup: boolean;
show: boolean;
hide: boolean;
@@ -128,7 +128,6 @@ export function parseArgs(argv: string[]): CliArgs {
launchMpv: false,
launchMpvTargets: [],
youtubePlay: undefined,
youtubeMode: undefined,
stop: false,
toggle: false,
toggleVisibleOverlay: false,
@@ -136,6 +135,7 @@ export function parseArgs(argv: string[]): CliArgs {
yomitan: false,
settings: false,
syncWindow: false,
youtubeBrowser: false,
setup: false,
show: false,
hide: false,
@@ -270,12 +270,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('--'));
@@ -287,6 +281,7 @@ export function parseArgs(argv: string[]): CliArgs {
else if (arg === '--yomitan') args.yomitan = 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;
@@ -570,6 +565,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
args.yomitan ||
args.settings ||
args.syncWindow ||
args.youtubeBrowser ||
args.setup ||
args.show ||
args.hide ||
@@ -649,6 +645,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
!args.yomitan &&
!args.settings &&
!args.syncWindow &&
!args.youtubeBrowser &&
!args.setup &&
!args.show &&
!args.hide &&
@@ -721,6 +718,7 @@ export function shouldStartApp(args: CliArgs): boolean {
args.yomitan ||
args.settings ||
args.syncWindow ||
args.youtubeBrowser ||
args.setup ||
args.copySubtitle ||
args.copySubtitleMultiple ||
@@ -779,6 +777,7 @@ export function shouldRunYomitanOnlyStartup(args: CliArgs): boolean {
!args.togglePrimarySubtitleBar &&
!args.settings &&
!args.syncWindow &&
!args.youtubeBrowser &&
!args.show &&
!args.hide &&
!args.setup &&
+3
View File
@@ -79,6 +79,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
@@ -49,7 +49,6 @@ const {
jellyfin,
discordPresence,
ai,
youtubeSubgen,
} = INTEGRATIONS_DEFAULT_CONFIG;
const { subtitleStyle, subtitleSidebar } = SUBTITLE_DEFAULT_CONFIG;
const { immersionTracking } = IMMERSION_DEFAULT_CONFIG;
@@ -85,7 +84,6 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
jellyfin,
discordPresence,
ai,
youtubeSubgen,
immersionTracking,
stats,
};
+1
View File
@@ -115,6 +115,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
},
youtube: {
primarySubLanguages: ['ja', 'jpn'],
subtitleSource: 'youtube',
mediaCache: {
mode: 'direct',
maxHeight: 720,
@@ -13,7 +13,6 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
| 'jellyfin'
| 'discordPresence'
| 'ai'
| 'youtubeSubgen'
> = {
ankiConnect: {
enabled: true,
@@ -179,15 +178,4 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
'You are a translation engine. Return only the translated text with no explanations.',
requestTimeoutMs: 15_000,
},
youtubeSubgen: {
whisperBin: '',
whisperModel: '',
whisperVadModel: '',
whisperThreads: 4,
fixWithAi: false,
ai: {
model: '',
systemPrompt: '',
},
},
};
@@ -85,13 +85,6 @@ const UNDOCUMENTED_LEAVES: ReadonlySet<string> = new Set([
'subtitleStyle.textShadow',
'subtitleStyle.WebkitTextStroke',
'subtitleStyle.wordSpacing',
'youtubeSubgen.ai.model',
'youtubeSubgen.ai.systemPrompt',
'youtubeSubgen.fixWithAi',
'youtubeSubgen.whisperBin',
'youtubeSubgen.whisperModel',
'youtubeSubgen.whisperThreads',
'youtubeSubgen.whisperVadModel',
]);
test('config option registry includes critical paths and has unique entries', () => {
+12
View File
@@ -119,6 +119,18 @@ export function buildCoreConfigOptionRegistry(
description:
'Comma-separated primary subtitle language priority for managed subtitle auto-selection.',
},
{
path: 'youtube.subtitleSource',
kind: 'enum',
enumValues: ['youtube', 'whisper'],
enumLabels: {
youtube: 'Download YouTube captions',
whisper: 'Generate with Whisper',
},
defaultValue: defaultConfig.youtube.subtitleSource,
description:
'Where primary YouTube subtitles come from. Whisper transcribes the audio locally using the subtitleGeneration settings.',
},
{
path: 'youtube.mediaCache.mode',
kind: 'enum',
+3 -1
View File
@@ -183,7 +183,9 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
description: [
'Defaults for managed subtitle language preferences and YouTube subtitle loading.',
],
notes: ['Hot-reload: primarySubLanguages applies to the next YouTube subtitle load.'],
notes: [
'Hot-reload: primarySubLanguages and subtitleSource apply to the next YouTube subtitle load.',
],
key: 'youtube',
},
{
+1
View File
@@ -21,6 +21,7 @@ const HOT_RELOAD_EXACT_OR_PREFIX_PATHS = [
'logging.rotation',
'logging.files',
'youtube.primarySubLanguages',
'youtube.subtitleSource',
'ankiConnect.deck',
'ankiConnect.media.normalizeAudio',
'ankiConnect.media.mirrorMpvVolume',
+12
View File
@@ -339,6 +339,18 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
);
}
const subtitleSource = src.youtube.subtitleSource;
if (subtitleSource === 'youtube' || subtitleSource === 'whisper') {
resolved.youtube.subtitleSource = subtitleSource;
} else if (subtitleSource !== undefined) {
warn(
'youtube.subtitleSource',
subtitleSource,
resolved.youtube.subtitleSource,
"Expected 'youtube' or 'whisper'.",
);
}
if (isObject(src.youtube.mediaCache)) {
const mode = src.youtube.mediaCache.mode;
if (mode === 'direct' || mode === 'background') {
-99
View File
@@ -118,105 +118,6 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
}
}
if (isObject(src.youtubeSubgen)) {
const whisperBin = asString(src.youtubeSubgen.whisperBin);
if (whisperBin !== undefined) {
resolved.youtubeSubgen.whisperBin = whisperBin;
} else if (src.youtubeSubgen.whisperBin !== undefined) {
warn(
'youtubeSubgen.whisperBin',
src.youtubeSubgen.whisperBin,
resolved.youtubeSubgen.whisperBin,
'Expected string.',
);
}
const whisperModel = asString(src.youtubeSubgen.whisperModel);
if (whisperModel !== undefined) {
resolved.youtubeSubgen.whisperModel = whisperModel;
} else if (src.youtubeSubgen.whisperModel !== undefined) {
warn(
'youtubeSubgen.whisperModel',
src.youtubeSubgen.whisperModel,
resolved.youtubeSubgen.whisperModel,
'Expected string.',
);
}
const whisperVadModel = asString(src.youtubeSubgen.whisperVadModel);
if (whisperVadModel !== undefined) {
resolved.youtubeSubgen.whisperVadModel = whisperVadModel;
} else if (src.youtubeSubgen.whisperVadModel !== undefined) {
warn(
'youtubeSubgen.whisperVadModel',
src.youtubeSubgen.whisperVadModel,
resolved.youtubeSubgen.whisperVadModel,
'Expected string.',
);
}
const whisperThreads = asNumber(src.youtubeSubgen.whisperThreads);
if (whisperThreads !== undefined && Number.isInteger(whisperThreads) && whisperThreads > 0) {
resolved.youtubeSubgen.whisperThreads = whisperThreads;
} else if (src.youtubeSubgen.whisperThreads !== undefined) {
warn(
'youtubeSubgen.whisperThreads',
src.youtubeSubgen.whisperThreads,
resolved.youtubeSubgen.whisperThreads,
'Expected positive integer.',
);
}
const fixWithAi = asBoolean(src.youtubeSubgen.fixWithAi);
if (fixWithAi !== undefined) {
resolved.youtubeSubgen.fixWithAi = fixWithAi;
} else if (src.youtubeSubgen.fixWithAi !== undefined) {
warn(
'youtubeSubgen.fixWithAi',
src.youtubeSubgen.fixWithAi,
resolved.youtubeSubgen.fixWithAi,
'Expected boolean.',
);
}
if (isObject(src.youtubeSubgen.ai)) {
const aiModel = asString(src.youtubeSubgen.ai.model);
if (aiModel !== undefined) {
resolved.youtubeSubgen.ai.model = aiModel;
} else if (src.youtubeSubgen.ai.model !== undefined) {
warn(
'youtubeSubgen.ai.model',
src.youtubeSubgen.ai.model,
resolved.youtubeSubgen.ai.model,
'Expected string.',
);
}
const aiSystemPrompt = asString(src.youtubeSubgen.ai.systemPrompt);
if (aiSystemPrompt !== undefined) {
resolved.youtubeSubgen.ai.systemPrompt = aiSystemPrompt;
} else if (src.youtubeSubgen.ai.systemPrompt !== undefined) {
warn(
'youtubeSubgen.ai.systemPrompt',
src.youtubeSubgen.ai.systemPrompt,
resolved.youtubeSubgen.ai.systemPrompt,
'Expected string.',
);
}
} else if (src.youtubeSubgen.ai !== undefined) {
warn('youtubeSubgen.ai', src.youtubeSubgen.ai, resolved.youtubeSubgen.ai, 'Expected object.');
}
if (src.youtubeSubgen.primarySubLanguages !== undefined) {
warn(
'youtubeSubgen.primarySubLanguages',
src.youtubeSubgen.primarySubLanguages,
undefined,
'Removed. Use youtube.primarySubLanguages instead.',
);
}
}
if (isObject(src.subtitleStyle)) {
const fallbackSubtitleStyleEnableJlpt = resolved.subtitleStyle.enableJlpt;
const fallbackSubtitleStylePrimaryDefaultMode = resolved.subtitleStyle.primaryDefaultMode;
-1
View File
@@ -328,7 +328,6 @@ test('settings registry hides app-managed and inactive config surfaces', () => {
'controller.preferredGamepadId',
'controller.preferredGamepadLabel',
'controller.profiles',
'youtubeSubgen.whisperBin',
'jellyfin.defaultLibraryId',
'subtitleSidebar.toggleKey',
'jellyfin.recentServers',
+2 -11
View File
@@ -61,7 +61,6 @@ export const LEGACY_HIDDEN_CONFIG_PATHS = [
'controller.preferredGamepadId',
'controller.preferredGamepadLabel',
'controller.profiles',
'youtubeSubgen.primarySubLanguages',
'anilist.characterDictionary.enabled',
'anilist.characterDictionary.refreshTtlHours',
'anilist.characterDictionary.evictionPolicy',
@@ -76,12 +75,7 @@ export const LEGACY_HIDDEN_CONFIG_PATHS = [
'jellyfin.recentServers',
] as const;
const EXCLUDED_PREFIXES = [
'ai',
'ankiConnect.ai',
'controller.buttonIndices',
'youtubeSubgen',
] as const;
const EXCLUDED_PREFIXES = ['ai', 'ankiConnect.ai', 'controller.buttonIndices'] as const;
const JSON_OBJECT_FIELDS = new Set([
'keybindings',
@@ -525,7 +519,6 @@ function topSection(path: string): string {
websocket: 'WebSocket server',
yomitan: 'Yomitan',
youtube: 'YouTube Playback Settings',
youtubeSubgen: 'YouTube subtitle generation',
auto_start_overlay: 'Playback Behavior',
};
return labels[top] ?? humanizePath(top);
@@ -725,9 +718,7 @@ function fieldForLeaf(leaf: Leaf): ConfigSettingsField {
...(option?.enumLabels ? { enumLabels: option.enumLabels } : {}),
restartBehavior: getConfigHotReloadField(leaf.path) ? 'hot-reload' : 'restart',
advanced:
leaf.path.startsWith('controller.') ||
leaf.path.startsWith('immersionTracking.retention.') ||
leaf.path.startsWith('youtubeSubgen.'),
leaf.path.startsWith('controller.') || leaf.path.startsWith('immersionTracking.retention.'),
secret: SECRET_PATHS.has(leaf.path),
settingsHidden: SUBTITLE_CSS_MANAGED_CONFIG_PATHS.has(leaf.path),
};
-7
View File
@@ -21,13 +21,6 @@ const SUBTITLE_CSS_SCOPES: SubtitleCssScope[] = ['primary', 'secondary', 'sideba
const HIDDEN_TEMPLATE_PATHS = [
'anilist.characterDictionary.evictionPolicy',
'anilist.characterDictionary.refreshTtlHours',
'youtubeSubgen.ai.model',
'youtubeSubgen.ai.systemPrompt',
'youtubeSubgen.fixWithAi',
'youtubeSubgen.whisperBin',
'youtubeSubgen.whisperModel',
'youtubeSubgen.whisperThreads',
'youtubeSubgen.whisperVadModel',
];
function normalizeCommentText(value: string): string {
+1
View File
@@ -17,6 +17,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
yomitan: false,
settings: false,
syncWindow: false,
youtubeBrowser: false,
setup: false,
show: false,
hide: false,
+17 -31
View File
@@ -15,13 +15,13 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
launchMpv: false,
launchMpvTargets: [],
youtubePlay: undefined,
youtubeMode: undefined,
stop: false,
toggle: false,
toggleVisibleOverlay: false,
yomitan: false,
settings: false,
syncWindow: false,
youtubeBrowser: false,
setup: false,
show: false,
hide: false,
@@ -142,6 +142,9 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
openSyncUiWindow: () => {
calls.push('openSyncUiWindow');
},
openYoutubeBrowserWindow: () => {
calls.push('openYoutubeBrowserWindow');
},
openFirstRunSetup: (force?: boolean) => {
calls.push(`openFirstRunSetup:${force === true ? 'force' : 'default'}`);
},
@@ -243,7 +246,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 ?? ''}`);
@@ -283,46 +286,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 () => {
@@ -332,11 +319,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:')));
@@ -676,6 +659,7 @@ test('createCliCommandDepsRuntime reconnects MPV client when reconnect hook exis
openYomitanSettings: () => {},
openConfigSettingsWindow: () => {},
openSyncUiWindow: () => {},
openYoutubeBrowserWindow: () => {},
cycleSecondarySubMode: () => {},
openRuntimeOptionsPalette: () => {},
printHelp: () => {},
@@ -789,6 +773,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',
@@ -822,9 +807,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
@@ -45,6 +45,7 @@ export interface CliCommandServiceDeps {
openYomitanSettingsDelayed: (delayMs: number) => void;
openConfigSettingsWindow: () => void;
openSyncUiWindow: () => void;
openYoutubeBrowserWindow: () => void;
setVisibleOverlayVisible: (visible: boolean) => void;
copyCurrentSubtitle: () => void;
startPendingMultiCopy: (timeoutMs: number) => void;
@@ -102,11 +103,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;
@@ -172,6 +169,7 @@ interface UiCliRuntime {
openYomitanSettings: () => void;
openConfigSettingsWindow: () => void;
openSyncUiWindow: () => void;
openYoutubeBrowserWindow: () => void;
cycleSecondarySubMode: () => void;
openRuntimeOptionsPalette: () => void;
printHelp: () => void;
@@ -277,6 +275,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,
@@ -429,6 +428,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) {
@@ -639,7 +640,6 @@ export function handleCliCommand(
() =>
deps.runYoutubePlaybackFlow({
url: youtubeUrl,
mode: args.youtubeMode ?? 'download',
source,
}),
deps,
+95 -1
View File
@@ -1,5 +1,6 @@
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import { ChildProcess } from 'node:child_process';
import net from 'node:net';
import { describe, test } from 'node:test';
import { buildMediaTimingPreviewArgs, MediaTimingPreviewSession } from './media-timing-preview';
@@ -217,6 +218,15 @@ function createFakeSocket() {
socket.destroyed = false;
socket.write = (data) => {
writes.push(data);
const message = JSON.parse(data);
if (message.request_id !== undefined) {
queueMicrotask(() =>
socket.emit(
'data',
`${JSON.stringify({ request_id: message.request_id, error: 'success' })}\n`,
),
);
}
return true;
};
socket.end = () => undefined;
@@ -226,6 +236,85 @@ function createFakeSocket() {
return { socket, writes };
}
test('preview waits for loaded media before seeking and reports the actual playback position', async () => {
const { socket, writes } = createFakeSocket();
const child = new ChildProcess();
child.kill = () => true;
const positions: number[] = [];
const session = new MediaTimingPreviewSession({
spawnProcess: () => child,
connectSocket: () => {
queueMicrotask(() => socket.emit('connect'));
return socket as never;
},
removeSocketFile: () => undefined,
createSocketPath: () => '/tmp/review.sock',
});
const position = (data: number) =>
socket.emit(
'data',
`${JSON.stringify({ event: 'property-change', name: 'time-pos', data })}\n`,
);
try {
await session.start({
mediaPath: '/video/show.mkv',
onPlaybackPosition: (time) => positions.push(time),
});
const playing = session.play(36, 42);
await Promise.resolve();
assert.equal(
writes.some((line) => JSON.parse(line).command[0] === 'seek'),
false,
);
position(0);
await playing;
position(36);
position(39);
// No advancing position means no fabricated progress, even while audio is stalled.
assert.deepEqual(positions, [36, 39]);
position(41.9);
assert.deepEqual(positions, [36, 39, 41.9]);
await session.stop();
position(42);
assert.deepEqual(positions, [36, 39, 41.9]);
} finally {
session.dispose();
}
});
for (const action of ['stop', 'dispose'] as const) {
test(`${action} cancels a preview still waiting for media to load`, async () => {
const { socket, writes } = createFakeSocket();
const child = new ChildProcess();
child.kill = () => true;
const session = new MediaTimingPreviewSession({
spawnProcess: () => child,
connectSocket: () => {
queueMicrotask(() => socket.emit('connect'));
return socket as never;
},
removeSocketFile: () => undefined,
createSocketPath: () => '/tmp/review.sock',
});
try {
await session.start({ mediaPath: '/video/show.mkv' });
const pending = assert.rejects(session.play(36, 42), /closed|cancelled/);
await session[action]();
await pending;
socket.emit(
'data',
`${JSON.stringify({ event: 'property-change', name: 'time-pos', data: 0 })}\n`,
);
assert.equal(
writes.some((line) => JSON.parse(line).command[0] === 'seek'),
false,
);
} finally {
session.dispose();
}
});
}
test('preview session plays once to the clip end and reports when mpv has drained it', async () => {
const { socket, writes } = createFakeSocket();
const child = new EventEmitter() as EventEmitter & { kill: () => boolean };
@@ -253,11 +342,16 @@ test('preview session plays once to the clip end and reports when mpv has draine
[
['observe_property', 1, 'eof-reached'],
['observe_property', 2, 'pause'],
['observe_property', 3, 'time-pos'],
],
);
// The observers' initial replies describe the idle paused player, not a finished preview.
socket.emit('data', property('eof-reached', false) + property('pause', true));
assert.equal(endedCount, 0);
socket.emit(
'data',
`${JSON.stringify({ event: 'property-change', name: 'time-pos', data: 0 })}\n`,
);
writes.length = 0;
await session.play(12.25, 14.5);
@@ -265,8 +359,8 @@ test('preview session plays once to the clip end and reports when mpv has draine
writes.map((line) => JSON.parse(line).command),
[
['set_property', 'pause', true],
['seek', 12.25, 'absolute+exact'],
['set_property', 'end', '14.500'],
['seek', 12.25, 'absolute+exact'],
['set_property', 'pause', false],
],
);
+121 -6
View File
@@ -15,6 +15,8 @@ const CONNECT_RETRY_MS = 40;
*/
const EOF_OBSERVER_ID = 1;
const PAUSE_OBSERVER_ID = 2;
const POSITION_OBSERVER_ID = 3;
const PLAYER_RESPONSE_TIMEOUT_MS = 15_000;
export interface MediaTimingPreviewStartOptions {
mediaPath: string;
@@ -23,6 +25,7 @@ export interface MediaTimingPreviewStartOptions {
volume?: number;
/** The file keeps source timestamps (a cached remote window); seek with the original times. */
absoluteTimestamps?: boolean;
onPlaybackPosition?: (time: number) => void;
}
type PreviewProcess = Pick<ChildProcess, 'kill' | 'once'>;
@@ -103,8 +106,21 @@ export class MediaTimingPreviewSession {
private disposed = false;
private readBuffer = '';
private playing = false;
private playSequence = 0;
private eofReached = false;
private paused = true;
private mediaReady = false;
private onPlaybackPosition: MediaTimingPreviewStartOptions['onPlaybackPosition'];
private nextRequestId = 0;
private readonly pendingCommands = new Map<
number,
{
resolve: () => void;
reject: (error: Error) => void;
}
>();
private readonly readyListeners = new Set<() => void>();
private readonly pendingWaits = new Set<(error: Error) => void>();
private readonly endedListeners = new Set<() => void>();
constructor(deps: Partial<MediaTimingPreviewDeps> = {}) {
@@ -141,6 +157,8 @@ export class MediaTimingPreviewSession {
private async startOnce(options: MediaTimingPreviewStartOptions): Promise<void> {
const mediaPath = options.mediaPath.trim();
if (!mediaPath) throw new Error('No media source is available for preview');
this.mediaReady = false;
this.onPlaybackPosition = options.onPlaybackPosition;
const socketPath = this.deps.createSocketPath();
this.socketPath = socketPath;
@@ -186,19 +204,35 @@ export class MediaTimingPreviewSession {
}
this.playing = false;
this.send(['set_property', 'pause', true]);
this.send(['seek', startTime, 'absolute+exact']);
const sequence = ++this.playSequence;
if (!this.mediaReady) {
await this.waitForPlayer((resolve) => {
this.readyListeners.add(resolve);
return () => this.readyListeners.delete(resolve);
});
}
// The option parser wants a time string; a raw JSON number is not accepted for `end`.
this.send(['set_property', 'end', endTime.toFixed(3)]);
this.send(['set_property', 'pause', false]);
// Only the seek's eof-reached=false and the later keep-open pause count for this play.
const commands = [
['set_property', 'pause', true],
['set_property', 'end', endTime.toFixed(3)],
['seek', startTime, 'absolute+exact'],
['set_property', 'pause', false],
];
for (const command of commands) {
if (sequence !== this.playSequence) throw new Error('Preview playback was cancelled');
await this.command(command);
}
if (sequence !== this.playSequence) throw new Error('Preview playback was cancelled');
// Discard paused/EOF observations from the previous clip before arming this playback.
this.eofReached = false;
this.paused = false;
this.playing = true;
}
async stop(): Promise<void> {
this.playSequence += 1;
this.playing = false;
this.rejectPendingWaits();
if (!this.socket || this.socket.destroyed) return;
this.send(['set_property', 'pause', true]);
}
@@ -227,6 +261,23 @@ export class MediaTimingPreviewSession {
} catch {
continue;
}
if (
typeof message === 'object' &&
message !== null &&
'request_id' in message &&
typeof message.request_id === 'number'
) {
const pending = this.pendingCommands.get(message.request_id);
if (pending) {
if ('error' in message && message.error === 'success') pending.resolve();
else
pending.reject(
new Error(
`Preview command failed: ${'error' in message ? String(message.error) : 'unknown error'}`,
),
);
}
}
if (
typeof message === 'object' &&
message !== null &&
@@ -241,6 +292,13 @@ export class MediaTimingPreviewSession {
}
private handlePropertyChange(name: unknown, data: unknown): void {
if (name === 'time-pos') {
if (typeof data !== 'number' || !Number.isFinite(data)) return;
this.mediaReady = true;
for (const listener of this.readyListeners) listener();
if (this.playing) this.onPlaybackPosition?.(data);
return;
}
if (name === 'eof-reached') this.eofReached = data === true;
else if (name === 'pause') this.paused = data === true;
else return;
@@ -255,6 +313,8 @@ export class MediaTimingPreviewSession {
private releaseResources(): void {
this.cancelRetryWait();
this.playing = false;
this.rejectPendingWaits();
try {
this.send(['quit']);
} catch {
@@ -283,6 +343,57 @@ export class MediaTimingPreviewSession {
this.socket.write(`${JSON.stringify({ command })}\n`);
}
private waitForPlayer(
subscribe: (resolve: () => void, reject: (error: Error) => void) => () => void,
): Promise<void> {
return new Promise((resolve, reject) => {
let settled = false;
let unsubscribe = () => {};
const finish = (error?: Error) => {
if (settled) return;
settled = true;
this.deps.cancelSchedule(timeout);
unsubscribe();
this.pendingWaits.delete(fail);
if (error) reject(error);
else resolve();
};
const fail = (error: Error) => finish(error);
const timeout = this.deps.schedule(
() => fail(new Error('Timed out waiting for the preview player')),
PLAYER_RESPONSE_TIMEOUT_MS,
);
this.pendingWaits.add(fail);
try {
unsubscribe = subscribe(() => finish(), fail);
if (settled) unsubscribe();
} catch (error) {
fail(error instanceof Error ? error : new Error(String(error)));
}
});
}
private command(command: Array<string | number | boolean>): Promise<void> {
if (!this.socket || this.socket.destroyed)
return Promise.reject(new Error('Preview player is not ready'));
const socket = this.socket;
const requestId = ++this.nextRequestId;
return this.waitForPlayer((resolve, reject) => {
this.pendingCommands.set(requestId, { resolve, reject });
try {
socket.write(`${JSON.stringify({ command, request_id: requestId })}\n`);
} catch (error) {
this.pendingCommands.delete(requestId);
throw error;
}
return () => this.pendingCommands.delete(requestId);
});
}
private rejectPendingWaits(): void {
for (const reject of this.pendingWaits) reject(new Error('Preview player is closed'));
}
private async connectWithRetry(socketPath: string): Promise<void> {
const deadline = this.deps.now() + CONNECT_TIMEOUT_MS;
while (!this.disposed && this.deps.now() < deadline) {
@@ -305,9 +416,13 @@ export class MediaTimingPreviewSession {
socket.on('data', (chunk: Buffer | string) => {
if (this.socket === socket) this.handleSocketData(chunk);
});
socket.once('close', () => this.finishPlayback());
socket.once('close', () => {
this.rejectPendingWaits();
this.finishPlayback();
});
this.send(['observe_property', EOF_OBSERVER_ID, 'eof-reached']);
this.send(['observe_property', PAUSE_OBSERVER_ID, 'pause']);
this.send(['observe_property', POSITION_OBSERVER_ID, 'time-pos']);
return;
} catch {
if (this.disposed) {
@@ -17,6 +17,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
yomitan: 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}`,
),
);
+26 -10
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.',
);
if (model.kind === 'invalid') throw new Error(model.message);
const tools = requireSubtitleGenerationTools(await resolveSubtitleGenerationTools(input.config));
const { modelPath, tools } = await requireSubtitleGenerationSetup(
input.config,
input.modelDirectory,
);
input.onProgress?.({ stage: 'extract', message: 'Inspecting audio tracks...' });
const probe = await runSubtitleGenerationProcess({
command: tools.ffprobe,
@@ -215,7 +229,9 @@ export async function generateJapaneseSubtitles(input: {
signal: input.signal,
});
const audio = parseAudioProbe(probe, input.audioStreamIndex);
const temporaryDirectory = await mkdtemp(path.join(tmpdir(), 'subminer-whisper-'));
const temporaryDirectory = await mkdtemp(
path.join(input.workDirectory ?? tmpdir(), 'subminer-whisper-'),
);
try {
const wavPath = path.join(temporaryDirectory, 'audio.wav');
const subtitleBase = path.join(temporaryDirectory, 'subtitles');
@@ -277,7 +293,7 @@ export async function generateJapaneseSubtitles(input: {
config: input.config,
tools,
referenceStarts,
modelPath: model.path,
modelPath,
wavPath,
directory: temporaryDirectory,
signal: input.signal,
@@ -288,7 +304,7 @@ export async function generateJapaneseSubtitles(input: {
command: tools.whisper,
args: [
'-m',
model.path,
modelPath,
'-f',
wavPath,
'-l',
@@ -478,6 +478,35 @@ test('downloadYoutubeSubtitleTrack prefers direct download URL when available',
});
});
test('downloadYoutubeSubtitleTrack falls back to yt-dlp when the direct URL is rejected', async () => {
if (process.platform === 'win32') {
return;
}
await withFakeYtDlp('both', async (root) => {
await withStubFetch(
async () => new Response('Too Many Requests', { status: 429 }),
async () => {
const result = await downloadYoutubeSubtitleTrack({
targetUrl: 'https://www.youtube.com/watch?v=abc123',
outputDir: path.join(root, 'out'),
track: {
id: 'auto:ja',
language: 'ja',
sourceLanguage: 'ja',
kind: 'auto',
label: 'Japanese (auto)',
downloadUrl: 'https://www.youtube.com/api/timedtext?v=abc123&lang=ja&fmt=vtt',
fileExtension: 'vtt',
},
});
assert.equal(path.extname(result.path), '.vtt');
},
);
});
});
test('downloadYoutubeSubtitleTrack sanitizes metadata source language in filenames', async () => {
await withTempDir(async (root) => {
await withStubFetch(
+10 -5
View File
@@ -221,11 +221,16 @@ export async function downloadYoutubeSubtitleTrack(input: {
}
}
if (canDownloadSubtitleFromUrl(input.track)) {
return await downloadSubtitleFromUrl({
outputDir: input.outputDir,
prefix,
track: input.track,
});
try {
return await downloadSubtitleFromUrl({
outputDir: input.outputDir,
prefix,
track: input.track,
});
} catch {
// YouTube rejects some direct timedtext requests (HTTP 429) that yt-dlp still gets
// through with its own client handling, so retry the same track via yt-dlp.
}
}
const outputTemplate = path.join(input.outputDir, `${prefix}.%(ext)s`);
const args = [
@@ -116,6 +116,31 @@ test('probeYoutubeTracks prefers srv3 over vtt for automatic captions', async ()
);
});
test('probeYoutubeTracks skips machine-translated caption URLs when the real track exists', async () => {
const translated = 'https://www.youtube.com/api/timedtext?v=abc123&lang=en-US&tlang=ja&fmt=srv3';
const original = 'https://www.youtube.com/api/timedtext?v=abc123&lang=ja&fmt=srv3';
await withFakeYtDlp(
{
id: 'abc123',
title: 'Example',
automatic_captions: {
ja: [
{ ext: 'srv3', url: translated, name: 'Japanese' },
{ ext: 'srv3', url: original, name: 'Japanese' },
],
fr: [{ ext: 'srv3', url: `${translated}&x=fr`, name: 'French' }],
},
},
async () => {
const result = await probeYoutubeTracks('https://www.youtube.com/watch?v=abc123');
const byLanguage = new Map(result.tracks.map((track) => [track.sourceLanguage, track]));
assert.equal(byLanguage.get('ja')?.downloadUrl, original);
// A translation-only language still gets a track rather than disappearing.
assert.equal(byLanguage.get('fr')?.downloadUrl, `${translated}&x=fr`);
},
);
});
test('probeYoutubeTracks honors SUBMINER_YTDLP_BIN when yt-dlp is not on PATH', async () => {
if (process.platform === 'win32') {
return;
+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;
+117 -4
View File
@@ -333,6 +333,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 {
@@ -488,6 +489,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';
@@ -669,6 +672,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 = [
@@ -1027,6 +1032,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),
@@ -1166,6 +1178,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 () => {
@@ -1393,7 +1424,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, {
@@ -2988,6 +3020,13 @@ const mediaTimingReviewRuntime = createMediaTimingReviewRuntime({
}
}
},
onPreviewPosition: (position) => {
for (const window of [overlayManager.getMainWindow(), overlayManager.getModalWindow()]) {
if (window && !window.isDestroyed()) {
window.webContents.send(IPC_CHANNELS.event.mediaTimingReviewPreviewPosition, position);
}
}
},
showStatus: (message) =>
overlayNotificationsRuntime.showConfiguredStatusNotification(message, { variant: 'warning' }),
});
@@ -3157,6 +3196,7 @@ const {
runJellyfinCommand,
openJellyfinSetupWindow,
getJellyfinClientInfo,
ensureMpvConnectedForPlayback,
} = composeJellyfinRuntimeHandlers({
getResolvedJellyfinConfigMainDeps: {
getResolvedConfig: () => configService.getConfig(),
@@ -3434,6 +3474,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,
});
@@ -4066,7 +4152,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(),
@@ -4669,6 +4760,9 @@ const {
}
youtubePrimarySubtitleNotificationRuntime.handleMediaPathChange(path);
void youtubeMediaCachePlaybackRuntime.handleMediaPathChange(path);
youtubeBrowserPlaybackRuntime.handleMediaPathChange(path);
youtubeFlowRuntime.handleMediaPathChange(path);
subtitleGenerationRuntime.handleMediaPathChange(path);
if (path) {
ensureImmersionTrackerStarted();
secondarySubtitleTrackController.scheduleRefresh();
@@ -5019,6 +5113,13 @@ function createMpvClientRuntimeService(): MpvIpcClient {
if (connected) {
return;
}
youtubeBrowserPlaybackRuntime.handleMpvDisconnected();
youtubeFlowRuntime.cancelWhisperGeneration();
subtitleGenerationRuntime.handleMediaPathChange(null);
if (quitWhenMpvClosesAfterYoutubeBrowser && !youtubeBrowserWindowRuntime.isOpen()) {
requestAppQuit();
return;
}
if (!youtubeFlowRuntime.hasActiveSession()) {
return;
}
@@ -6164,6 +6265,7 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({
openYomitanSettings: () => openYomitanSettings(),
openConfigSettingsWindow: () => configSettingsRuntime.openWindow(),
openSyncUiWindow: () => openSyncUiWindowHandler(),
openYoutubeBrowserWindow: () => youtubeBrowserWindowRuntime.open(),
cycleSecondarySubMode: () => cycleSecondarySubMode(),
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT),
@@ -6427,6 +6529,7 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } =
openYomitanSettings: () => openYomitanSettings(),
openConfigSettingsWindow: () => configSettingsRuntime.openWindow(),
openSyncUiWindow: () => openSyncUiWindowHandler(),
openYoutubeBrowserWindow: () => youtubeBrowserWindowRuntime.open(),
exportLogs: () => {
void exportLogsFromTray();
},
@@ -6695,8 +6798,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
@@ -51,6 +51,7 @@ export interface CliCommandRuntimeServiceContext {
openYomitanSettings: () => void;
openConfigSettingsWindow: () => void;
openSyncUiWindow: () => void;
openYoutubeBrowserWindow: () => void;
cycleSecondarySubMode: () => void;
openRuntimeOptionsPalette: () => void;
printHelp: () => void;
@@ -137,6 +138,7 @@ function createCliCommandDepsFromContext(
openYomitanSettings: context.openYomitanSettings,
openConfigSettingsWindow: context.openConfigSettingsWindow,
openSyncUiWindow: context.openSyncUiWindow,
openYoutubeBrowserWindow: context.openYoutubeBrowserWindow,
cycleSecondarySubMode: context.cycleSecondarySubMode,
openRuntimeOptionsPalette: context.openRuntimeOptionsPalette,
printHelp: context.printHelp,
+2
View File
@@ -219,6 +219,7 @@ export interface CliCommandRuntimeServiceDepsParams {
openYomitanSettings: CliCommandDepsRuntimeOptions['ui']['openYomitanSettings'];
openConfigSettingsWindow: CliCommandDepsRuntimeOptions['ui']['openConfigSettingsWindow'];
openSyncUiWindow: CliCommandDepsRuntimeOptions['ui']['openSyncUiWindow'];
openYoutubeBrowserWindow: CliCommandDepsRuntimeOptions['ui']['openYoutubeBrowserWindow'];
cycleSecondarySubMode: CliCommandDepsRuntimeOptions['ui']['cycleSecondarySubMode'];
openRuntimeOptionsPalette: CliCommandDepsRuntimeOptions['ui']['openRuntimeOptionsPalette'];
printHelp: CliCommandDepsRuntimeOptions['ui']['printHelp'];
@@ -432,6 +433,7 @@ export function createCliCommandRuntimeServiceDeps(
openYomitanSettings: params.ui.openYomitanSettings,
openConfigSettingsWindow: params.ui.openConfigSettingsWindow,
openSyncUiWindow: params.ui.openSyncUiWindow,
openYoutubeBrowserWindow: params.ui.openYoutubeBrowserWindow,
cycleSecondarySubMode: params.ui.cycleSecondarySubMode,
openRuntimeOptionsPalette: params.ui.openRuntimeOptionsPalette,
printHelp: params.ui.printHelp,
@@ -75,6 +75,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'),
@@ -49,6 +49,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
openYomitanSettings: () => void;
openConfigSettingsWindow: () => void;
openSyncUiWindow: () => void;
openYoutubeBrowserWindow: () => void;
cycleSecondarySubMode: () => void;
openRuntimeOptionsPalette: () => void;
printHelp: () => void;
@@ -109,6 +110,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
openYomitanSettings: deps.openYomitanSettings,
openConfigSettingsWindow: deps.openConfigSettingsWindow,
openSyncUiWindow: deps.openSyncUiWindow,
openYoutubeBrowserWindow: deps.openYoutubeBrowserWindow,
cycleSecondarySubMode: deps.cycleSecondarySubMode,
openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette,
printHelp: deps.printHelp,
@@ -77,6 +77,7 @@ test('cli command context factory composes main deps and context handlers', () =
openYomitanSettings: () => {},
openConfigSettingsWindow: () => {},
openSyncUiWindow: () => {},
openYoutubeBrowserWindow: () => {},
cycleSecondarySubMode: () => {},
openRuntimeOptionsPalette: () => {},
printHelp: () => {},
@@ -106,6 +106,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'),
@@ -66,6 +66,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
openYomitanSettings: () => void;
openConfigSettingsWindow: () => void;
openSyncUiWindow: () => void;
openYoutubeBrowserWindow: () => void;
cycleSecondarySubMode: () => void;
openRuntimeOptionsPalette: () => void;
printHelp: () => void;
@@ -146,6 +147,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
openYomitanSettings: () => deps.openYomitanSettings(),
openConfigSettingsWindow: () => deps.openConfigSettingsWindow(),
openSyncUiWindow: () => deps.openSyncUiWindow(),
openYoutubeBrowserWindow: () => deps.openYoutubeBrowserWindow(),
cycleSecondarySubMode: () => deps.cycleSecondarySubMode(),
openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(),
printHelp: () => deps.printHelp(),
@@ -59,6 +59,7 @@ function createDeps() {
openYomitanSettings: () => {},
openConfigSettingsWindow: () => {},
openSyncUiWindow: () => {},
openYoutubeBrowserWindow: () => {},
cycleSecondarySubMode: () => {},
openRuntimeOptionsPalette: () => {},
printHelp: () => {},
+2
View File
@@ -54,6 +54,7 @@ export type CliCommandContextFactoryDeps = {
openYomitanSettings: () => void;
openConfigSettingsWindow: () => void;
openSyncUiWindow: () => void;
openYoutubeBrowserWindow: () => void;
cycleSecondarySubMode: () => void;
openRuntimeOptionsPalette: () => void;
printHelp: () => void;
@@ -136,6 +137,7 @@ export function createCliCommandContext(
openYomitanSettings: deps.openYomitanSettings,
openConfigSettingsWindow: deps.openConfigSettingsWindow,
openSyncUiWindow: deps.openSyncUiWindow,
openYoutubeBrowserWindow: deps.openYoutubeBrowserWindow,
cycleSecondarySubMode: deps.cycleSecondarySubMode,
openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette,
printHelp: deps.printHelp,
@@ -53,6 +53,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,
@@ -32,6 +32,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
yomitan: false,
settings: false,
syncWindow: false,
youtubeBrowser: false,
setup: false,
show: false,
hide: false,
@@ -1,11 +1,72 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import test, { beforeEach, afterEach } from 'node:test';
import {
clearLinuxMpvFullscreenOverlayRefreshTimeouts,
updateLinuxMpvFullscreenOverlayRefreshBurst,
scheduleLinuxVisibleOverlayFullscreenRefreshBurst,
} from './linux-mpv-fullscreen-overlay-refresh';
const compositorEnvKeys = [
'HYPRLAND_INSTANCE_SIGNATURE',
'SWAYSOCK',
'XDG_CURRENT_DESKTOP',
'XDG_SESSION_DESKTOP',
] as const;
const originalCompositorEnv = compositorEnvKeys.map((key) => [key, process.env[key]] as const);
beforeEach(() => {
for (const key of compositorEnvKeys) delete process.env[key];
});
afterEach(() => {
for (const [key, value] of originalCompositorEnv) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
});
for (const { compositorKey, expectedRefreshCalls } of [
{
compositorKey: 'HYPRLAND_INSTANCE_SIGNATURE',
expectedRefreshCalls: ['mode', 'visibility', 'mouse', 'restack'],
},
{
compositorKey: 'SWAYSOCK',
expectedRefreshCalls: ['mode', 'visibility', 'hide', 'showInactive', 'mouse', 'restack'],
},
]) {
test(`${compositorKey} fullscreen refresh uses compositor-specific restacking`, async () => {
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' });
process.env[compositorKey] = 'fullscreen-refresh-test';
const calls: string[] = [];
try {
scheduleLinuxVisibleOverlayFullscreenRefreshBurst(true, {
overlayManager: {
getMainWindow: () => ({
hide: () => calls.push('hide'),
showInactive: () => calls.push('showInactive'),
isDestroyed: () => false,
isVisible: () => true,
setIgnoreMouseEvents: () => calls.push('mouse'),
}),
getVisibleOverlayVisible: () => true,
},
overlayVisibilityRuntime: {
updateVisibleOverlayVisibility: () => calls.push('visibility'),
},
syncVisibleOverlayMpvFullscreenMode: () => calls.push('mode'),
ensureOverlayWindowLevel: () => calls.push('restack'),
});
await new Promise((resolve) => setTimeout(resolve, 700));
assert.deepEqual(calls, Array.from({ length: 5 }, () => expectedRefreshCalls).flat());
} finally {
clearLinuxMpvFullscreenOverlayRefreshTimeouts();
if (originalPlatformDescriptor) {
Object.defineProperty(process, 'platform', originalPlatformDescriptor);
}
}
});
}
test('linux mpv fullscreen overlay refresh burst schedules overlay refresh work on linux', async () => {
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
Object.defineProperty(process, 'platform', {
@@ -52,8 +52,12 @@ function refreshLinuxVisibleOverlayAfterMpvFullscreenChange(
return;
}
mainWindow.hide();
mainWindow.showInactive();
// Hyprland placement can restack in place. Remapping the overlay can
// take focus from mpv and make Hyprland cancel the fullscreen transition.
if (!process.env.HYPRLAND_INSTANCE_SIGNATURE) {
mainWindow.hide();
mainWindow.showInactive();
}
if (deps.getOverlayInteractionActive?.() === true) {
mainWindow.setIgnoreMouseEvents(false);
} else {
+26 -13
View File
@@ -410,14 +410,17 @@ test('media timing review downloads one window of a remote stream for the wavefo
endTime: 14.5,
},
]);
assert.deepEqual(previewStarts, [
{
mediaPath: '/tmp/window-7.5-14.5.mkv',
executablePath: 'mpv',
volume: 60,
absoluteTimestamps: true,
},
]);
assert.deepEqual(
previewStarts.map(({ onPlaybackPosition, ...options }) => options),
[
{
mediaPath: '/tmp/window-7.5-14.5.mkv',
executablePath: 'mpv',
volume: 60,
absoluteTimestamps: true,
},
],
);
assert.deepEqual(previewPlays, [['/tmp/window-7.5-14.5.mkv', 9.5, 12.5]]);
assert.deepEqual(disposed, ['/tmp/window-7.5-14.5.mkv']);
});
@@ -512,9 +515,10 @@ test('media timing review falls back to the remote stream after one failed windo
audioStreamIndex: 2,
},
]);
assert.deepEqual(previewStarts, [
{ mediaPath: REMOTE_STREAM_URL, executablePath: 'mpv', volume: 60, audioTrackId: 3 },
]);
assert.deepEqual(
previewStarts.map(({ onPlaybackPosition, ...options }) => options),
[{ mediaPath: REMOTE_STREAM_URL, executablePath: 'mpv', volume: 60, audioTrackId: 3 }],
);
assert.deepEqual(previewPlays, [[REMOTE_STREAM_URL, 9.5, 12.5]]);
});
@@ -981,7 +985,8 @@ test('disposing owns a preview session whose startup is still pending', async ()
test('media timing review forwards the hidden player finishing a preview to the modal', async () => {
const endedReviewIds: string[] = [];
const playback: { ended?: () => void } = {};
const positions: Array<{ reviewId: string; time: number }> = [];
const playback: { ended?: () => void; position?: (time: number) => void } = {};
let publishPayload!: (payload: MediaTimingReviewOpenPayload) => void;
const openedPayload = new Promise<MediaTimingReviewOpenPayload>((resolve) => {
publishPayload = resolve;
@@ -997,7 +1002,9 @@ test('media timing review forwards the hidden player finishing a preview to the
getMpvExecutablePath: () => 'mpv',
generateWaveform: async () => [],
createPreviewSession: () => ({
start: async () => undefined,
start: async (options) => {
playback.position = options.onPlaybackPosition;
},
play: async () => undefined,
stop: async () => undefined,
onPlaybackEnded: (listener) => {
@@ -1012,6 +1019,7 @@ test('media timing review forwards the hidden player finishing a preview to the
onPreviewEnded: (reviewId) => {
endedReviewIds.push(reviewId);
},
onPreviewPosition: (position) => positions.push(position),
showStatus: () => undefined,
});
const pendingDecision = runtime.requestReview({
@@ -1031,6 +1039,9 @@ test('media timing review forwards the hidden player finishing a preview to the
},
);
assert.ok(playback.ended);
assert.ok(playback.position);
playback.position(11);
assert.deepEqual(positions, [{ reviewId: payload.reviewId, time: 11 }]);
playback.ended();
assert.deepEqual(endedReviewIds, [payload.reviewId]);
@@ -1038,6 +1049,8 @@ test('media timing review forwards the hidden player finishing a preview to the
await pendingDecision;
playback.ended();
assert.deepEqual(endedReviewIds, [payload.reviewId]);
playback.position(12);
assert.deepEqual(positions, [{ reviewId: payload.reviewId, time: 11 }]);
});
test('preview reports a stale review when the review ends during playback', async () => {
+9 -7
View File
@@ -5,6 +5,7 @@ import type {
MediaTimingReviewDecision,
MediaTimingReviewOpenPayload,
MediaTimingReviewPreviewRequest,
MediaTimingReviewPreviewPosition,
MediaTimingReviewRequest,
MediaTimingReviewResolveRequest,
MediaTimingReviewFrameRequest,
@@ -13,6 +14,7 @@ import type {
MediaTimingReviewWaveformResult,
} from '../../types/anki';
import type { SpeechWaveformOptions } from '../../core/services/media-timing-waveform';
import type { MediaTimingPreviewStartOptions } from '../../core/services/media-timing-preview';
import type { MediaTimingFrameOptions } from '../../core/services/media-timing-frame';
import {
isRemoteMediaWindowSourcePath,
@@ -36,13 +38,7 @@ interface ReviewMpvClient {
}
interface PreviewSession {
start(options: {
mediaPath: string;
executablePath?: string;
audioTrackId?: number;
volume?: number;
absoluteTimestamps?: boolean;
}): Promise<void>;
start(options: MediaTimingPreviewStartOptions): Promise<void>;
play(startTime: number, endTime: number): Promise<void>;
stop(): Promise<void>;
/** Fires when the player reaches the end of the clip started by play(). */
@@ -113,6 +109,7 @@ export interface MediaTimingReviewRuntimeDeps {
openModal: (payload: MediaTimingReviewOpenPayload, signal: AbortSignal) => Promise<boolean>;
/** Tells the modal that the hidden player finished the previewed clip. */
onPreviewEnded?: (reviewId: string) => void;
onPreviewPosition?: (position: MediaTimingReviewPreviewPosition) => void;
showStatus: (message: string) => void;
}
@@ -354,6 +351,11 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
}
await session.start({
mediaPath,
onPlaybackPosition: (time) => {
if (active === review && review.preview?.session === started) {
deps.onPreviewPosition?.({ reviewId: review.payload.reviewId, time });
}
},
...previewOptions,
// A cached window keeps one audio stream, so mpv's track id from the source no longer applies.
...(window
@@ -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);
@@ -74,6 +75,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,
@@ -100,6 +102,7 @@ test('build tray template handler wires actions and init guards', () => {
'yomitan',
'configuration',
'sync-ui',
'youtube',
'export-logs',
'jellyfin',
'jellyfin-discovery:true',
@@ -130,6 +133,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
@@ -49,6 +49,7 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
openYomitanSettings: () => void;
openConfigSettings: () => void;
openSyncUi: () => void;
openYoutubeBrowser: () => void;
exportLogs: () => void;
openJellyfinSetup: () => void;
showJellyfinDiscovery: boolean;
@@ -70,6 +71,7 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
openYomitanSettings: () => void;
openConfigSettingsWindow: () => void;
openSyncUiWindow: () => void;
openYoutubeBrowserWindow: () => void;
exportLogs: () => void;
openJellyfinSetupWindow: () => void;
isJellyfinConfigured: () => boolean;
@@ -116,6 +118,9 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
openSyncUi: () => {
deps.openSyncUiWindow();
},
openYoutubeBrowser: () => {
deps.openYoutubeBrowserWindow();
},
exportLogs: () => {
deps.exportLogs();
},
+2
View File
@@ -34,6 +34,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,
@@ -61,6 +62,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
@@ -39,6 +39,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
openYomitanSettings: () => void;
openConfigSettings: () => void;
openSyncUi: () => void;
openYoutubeBrowser: () => void;
exportLogs: () => void;
openJellyfinSetup: () => void;
showJellyfinDiscovery: boolean;
@@ -60,6 +61,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
openYomitanSettings: () => void;
openConfigSettingsWindow: () => void;
openSyncUiWindow: () => void;
openYoutubeBrowserWindow: () => void;
exportLogs: () => void;
openJellyfinSetupWindow: () => void;
isJellyfinConfigured: () => boolean;
@@ -85,6 +87,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,
@@ -34,6 +34,7 @@ test('tray runtime handlers compose resolve/menu/ensure/destroy handlers', () =>
openYomitanSettings: () => {},
openConfigSettingsWindow: () => {},
openSyncUiWindow: () => {},
openYoutubeBrowserWindow: () => {},
exportLogs: () => {},
openJellyfinSetupWindow: () => {},
isJellyfinConfigured: () => false,
+8
View File
@@ -40,6 +40,7 @@ test('tray menu template contains expected entries and handlers', () => {
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,
@@ -69,6 +70,7 @@ test('tray menu template contains expected entries and handlers', () => {
'Open Yomitan Settings',
'Open SubMiner Settings',
'Sync Stats && History',
'Browse YouTube',
'Export Logs',
'Configure Jellyfin',
'Jellyfin Discovery',
@@ -88,6 +90,7 @@ test('tray menu template contains expected entries and handlers', () => {
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');
@@ -99,6 +102,7 @@ test('tray menu template contains expected entries and handlers', () => {
'changelog',
'texthooker',
'sync-ui',
'youtube',
'export-logs',
'updates',
'separator',
@@ -119,6 +123,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,
@@ -149,6 +154,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,
@@ -177,6 +183,7 @@ test('tray menu template renders active jellyfin discovery checkbox', () => {
openYomitanSettings: () => undefined,
openConfigSettings: () => undefined,
openSyncUi: () => undefined,
openYoutubeBrowser: () => undefined,
exportLogs: () => undefined,
openJellyfinSetup: () => undefined,
showJellyfinDiscovery: true,
@@ -206,6 +213,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
@@ -42,6 +42,7 @@ export type TrayMenuActionHandlers = {
openYomitanSettings: () => void;
openConfigSettings: () => void;
openSyncUi: () => void;
openYoutubeBrowser: () => void;
exportLogs: () => void;
openJellyfinSetup: () => void;
showJellyfinDiscovery: boolean;
@@ -113,6 +114,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,

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