mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-29 17:40:50 -07:00
feat(youtube): add YouTube browser window and Whisper subtitle source (#273)
This commit is contained in:
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -682,13 +682,14 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
// YouTube Playback Settings
|
// YouTube Playback Settings
|
||||||
// Defaults for managed subtitle language preferences and YouTube subtitle loading.
|
// 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": {
|
"youtube": {
|
||||||
"primarySubLanguages": [
|
"primarySubLanguages": [
|
||||||
"ja",
|
"ja",
|
||||||
"jpn"
|
"jpn"
|
||||||
], // Comma-separated primary subtitle language priority for managed subtitle auto-selection.
|
], // 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": {
|
"mediaCache": {
|
||||||
"mode": "direct", // How YouTube card audio/images are extracted. Values: direct | background
|
"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.
|
"maxHeight": 720 // Maximum video height downloaded for the YouTube background media cache. Set to 0 for unlimited.
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ These apply live:
|
|||||||
|
|
||||||
- `subtitleStyle`, `subtitleSidebar`, `subtitleSelection`, `keybindings`, `shortcuts`
|
- `subtitleStyle`, `subtitleSidebar`, `subtitleSelection`, `keybindings`, `shortcuts`
|
||||||
- `logging.level`, `logging.rotation`, `logging.files`
|
- `logging.level`, `logging.rotation`, `logging.files`
|
||||||
- `secondarySub.defaultMode`, `youtube.primarySubLanguages`
|
- `secondarySub.defaultMode`, `youtube.primarySubLanguages`, `youtube.subtitleSource`
|
||||||
- `mpv.aniskipEnabled`, `mpv.aniskipButtonKey`, `stats.toggleKey`, `stats.markWatchedKey`
|
- `mpv.aniskipEnabled`, `mpv.aniskipButtonKey`, `stats.toggleKey`, `stats.markWatchedKey`
|
||||||
- `ankiConnect.deck`, `ankiConnect.fields.*`, `ankiConnect.behavior.autoUpdateNewCards`
|
- `ankiConnect.deck`, `ankiConnect.fields.*`, `ankiConnect.behavior.autoUpdateNewCards`
|
||||||
- `ankiConnect.media.normalizeAudio`, `media.mirrorMpvVolume`, `media.reviewTiming`
|
- `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
|
### 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 |
|
| Key | Default | What it does |
|
||||||
| ------------------------------ | --------------- | -------------------------------------------------------------------------------------------- |
|
| ------------------------------ | --------------- | -------------------------------------------------------------------------------------------- |
|
||||||
| `youtube.primarySubLanguages` | `["ja", "jpn"]` | Languages that count as a valid primary track, also used for local playback |
|
| `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.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 |
|
| `youtube.mediaCache.maxHeight` | `720` | Maximum download height in `background` mode. `0` is unlimited |
|
||||||
|
|
||||||
|
|||||||
@@ -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` | 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 doctor --refresh-known-words` | Refresh the known-word cache from Anki |
|
||||||
| `subminer settings` | Open the settings window |
|
| `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 generate-subs [video]` | Generate [Japanese subtitles](/subtitle-generation) with whisper.cpp |
|
||||||
| `subminer jellyfin` / `jf` | [Jellyfin](/jellyfin-integration) actions: `setup`, `login`, `logout`, `play`, `discovery` |
|
| `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 |
|
| `subminer dictionary <path>` / `dict` | Build a [character dictionary](/character-dictionary) for a file or directory |
|
||||||
|
|||||||
@@ -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.
|
`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
|
## Auto-start behavior
|
||||||
|
|
||||||
|
|||||||
@@ -682,13 +682,14 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
// YouTube Playback Settings
|
// YouTube Playback Settings
|
||||||
// Defaults for managed subtitle language preferences and YouTube subtitle loading.
|
// 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": {
|
"youtube": {
|
||||||
"primarySubLanguages": [
|
"primarySubLanguages": [
|
||||||
"ja",
|
"ja",
|
||||||
"jpn"
|
"jpn"
|
||||||
], // Comma-separated primary subtitle language priority for managed subtitle auto-selection.
|
], // 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": {
|
"mediaCache": {
|
||||||
"mode": "direct", // How YouTube card audio/images are extracted. Values: direct | background
|
"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.
|
"maxHeight": 720 // Maximum video height downloaded for the YouTube background media cache. Set to 0 for unlimited.
|
||||||
|
|||||||
@@ -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.
|
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**.
|
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
|
## 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.
|
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.
|
3. Pick a model and download it if needed.
|
||||||
4. Optionally check **Focus on spoken dialogue** (see below).
|
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.
|
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`.
|
Change the shortcut with `shortcuts.openSubtitleGeneration`.
|
||||||
|
|
||||||
## Generating from the launcher
|
## Generating from the launcher
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ Language preferences live under `youtube` and `secondarySub` in the config. See
|
|||||||
```bash
|
```bash
|
||||||
subminer stats # start the immersion stats dashboard
|
subminer stats # start the immersion stats dashboard
|
||||||
subminer settings # open the settings window
|
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 doctor # check dependencies, config, and the mpv socket
|
||||||
subminer generate-subs video.mkv # make Japanese subtitles from the audio
|
subminer generate-subs video.mkv # make Japanese subtitles from the audio
|
||||||
subminer logs -e # export a log ZIP for bug reports
|
subminer logs -e # export a log ZIP for bug reports
|
||||||
|
|||||||
@@ -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
|
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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
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
|
## 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.
|
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.
|
**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.
|
A missing or failed secondary track never blocks playback.
|
||||||
|
|
||||||
## Stats
|
## Stats
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
launchTexthookerOnly,
|
launchTexthookerOnly,
|
||||||
runAppCommandWithInherit,
|
runAppCommandWithInherit,
|
||||||
} from '../mpv.js';
|
} from '../mpv.js';
|
||||||
|
import { shouldForwardLogLevel } from '../types.js';
|
||||||
import type { LauncherCommandContext } from './context.js';
|
import type { LauncherCommandContext } from './context.js';
|
||||||
|
|
||||||
type AppCommandDeps = {
|
type AppCommandDeps = {
|
||||||
@@ -12,6 +13,10 @@ type AppCommandDeps = {
|
|||||||
appPath: string,
|
appPath: string,
|
||||||
logLevel: LauncherCommandContext['args']['logLevel'],
|
logLevel: LauncherCommandContext['args']['logLevel'],
|
||||||
) => void;
|
) => void;
|
||||||
|
launchYoutubeBrowserDetached: (
|
||||||
|
appPath: string,
|
||||||
|
logLevel: LauncherCommandContext['args']['logLevel'],
|
||||||
|
) => void;
|
||||||
launchAppBackgroundDetached: (
|
launchAppBackgroundDetached: (
|
||||||
appPath: string,
|
appPath: string,
|
||||||
logLevel: LauncherCommandContext['args']['logLevel'],
|
logLevel: LauncherCommandContext['args']['logLevel'],
|
||||||
@@ -22,6 +27,11 @@ const defaultAppCommandDeps: AppCommandDeps = {
|
|||||||
runAppCommandWithInherit,
|
runAppCommandWithInherit,
|
||||||
launchSyncUiDetached: (appPath, logLevel) =>
|
launchSyncUiDetached: (appPath, logLevel) =>
|
||||||
launchAppCommandDetached(appPath, ['--sync-window'], logLevel, 'sync-ui'),
|
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,
|
launchAppBackgroundDetached,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -41,6 +51,10 @@ export function runAppPassthroughCommand(
|
|||||||
deps.launchSyncUiDetached(appPath, args.logLevel);
|
deps.launchSyncUiDetached(appPath, args.logLevel);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (args.youtubeBrowser) {
|
||||||
|
deps.launchYoutubeBrowserDetached(appPath, args.logLevel);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (!args.appPassthrough) {
|
if (!args.appPassthrough) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -207,6 +207,7 @@ test('app command starts default macOS background app detached from launcher', (
|
|||||||
calls.push('attached');
|
calls.push('attached');
|
||||||
},
|
},
|
||||||
launchSyncUiDetached: () => calls.push('sync-ui'),
|
launchSyncUiDetached: () => calls.push('sync-ui'),
|
||||||
|
launchYoutubeBrowserDetached: () => calls.push('youtube'),
|
||||||
launchAppBackgroundDetached: (appPath, logLevel) => {
|
launchAppBackgroundDetached: (appPath, logLevel) => {
|
||||||
calls.push(`detached:${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');
|
calls.push('attached');
|
||||||
},
|
},
|
||||||
launchSyncUiDetached: () => calls.push('sync-ui'),
|
launchSyncUiDetached: () => calls.push('sync-ui'),
|
||||||
|
launchYoutubeBrowserDetached: () => calls.push('youtube'),
|
||||||
launchAppBackgroundDetached: (appPath, logLevel) => {
|
launchAppBackgroundDetached: (appPath, logLevel) => {
|
||||||
calls.push(`detached:${appPath}:${logLevel}`);
|
calls.push(`detached:${appPath}:${logLevel}`);
|
||||||
},
|
},
|
||||||
@@ -248,6 +250,7 @@ test('app command keeps explicit passthrough args attached', () => {
|
|||||||
forwarded.push(appArgs);
|
forwarded.push(appArgs);
|
||||||
},
|
},
|
||||||
launchSyncUiDetached: () => detached.push('sync-ui'),
|
launchSyncUiDetached: () => detached.push('sync-ui'),
|
||||||
|
launchYoutubeBrowserDetached: () => detached.push('youtube'),
|
||||||
launchAppBackgroundDetached: () => {
|
launchAppBackgroundDetached: () => {
|
||||||
detached.push('detached');
|
detached.push('detached');
|
||||||
},
|
},
|
||||||
@@ -266,6 +269,7 @@ test('sync UI command launches the app detached from the terminal', () => {
|
|||||||
const handled = runAppPassthroughCommand(context, {
|
const handled = runAppPassthroughCommand(context, {
|
||||||
runAppCommandWithInherit: () => calls.push('piped'),
|
runAppCommandWithInherit: () => calls.push('piped'),
|
||||||
launchSyncUiDetached: (appPath, logLevel) => calls.push(`sync-ui:${appPath}:${logLevel}`),
|
launchSyncUiDetached: (appPath, logLevel) => calls.push(`sync-ui:${appPath}:${logLevel}`),
|
||||||
|
launchYoutubeBrowserDetached: () => calls.push('youtube'),
|
||||||
launchAppBackgroundDetached: () => calls.push('detached'),
|
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']);
|
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 () => {
|
test('mpv pre-app command exits non-zero when socket is not ready', async () => {
|
||||||
const context = createContext();
|
const context = createContext();
|
||||||
context.args.mpvStatus = true;
|
context.args.mpvStatus = true;
|
||||||
|
|||||||
@@ -16,20 +16,9 @@ function createContext(): LauncherCommandContext {
|
|||||||
recursive: false,
|
recursive: false,
|
||||||
profile: '',
|
profile: '',
|
||||||
startOverlay: false,
|
startOverlay: false,
|
||||||
youtubeMode: 'download',
|
|
||||||
whisperBin: '',
|
|
||||||
whisperModel: '',
|
|
||||||
whisperVadModel: '',
|
|
||||||
whisperThreads: 0,
|
|
||||||
youtubeSubgenOutDir: '',
|
|
||||||
youtubeSubgenAudioFormat: '',
|
|
||||||
youtubeSubgenKeepTemp: false,
|
|
||||||
youtubeFixWithAi: false,
|
|
||||||
youtubePrimarySubLangs: [],
|
youtubePrimarySubLangs: [],
|
||||||
youtubeSecondarySubLangs: [],
|
youtubeSecondarySubLangs: [],
|
||||||
youtubeAudioLangs: [],
|
youtubeAudioLangs: [],
|
||||||
youtubeWhisperSourceLanguage: '',
|
|
||||||
aiConfig: {},
|
|
||||||
useTexthooker: false,
|
useTexthooker: false,
|
||||||
autoStartOverlay: false,
|
autoStartOverlay: false,
|
||||||
texthookerOnly: false,
|
texthookerOnly: false,
|
||||||
@@ -63,6 +52,7 @@ function createContext(): LauncherCommandContext {
|
|||||||
logsExport: false,
|
logsExport: false,
|
||||||
version: false,
|
version: false,
|
||||||
settings: false,
|
settings: false,
|
||||||
|
youtubeBrowser: false,
|
||||||
configPath: false,
|
configPath: false,
|
||||||
configShow: false,
|
configShow: false,
|
||||||
mpvIdle: false,
|
mpvIdle: false,
|
||||||
@@ -162,7 +152,7 @@ test('youtube playback launches overlay with app-owned youtube flow args', async
|
|||||||
|
|
||||||
assert.deepEqual(calls, [
|
assert.deepEqual(calls, [
|
||||||
'startMpv',
|
'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]?.startPaused, true);
|
||||||
assert.equal(receivedStartMpvOptions[0]?.disableYoutubeSubtitleAutoLoad, true);
|
assert.equal(receivedStartMpvOptions[0]?.disableYoutubeSubtitleAutoLoad, true);
|
||||||
|
|||||||
@@ -240,7 +240,6 @@ export async function runPlaybackCommandWithDeps(
|
|||||||
|
|
||||||
const isYoutubeUrl = selectedTarget.kind === 'url' && isYoutubeTarget(selectedTarget.target);
|
const isYoutubeUrl = selectedTarget.kind === 'url' && isYoutubeTarget(selectedTarget.target);
|
||||||
const isAppOwnedYoutubeFlow = isYoutubeUrl;
|
const isAppOwnedYoutubeFlow = isYoutubeUrl;
|
||||||
const youtubeMode = args.youtubeMode ?? 'download';
|
|
||||||
const configDir = getLauncherConfigDir();
|
const configDir = getLauncherConfigDir();
|
||||||
|
|
||||||
if (isYoutubeUrl) {
|
if (isYoutubeUrl) {
|
||||||
@@ -325,7 +324,7 @@ export async function runPlaybackCommandWithDeps(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const extraAppArgs = isAppOwnedYoutubeFlow
|
const extraAppArgs = isAppOwnedYoutubeFlow
|
||||||
? ['--youtube-play', selectedTarget.target, '--youtube-mode', youtubeMode]
|
? ['--youtube-play', selectedTarget.target]
|
||||||
: shouldLauncherAttachRunningApp
|
: shouldLauncherAttachRunningApp
|
||||||
? [
|
? [
|
||||||
pluginRuntimeConfig.autoStartVisibleOverlay
|
pluginRuntimeConfig.autoStartVisibleOverlay
|
||||||
|
|||||||
@@ -12,25 +12,6 @@ import { getDefaultSocketPath } from './types.js';
|
|||||||
|
|
||||||
test('parseLauncherYoutubeSubgenConfig keeps only valid typed values', () => {
|
test('parseLauncherYoutubeSubgenConfig keeps only valid typed values', () => {
|
||||||
const parsed = parseLauncherYoutubeSubgenConfig({
|
const parsed = parseLauncherYoutubeSubgenConfig({
|
||||||
ai: {
|
|
||||||
enabled: true,
|
|
||||||
apiKey: 'shared-key',
|
|
||||||
baseUrl: 'https://openrouter.ai/api',
|
|
||||||
model: 'openrouter/shared-model',
|
|
||||||
systemPrompt: 'Legacy shared prompt.',
|
|
||||||
requestTimeoutMs: 12000,
|
|
||||||
},
|
|
||||||
youtubeSubgen: {
|
|
||||||
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: {
|
youtube: {
|
||||||
primarySubLanguages: ['ja', 42, 'en'],
|
primarySubLanguages: ['ja', 42, 'en'],
|
||||||
},
|
},
|
||||||
@@ -49,14 +30,6 @@ test('parseLauncherYoutubeSubgenConfig keeps only valid typed values', () => {
|
|||||||
assert.equal('mode' in parsed, false);
|
assert.equal('mode' in parsed, false);
|
||||||
assert.deepEqual(parsed.primarySubLanguages, ['ja', 'en']);
|
assert.deepEqual(parsed.primarySubLanguages, ['ja', 'en']);
|
||||||
assert.deepEqual(parsed.secondarySubLanguages, ['eng', 'deu']);
|
assert.deepEqual(parsed.secondarySubLanguages, ['eng', 'deu']);
|
||||||
assert.equal(parsed.whisperVadModel, '/models/vad.bin');
|
|
||||||
assert.equal(parsed.whisperThreads, 6);
|
|
||||||
assert.equal(parsed.fixWithAi, true);
|
|
||||||
assert.equal(parsed.ai?.enabled, true);
|
|
||||||
assert.equal(parsed.ai?.apiKey, 'shared-key');
|
|
||||||
assert.equal(parsed.ai?.model, 'openrouter/subgen-model');
|
|
||||||
assert.equal(parsed.ai?.systemPrompt, 'Fix subtitles only.');
|
|
||||||
assert.equal(parsed.ai?.requestTimeoutMs, 12000);
|
|
||||||
assert.equal(parsed.jimakuLanguagePreference, 'ja');
|
assert.equal(parsed.jimakuLanguagePreference, 'ja');
|
||||||
assert.equal(parsed.jimakuMaxEntryResults, 8);
|
assert.equal(parsed.jimakuMaxEntryResults, 8);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,30 +19,16 @@ function withTempDir<T>(fn: (dir: string) => T): T {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
test('createDefaultArgs normalizes configured language codes and env thread override', () => {
|
test('createDefaultArgs normalizes configured language codes', () => {
|
||||||
const originalThreads = process.env.SUBMINER_WHISPER_THREADS;
|
|
||||||
process.env.SUBMINER_WHISPER_THREADS = '7';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parsed = createDefaultArgs({
|
const parsed = createDefaultArgs({
|
||||||
primarySubLanguages: [' JA ', 'jpn', 'ja'],
|
primarySubLanguages: [' JA ', 'jpn', 'ja'],
|
||||||
secondarySubLanguages: ['en', 'ENG', ''],
|
secondarySubLanguages: ['en', 'ENG', ''],
|
||||||
whisperThreads: 2,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.deepEqual(parsed.youtubePrimarySubLangs, ['ja', 'jpn']);
|
assert.deepEqual(parsed.youtubePrimarySubLangs, ['ja', 'jpn']);
|
||||||
assert.deepEqual(parsed.youtubeSecondarySubLangs, ['en', 'eng']);
|
assert.deepEqual(parsed.youtubeSecondarySubLangs, ['en', 'eng']);
|
||||||
assert.deepEqual(parsed.youtubeAudioLangs, ['ja', 'jpn', 'en', 'eng']);
|
assert.deepEqual(parsed.youtubeAudioLangs, ['ja', 'jpn', 'en', 'eng']);
|
||||||
assert.equal(parsed.whisperThreads, 7);
|
|
||||||
assert.equal(parsed.youtubeWhisperSourceLanguage, 'ja');
|
|
||||||
assert.equal(parsed.profile, '');
|
assert.equal(parsed.profile, '');
|
||||||
} finally {
|
|
||||||
if (originalThreads === undefined) {
|
|
||||||
delete process.env.SUBMINER_WHISPER_THREADS;
|
|
||||||
} else {
|
|
||||||
process.env.SUBMINER_WHISPER_THREADS = originalThreads;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('createDefaultArgs seeds mpv profile from launcher config', () => {
|
test('createDefaultArgs seeds mpv profile from launcher config', () => {
|
||||||
@@ -120,6 +106,7 @@ test('applyInvocationsToArgs maps config and jellyfin invocation state', () => {
|
|||||||
logLevel: 'warn',
|
logLevel: 'warn',
|
||||||
},
|
},
|
||||||
settingsInvocation: null,
|
settingsInvocation: null,
|
||||||
|
youtubeBrowserInvocation: null,
|
||||||
mpvInvocation: null,
|
mpvInvocation: null,
|
||||||
appInvocation: null,
|
appInvocation: null,
|
||||||
dictionaryTriggered: false,
|
dictionaryTriggered: false,
|
||||||
@@ -174,6 +161,7 @@ test('applyInvocationsToArgs maps settings invocation to settings window', () =>
|
|||||||
settingsInvocation: {
|
settingsInvocation: {
|
||||||
logLevel: undefined,
|
logLevel: undefined,
|
||||||
},
|
},
|
||||||
|
youtubeBrowserInvocation: null,
|
||||||
mpvInvocation: null,
|
mpvInvocation: null,
|
||||||
appInvocation: null,
|
appInvocation: null,
|
||||||
dictionaryTriggered: false,
|
dictionaryTriggered: false,
|
||||||
@@ -221,6 +209,7 @@ test('applyInvocationsToArgs fails when config invocation has no action', () =>
|
|||||||
action: undefined,
|
action: undefined,
|
||||||
},
|
},
|
||||||
settingsInvocation: null,
|
settingsInvocation: null,
|
||||||
|
youtubeBrowserInvocation: null,
|
||||||
mpvInvocation: null,
|
mpvInvocation: null,
|
||||||
appInvocation: null,
|
appInvocation: null,
|
||||||
dictionaryTriggered: false,
|
dictionaryTriggered: false,
|
||||||
@@ -266,6 +255,7 @@ test('applyInvocationsToArgs maps texthooker browser-open request', () => {
|
|||||||
jellyfinInvocation: null,
|
jellyfinInvocation: null,
|
||||||
configInvocation: null,
|
configInvocation: null,
|
||||||
settingsInvocation: null,
|
settingsInvocation: null,
|
||||||
|
youtubeBrowserInvocation: null,
|
||||||
mpvInvocation: null,
|
mpvInvocation: null,
|
||||||
appInvocation: null,
|
appInvocation: null,
|
||||||
dictionaryTriggered: false,
|
dictionaryTriggered: false,
|
||||||
|
|||||||
@@ -13,14 +13,8 @@ import {
|
|||||||
DEFAULT_JIMAKU_API_BASE_URL,
|
DEFAULT_JIMAKU_API_BASE_URL,
|
||||||
DEFAULT_YOUTUBE_PRIMARY_SUB_LANGS,
|
DEFAULT_YOUTUBE_PRIMARY_SUB_LANGS,
|
||||||
DEFAULT_YOUTUBE_SECONDARY_SUB_LANGS,
|
DEFAULT_YOUTUBE_SECONDARY_SUB_LANGS,
|
||||||
DEFAULT_YOUTUBE_SUBGEN_OUT_DIR,
|
|
||||||
} from '../types.js';
|
} from '../types.js';
|
||||||
import {
|
import { isUrlTarget, resolvePathMaybe, uniqueNormalizedLangCodes } from '../util.js';
|
||||||
inferWhisperLanguage,
|
|
||||||
isUrlTarget,
|
|
||||||
resolvePathMaybe,
|
|
||||||
uniqueNormalizedLangCodes,
|
|
||||||
} from '../util.js';
|
|
||||||
import type { CliInvocations } from './cli-parser-builder.js';
|
import type { CliInvocations } from './cli-parser-builder.js';
|
||||||
|
|
||||||
function ensureTarget(target: string, parsed: Args): void {
|
function ensureTarget(target: string, parsed: Args): void {
|
||||||
@@ -131,18 +125,6 @@ export function createDefaultArgs(
|
|||||||
recursive: false,
|
recursive: false,
|
||||||
profile: mpvConfig.profile ?? '',
|
profile: mpvConfig.profile ?? '',
|
||||||
startOverlay: false,
|
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 || '',
|
jimakuApiKey: process.env.SUBMINER_JIMAKU_API_KEY || '',
|
||||||
jimakuApiKeyCommand: process.env.SUBMINER_JIMAKU_API_KEY_COMMAND || '',
|
jimakuApiKeyCommand: process.env.SUBMINER_JIMAKU_API_KEY_COMMAND || '',
|
||||||
jimakuApiBaseUrl: process.env.SUBMINER_JIMAKU_API_BASE_URL || DEFAULT_JIMAKU_API_BASE_URL,
|
jimakuApiBaseUrl: process.env.SUBMINER_JIMAKU_API_BASE_URL || DEFAULT_JIMAKU_API_BASE_URL,
|
||||||
@@ -170,6 +152,7 @@ export function createDefaultArgs(
|
|||||||
version: false,
|
version: false,
|
||||||
update: false,
|
update: false,
|
||||||
settings: false,
|
settings: false,
|
||||||
|
youtubeBrowser: false,
|
||||||
configPath: false,
|
configPath: false,
|
||||||
configShow: false,
|
configShow: false,
|
||||||
mpvIdle: false,
|
mpvIdle: false,
|
||||||
@@ -185,16 +168,6 @@ export function createDefaultArgs(
|
|||||||
youtubePrimarySubLangs: primarySubLangs,
|
youtubePrimarySubLangs: primarySubLangs,
|
||||||
youtubeSecondarySubLangs: secondarySubLangs,
|
youtubeSecondarySubLangs: secondarySubLangs,
|
||||||
youtubeAudioLangs,
|
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,
|
useTexthooker: true,
|
||||||
autoStartOverlay: false,
|
autoStartOverlay: false,
|
||||||
texthookerOnly: false,
|
texthookerOnly: false,
|
||||||
@@ -363,6 +336,13 @@ export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations
|
|||||||
parsed.settings = true;
|
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) {
|
||||||
if (invocations.mpvInvocation.logLevel) {
|
if (invocations.mpvInvocation.logLevel) {
|
||||||
parsed.logLevel = parseLogLevel(invocations.mpvInvocation.logLevel);
|
parsed.logLevel = parseLogLevel(invocations.mpvInvocation.logLevel);
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export interface CliInvocations {
|
|||||||
jellyfinInvocation: JellyfinInvocation | null;
|
jellyfinInvocation: JellyfinInvocation | null;
|
||||||
configInvocation: CommandActionInvocation | null;
|
configInvocation: CommandActionInvocation | null;
|
||||||
settingsInvocation: CommandActionInvocation | null;
|
settingsInvocation: CommandActionInvocation | null;
|
||||||
|
youtubeBrowserInvocation: CommandActionInvocation | null;
|
||||||
mpvInvocation: CommandActionInvocation | null;
|
mpvInvocation: CommandActionInvocation | null;
|
||||||
appInvocation: { appArgs: string[] } | null;
|
appInvocation: { appArgs: string[] } | null;
|
||||||
dictionaryTriggered: boolean;
|
dictionaryTriggered: boolean;
|
||||||
@@ -121,6 +122,8 @@ function getTopLevelCommand(argv: string[]): { name: string; index: number } | n
|
|||||||
'doctor',
|
'doctor',
|
||||||
'config',
|
'config',
|
||||||
'settings',
|
'settings',
|
||||||
|
'youtube',
|
||||||
|
'yt',
|
||||||
'mpv',
|
'mpv',
|
||||||
'logs',
|
'logs',
|
||||||
'dictionary',
|
'dictionary',
|
||||||
@@ -175,6 +178,7 @@ export function parseCliPrograms(
|
|||||||
let jellyfinInvocation: JellyfinInvocation | null = null;
|
let jellyfinInvocation: JellyfinInvocation | null = null;
|
||||||
let configInvocation: CommandActionInvocation | null = null;
|
let configInvocation: CommandActionInvocation | null = null;
|
||||||
let settingsInvocation: CommandActionInvocation | null = null;
|
let settingsInvocation: CommandActionInvocation | null = null;
|
||||||
|
let youtubeBrowserInvocation: CommandActionInvocation | null = null;
|
||||||
let mpvInvocation: CommandActionInvocation | null = null;
|
let mpvInvocation: CommandActionInvocation | null = null;
|
||||||
let appInvocation: { appArgs: string[] } | null = null;
|
let appInvocation: { appArgs: string[] } | null = null;
|
||||||
let dictionaryTriggered = false;
|
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
|
commandProgram
|
||||||
.command('mpv')
|
.command('mpv')
|
||||||
.description('MPV helpers')
|
.description('MPV helpers')
|
||||||
@@ -567,6 +582,7 @@ export function parseCliPrograms(
|
|||||||
jellyfinInvocation,
|
jellyfinInvocation,
|
||||||
configInvocation,
|
configInvocation,
|
||||||
settingsInvocation,
|
settingsInvocation,
|
||||||
|
youtubeBrowserInvocation,
|
||||||
mpvInvocation,
|
mpvInvocation,
|
||||||
appInvocation,
|
appInvocation,
|
||||||
dictionaryTriggered,
|
dictionaryTriggered,
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { LauncherYoutubeSubgenConfig } from '../types.js';
|
import type { LauncherYoutubeSubgenConfig } from '../types.js';
|
||||||
import { mergeAiConfig } from '../../src/ai/config.js';
|
|
||||||
|
|
||||||
function asStringArray(value: unknown): string[] | undefined {
|
function asStringArray(value: unknown): string[] | undefined {
|
||||||
if (!Array.isArray(value)) return undefined;
|
if (!Array.isArray(value)) return undefined;
|
||||||
@@ -9,11 +8,6 @@ function asStringArray(value: unknown): string[] | undefined {
|
|||||||
export function parseLauncherYoutubeSubgenConfig(
|
export function parseLauncherYoutubeSubgenConfig(
|
||||||
root: Record<string, unknown>,
|
root: Record<string, unknown>,
|
||||||
): LauncherYoutubeSubgenConfig {
|
): LauncherYoutubeSubgenConfig {
|
||||||
const youtubeSubgenRaw = root.youtubeSubgen;
|
|
||||||
const youtubeSubgen =
|
|
||||||
youtubeSubgenRaw && typeof youtubeSubgenRaw === 'object'
|
|
||||||
? (youtubeSubgenRaw as Record<string, unknown>)
|
|
||||||
: null;
|
|
||||||
const youtubeRaw = root.youtube;
|
const youtubeRaw = root.youtube;
|
||||||
const youtube =
|
const youtube =
|
||||||
youtubeRaw && typeof youtubeRaw === 'object' ? (youtubeRaw as Record<string, unknown>) : null;
|
youtubeRaw && typeof youtubeRaw === 'object' ? (youtubeRaw as Record<string, unknown>) : null;
|
||||||
@@ -25,58 +19,11 @@ export function parseLauncherYoutubeSubgenConfig(
|
|||||||
const jimakuRaw = root.jimaku;
|
const jimakuRaw = root.jimaku;
|
||||||
const jimaku =
|
const jimaku =
|
||||||
jimakuRaw && typeof jimakuRaw === 'object' ? (jimakuRaw as Record<string, unknown>) : null;
|
jimakuRaw && typeof jimakuRaw === 'object' ? (jimakuRaw as Record<string, unknown>) : null;
|
||||||
const aiRaw = root.ai;
|
|
||||||
const ai = aiRaw && typeof aiRaw === 'object' ? (aiRaw as Record<string, unknown>) : null;
|
|
||||||
const youtubeAiRaw = youtubeSubgen?.ai;
|
|
||||||
const youtubeAi =
|
|
||||||
youtubeAiRaw && typeof youtubeAiRaw === 'object'
|
|
||||||
? (youtubeAiRaw as Record<string, unknown>)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const jimakuLanguagePreference = jimaku?.languagePreference;
|
const jimakuLanguagePreference = jimaku?.languagePreference;
|
||||||
const jimakuMaxEntryResults = jimaku?.maxEntryResults;
|
const jimakuMaxEntryResults = jimaku?.maxEntryResults;
|
||||||
|
|
||||||
return {
|
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),
|
primarySubLanguages: asStringArray(youtube?.primarySubLanguages),
|
||||||
secondarySubLanguages: asStringArray(secondarySub?.secondarySubLanguages),
|
secondarySubLanguages: asStringArray(secondarySub?.secondarySubLanguages),
|
||||||
jimakuApiKey: typeof jimaku?.apiKey === 'string' ? jimaku.apiKey : undefined,
|
jimakuApiKey: typeof jimaku?.apiKey === 'string' ? jimaku.apiKey : undefined,
|
||||||
|
|||||||
@@ -10,20 +10,9 @@ function createArgs(): Args {
|
|||||||
recursive: false,
|
recursive: false,
|
||||||
profile: '',
|
profile: '',
|
||||||
startOverlay: false,
|
startOverlay: false,
|
||||||
youtubeMode: 'download',
|
|
||||||
whisperBin: '',
|
|
||||||
whisperModel: '',
|
|
||||||
whisperVadModel: '',
|
|
||||||
whisperThreads: 0,
|
|
||||||
youtubeSubgenOutDir: '',
|
|
||||||
youtubeSubgenAudioFormat: '',
|
|
||||||
youtubeSubgenKeepTemp: false,
|
|
||||||
youtubeFixWithAi: false,
|
|
||||||
youtubePrimarySubLangs: [],
|
youtubePrimarySubLangs: [],
|
||||||
youtubeSecondarySubLangs: [],
|
youtubeSecondarySubLangs: [],
|
||||||
youtubeAudioLangs: [],
|
youtubeAudioLangs: [],
|
||||||
youtubeWhisperSourceLanguage: '',
|
|
||||||
aiConfig: {},
|
|
||||||
useTexthooker: false,
|
useTexthooker: false,
|
||||||
autoStartOverlay: false,
|
autoStartOverlay: false,
|
||||||
texthookerOnly: false,
|
texthookerOnly: false,
|
||||||
@@ -57,6 +46,7 @@ function createArgs(): Args {
|
|||||||
logsExport: false,
|
logsExport: false,
|
||||||
version: false,
|
version: false,
|
||||||
settings: false,
|
settings: false,
|
||||||
|
youtubeBrowser: false,
|
||||||
configPath: false,
|
configPath: false,
|
||||||
configShow: false,
|
configShow: false,
|
||||||
mpvIdle: false,
|
mpvIdle: false,
|
||||||
|
|||||||
@@ -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', () => {
|
test('launcher settings command suppresses known Electron macOS menu diagnostics', () => {
|
||||||
withTempDir((root) => {
|
withTempDir((root) => {
|
||||||
const homeDir = path.join(root, 'home');
|
const homeDir = path.join(root, 'home');
|
||||||
|
|||||||
+2
-20
@@ -581,19 +581,9 @@ function makeArgs(overrides: Partial<Args> = {}): Args {
|
|||||||
recursive: false,
|
recursive: false,
|
||||||
profile: '',
|
profile: '',
|
||||||
startOverlay: false,
|
startOverlay: false,
|
||||||
whisperBin: '',
|
|
||||||
whisperModel: '',
|
|
||||||
whisperVadModel: '',
|
|
||||||
whisperThreads: 4,
|
|
||||||
youtubeSubgenOutDir: '',
|
|
||||||
youtubeSubgenAudioFormat: 'wav',
|
|
||||||
youtubeSubgenKeepTemp: false,
|
|
||||||
youtubeFixWithAi: false,
|
|
||||||
youtubePrimarySubLangs: [],
|
youtubePrimarySubLangs: [],
|
||||||
youtubeSecondarySubLangs: [],
|
youtubeSecondarySubLangs: [],
|
||||||
youtubeAudioLangs: [],
|
youtubeAudioLangs: [],
|
||||||
youtubeWhisperSourceLanguage: 'ja',
|
|
||||||
aiConfig: {},
|
|
||||||
useTexthooker: false,
|
useTexthooker: false,
|
||||||
autoStartOverlay: false,
|
autoStartOverlay: false,
|
||||||
texthookerOnly: false,
|
texthookerOnly: false,
|
||||||
@@ -627,6 +617,7 @@ function makeArgs(overrides: Partial<Args> = {}): Args {
|
|||||||
logsExport: false,
|
logsExport: false,
|
||||||
version: false,
|
version: false,
|
||||||
settings: false,
|
settings: false,
|
||||||
|
youtubeBrowser: false,
|
||||||
configPath: false,
|
configPath: false,
|
||||||
configShow: false,
|
configShow: false,
|
||||||
mpvIdle: false,
|
mpvIdle: false,
|
||||||
@@ -1084,30 +1075,21 @@ test('cleanupPlaybackSession stops launcher-managed overlay app and mpv-owned ch
|
|||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
} as unknown as NonNullable<typeof state.mpvProc>;
|
} 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.stopRequested = false;
|
||||||
state.appPath = appPath;
|
state.appPath = appPath;
|
||||||
state.overlayManagedByLauncher = true;
|
state.overlayManagedByLauncher = true;
|
||||||
state.overlayProc = overlayProc;
|
state.overlayProc = overlayProc;
|
||||||
state.mpvProc = mpvProc;
|
state.mpvProc = mpvProc;
|
||||||
state.youtubeSubgenChildren.add(helperProc);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await cleanupPlaybackSession(makeArgs());
|
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/);
|
assert.match(fs.readFileSync(appInvocationsPath, 'utf8'), /--stop/);
|
||||||
} finally {
|
} finally {
|
||||||
state.overlayProc = null;
|
state.overlayProc = null;
|
||||||
state.mpvProc = null;
|
state.mpvProc = null;
|
||||||
state.youtubeSubgenChildren.clear();
|
|
||||||
state.overlayManagedByLauncher = false;
|
state.overlayManagedByLauncher = false;
|
||||||
state.appPath = '';
|
state.appPath = '';
|
||||||
state.stopRequested = false;
|
state.stopRequested = false;
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ import {
|
|||||||
export const state = {
|
export const state = {
|
||||||
overlayProc: null as ReturnType<typeof spawn> | null,
|
overlayProc: null as ReturnType<typeof spawn> | null,
|
||||||
mpvProc: null as ReturnType<typeof spawn> | null,
|
mpvProc: null as ReturnType<typeof spawn> | null,
|
||||||
youtubeSubgenChildren: new Set<ReturnType<typeof spawn>>(),
|
|
||||||
appPath: '' as string,
|
appPath: '' as string,
|
||||||
overlayManagedByLauncher: false,
|
overlayManagedByLauncher: false,
|
||||||
stopRequested: 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);
|
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);
|
await terminateTrackedDetachedMpv(args.logLevel);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -107,6 +107,15 @@ test('parseArgs maps settings command to settings window', () => {
|
|||||||
assert.equal(parsed.configShow, false);
|
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', () => {
|
test('parseArgs maps config path action to config path output', () => {
|
||||||
const parsed = parseArgs(['config', 'path'], 'subminer', {});
|
const parsed = parseArgs(['config', 'path'], 'subminer', {});
|
||||||
|
|
||||||
|
|||||||
+1
-42
@@ -1,4 +1,3 @@
|
|||||||
import path from 'node:path';
|
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import type { MpvBackend, MpvLaunchMode } from '../src/types/config.js';
|
import type { MpvBackend, MpvLaunchMode } from '../src/types/config.js';
|
||||||
import type { SubtitleGenerationConfig } from '../src/shared/subtitle-generation.js';
|
import type { SubtitleGenerationConfig } from '../src/shared/subtitle-generation.js';
|
||||||
@@ -27,12 +26,6 @@ export const YOUTUBE_AUDIO_EXTENSIONS = new Set([
|
|||||||
'.aac',
|
'.aac',
|
||||||
'.flac',
|
'.flac',
|
||||||
]);
|
]);
|
||||||
export const DEFAULT_YOUTUBE_SUBGEN_OUT_DIR = path.join(
|
|
||||||
os.homedir(),
|
|
||||||
'.cache',
|
|
||||||
'subminer',
|
|
||||||
'youtube-subs',
|
|
||||||
);
|
|
||||||
export function getDefaultLauncherLogFile(options?: {
|
export function getDefaultLauncherLogFile(options?: {
|
||||||
platform?: NodeJS.Platform;
|
platform?: NodeJS.Platform;
|
||||||
homeDir?: string;
|
homeDir?: string;
|
||||||
@@ -78,16 +71,6 @@ export function shouldForwardLogLevel(level: LogLevel): boolean {
|
|||||||
export type Backend = 'auto' | 'hyprland' | 'sway' | 'x11' | 'macos' | 'windows';
|
export type Backend = 'auto' | 'hyprland' | 'sway' | 'x11' | 'macos' | 'windows';
|
||||||
export type JimakuLanguagePreference = 'ja' | 'en' | 'none';
|
export type JimakuLanguagePreference = 'ja' | 'en' | 'none';
|
||||||
|
|
||||||
export interface LauncherAiConfig {
|
|
||||||
enabled?: boolean;
|
|
||||||
apiKey?: string;
|
|
||||||
apiKeyCommand?: string;
|
|
||||||
baseUrl?: string;
|
|
||||||
model?: string;
|
|
||||||
systemPrompt?: string;
|
|
||||||
requestTimeoutMs?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Args {
|
export interface Args {
|
||||||
generateSubtitles?: {
|
generateSubtitles?: {
|
||||||
mediaPath?: string;
|
mediaPath?: string;
|
||||||
@@ -102,20 +85,9 @@ export interface Args {
|
|||||||
recursive: boolean;
|
recursive: boolean;
|
||||||
profile: string;
|
profile: string;
|
||||||
startOverlay: boolean;
|
startOverlay: boolean;
|
||||||
youtubeMode?: 'download' | 'generate';
|
|
||||||
whisperBin: string;
|
|
||||||
whisperModel: string;
|
|
||||||
whisperVadModel: string;
|
|
||||||
whisperThreads: number;
|
|
||||||
youtubeSubgenOutDir: string;
|
|
||||||
youtubeSubgenAudioFormat: string;
|
|
||||||
youtubeSubgenKeepTemp: boolean;
|
|
||||||
youtubeFixWithAi: boolean;
|
|
||||||
youtubePrimarySubLangs: string[];
|
youtubePrimarySubLangs: string[];
|
||||||
youtubeSecondarySubLangs: string[];
|
youtubeSecondarySubLangs: string[];
|
||||||
youtubeAudioLangs: string[];
|
youtubeAudioLangs: string[];
|
||||||
youtubeWhisperSourceLanguage: string;
|
|
||||||
aiConfig: LauncherAiConfig;
|
|
||||||
useTexthooker: boolean;
|
useTexthooker: boolean;
|
||||||
autoStartOverlay: boolean;
|
autoStartOverlay: boolean;
|
||||||
texthookerOnly: boolean;
|
texthookerOnly: boolean;
|
||||||
@@ -161,6 +133,7 @@ export interface Args {
|
|||||||
version: boolean;
|
version: boolean;
|
||||||
update?: boolean;
|
update?: boolean;
|
||||||
settings: boolean;
|
settings: boolean;
|
||||||
|
youtubeBrowser: boolean;
|
||||||
configPath: boolean;
|
configPath: boolean;
|
||||||
configShow: boolean;
|
configShow: boolean;
|
||||||
mpvIdle: boolean;
|
mpvIdle: boolean;
|
||||||
@@ -176,12 +149,6 @@ export interface Args {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface LauncherYoutubeSubgenConfig {
|
export interface LauncherYoutubeSubgenConfig {
|
||||||
whisperBin?: string;
|
|
||||||
whisperModel?: string;
|
|
||||||
whisperVadModel?: string;
|
|
||||||
whisperThreads?: number;
|
|
||||||
fixWithAi?: boolean;
|
|
||||||
ai?: LauncherAiConfig;
|
|
||||||
primarySubLanguages?: string[];
|
primarySubLanguages?: string[];
|
||||||
secondarySubLanguages?: string[];
|
secondarySubLanguages?: string[];
|
||||||
jimakuApiKey?: string;
|
jimakuApiKey?: string;
|
||||||
@@ -252,14 +219,6 @@ export interface SubtitleCandidate {
|
|||||||
source: 'manual' | 'whisper' | 'whisper-fixed' | 'whisper-translate' | 'whisper-translate-fixed';
|
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 {
|
export interface MpvTrack {
|
||||||
type?: string;
|
type?: string;
|
||||||
id?: number;
|
id?: number;
|
||||||
|
|||||||
@@ -236,14 +236,6 @@ export function parseBoolLike(value: string): boolean | null {
|
|||||||
return 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 {
|
export interface CommandInvocationOptions {
|
||||||
normalizeWindowsShellArgs?: boolean;
|
normalizeWindowsShellArgs?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ function M.create(ctx)
|
|||||||
mp.register_script_message("subminer-autoplay-ready", function()
|
mp.register_script_message("subminer-autoplay-ready", function()
|
||||||
process.notify_auto_play_ready()
|
process.notify_auto_play_ready()
|
||||||
end)
|
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()
|
mp.register_script_message("subminer-overlay-loading-ready", function()
|
||||||
process.stop_overlay_loading_osd()
|
process.stop_overlay_loading_osd()
|
||||||
end)
|
end)
|
||||||
|
|||||||
@@ -351,6 +351,17 @@ function M.create(ctx)
|
|||||||
return true
|
return true
|
||||||
end
|
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 function arm_auto_play_ready_gate()
|
||||||
local was_armed = state.auto_play_ready_gate_armed
|
local was_armed = state.auto_play_ready_gate_armed
|
||||||
if was_armed then
|
if was_armed then
|
||||||
@@ -1020,6 +1031,7 @@ function M.create(ctx)
|
|||||||
check_status = check_status,
|
check_status = check_status,
|
||||||
check_binary_available = check_binary_available,
|
check_binary_available = check_binary_available,
|
||||||
notify_auto_play_ready = notify_auto_play_ready,
|
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,
|
disarm_auto_play_ready_gate = disarm_auto_play_ready_gate,
|
||||||
start_overlay_loading_osd = start_overlay_loading_osd,
|
start_overlay_loading_osd = start_overlay_loading_osd,
|
||||||
stop_overlay_loading_osd = stop_overlay_loading_osd,
|
stop_overlay_loading_osd = stop_overlay_loading_osd,
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -15,6 +15,7 @@ const REQUIRED_APP_FILES = [
|
|||||||
'dist/preload-syncui.js',
|
'dist/preload-syncui.js',
|
||||||
'dist/preload-stats.js',
|
'dist/preload-stats.js',
|
||||||
'dist/preload-jellyfin-setup.js',
|
'dist/preload-jellyfin-setup.js',
|
||||||
|
'dist/preload-youtube-browser.js',
|
||||||
'dist/fonts/MPLUS1[wght].ttf',
|
'dist/fonts/MPLUS1[wght].ttf',
|
||||||
'stats/dist/index.html',
|
'stats/dist/index.html',
|
||||||
'vendor/texthooker-ui/docs/index.html',
|
'vendor/texthooker-ui/docs/index.html',
|
||||||
|
|||||||
@@ -2476,4 +2476,41 @@ do
|
|||||||
)
|
)
|
||||||
end
|
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")
|
print("plugin start gate regression tests: OK")
|
||||||
|
|||||||
@@ -97,15 +97,9 @@ test('parseArgs captures launch-mpv targets and keeps it out of app startup', ()
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('parseArgs captures youtube startup forwarding flags', () => {
|
test('parseArgs captures youtube startup forwarding flags', () => {
|
||||||
const args = parseArgs([
|
const args = parseArgs(['--youtube-play', 'https://youtube.com/watch?v=abc']);
|
||||||
'--youtube-play',
|
|
||||||
'https://youtube.com/watch?v=abc',
|
|
||||||
'--youtube-mode',
|
|
||||||
'generate',
|
|
||||||
]);
|
|
||||||
|
|
||||||
assert.equal(args.youtubePlay, '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(hasExplicitCommand(args), true);
|
||||||
assert.equal(shouldStartApp(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(commandNeedsOverlayRuntime(settings), false);
|
||||||
assert.equal(commandNeedsOverlayStartupPrereqs(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']);
|
const yomitanWithOverlay = parseArgs(['--yomitan', '--toggle-visible-overlay']);
|
||||||
assert.equal(yomitanWithOverlay.yomitan, true);
|
assert.equal(yomitanWithOverlay.yomitan, true);
|
||||||
assert.equal(yomitanWithOverlay.toggleVisibleOverlay, true);
|
assert.equal(yomitanWithOverlay.toggleVisibleOverlay, true);
|
||||||
|
|||||||
+7
-8
@@ -7,7 +7,6 @@ export interface CliArgs {
|
|||||||
launchMpv: boolean;
|
launchMpv: boolean;
|
||||||
launchMpvTargets: string[];
|
launchMpvTargets: string[];
|
||||||
youtubePlay?: string;
|
youtubePlay?: string;
|
||||||
youtubeMode?: 'download' | 'generate';
|
|
||||||
stop: boolean;
|
stop: boolean;
|
||||||
toggle: boolean;
|
toggle: boolean;
|
||||||
toggleVisibleOverlay: boolean;
|
toggleVisibleOverlay: boolean;
|
||||||
@@ -15,6 +14,7 @@ export interface CliArgs {
|
|||||||
yomitan: boolean;
|
yomitan: boolean;
|
||||||
settings: boolean;
|
settings: boolean;
|
||||||
syncWindow: boolean;
|
syncWindow: boolean;
|
||||||
|
youtubeBrowser: boolean;
|
||||||
setup: boolean;
|
setup: boolean;
|
||||||
show: boolean;
|
show: boolean;
|
||||||
hide: boolean;
|
hide: boolean;
|
||||||
@@ -128,7 +128,6 @@ export function parseArgs(argv: string[]): CliArgs {
|
|||||||
launchMpv: false,
|
launchMpv: false,
|
||||||
launchMpvTargets: [],
|
launchMpvTargets: [],
|
||||||
youtubePlay: undefined,
|
youtubePlay: undefined,
|
||||||
youtubeMode: undefined,
|
|
||||||
stop: false,
|
stop: false,
|
||||||
toggle: false,
|
toggle: false,
|
||||||
toggleVisibleOverlay: false,
|
toggleVisibleOverlay: false,
|
||||||
@@ -136,6 +135,7 @@ export function parseArgs(argv: string[]): CliArgs {
|
|||||||
yomitan: false,
|
yomitan: false,
|
||||||
settings: false,
|
settings: false,
|
||||||
syncWindow: false,
|
syncWindow: false,
|
||||||
|
youtubeBrowser: false,
|
||||||
setup: false,
|
setup: false,
|
||||||
show: false,
|
show: false,
|
||||||
hide: false,
|
hide: false,
|
||||||
@@ -270,12 +270,6 @@ export function parseArgs(argv: string[]): CliArgs {
|
|||||||
} else if (arg === '--youtube-play') {
|
} else if (arg === '--youtube-play') {
|
||||||
const value = readValue(argv[i + 1]);
|
const value = readValue(argv[i + 1]);
|
||||||
if (value) args.youtubePlay = value;
|
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') {
|
} else if (arg === '--launch-mpv') {
|
||||||
args.launchMpv = true;
|
args.launchMpv = true;
|
||||||
args.launchMpvTargets = argv.slice(i + 1).filter((value) => value && !value.startsWith('--'));
|
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 === '--yomitan') args.yomitan = true;
|
||||||
else if (arg === '--settings') args.settings = true;
|
else if (arg === '--settings') args.settings = true;
|
||||||
else if (arg === '--sync-window') args.syncWindow = 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 === '--setup') args.setup = true;
|
||||||
else if (arg === '--show') args.show = true;
|
else if (arg === '--show') args.show = true;
|
||||||
else if (arg === '--hide') args.hide = true;
|
else if (arg === '--hide') args.hide = true;
|
||||||
@@ -570,6 +565,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
|
|||||||
args.yomitan ||
|
args.yomitan ||
|
||||||
args.settings ||
|
args.settings ||
|
||||||
args.syncWindow ||
|
args.syncWindow ||
|
||||||
|
args.youtubeBrowser ||
|
||||||
args.setup ||
|
args.setup ||
|
||||||
args.show ||
|
args.show ||
|
||||||
args.hide ||
|
args.hide ||
|
||||||
@@ -649,6 +645,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
|
|||||||
!args.yomitan &&
|
!args.yomitan &&
|
||||||
!args.settings &&
|
!args.settings &&
|
||||||
!args.syncWindow &&
|
!args.syncWindow &&
|
||||||
|
!args.youtubeBrowser &&
|
||||||
!args.setup &&
|
!args.setup &&
|
||||||
!args.show &&
|
!args.show &&
|
||||||
!args.hide &&
|
!args.hide &&
|
||||||
@@ -721,6 +718,7 @@ export function shouldStartApp(args: CliArgs): boolean {
|
|||||||
args.yomitan ||
|
args.yomitan ||
|
||||||
args.settings ||
|
args.settings ||
|
||||||
args.syncWindow ||
|
args.syncWindow ||
|
||||||
|
args.youtubeBrowser ||
|
||||||
args.setup ||
|
args.setup ||
|
||||||
args.copySubtitle ||
|
args.copySubtitle ||
|
||||||
args.copySubtitleMultiple ||
|
args.copySubtitleMultiple ||
|
||||||
@@ -779,6 +777,7 @@ export function shouldRunYomitanOnlyStartup(args: CliArgs): boolean {
|
|||||||
!args.togglePrimarySubtitleBar &&
|
!args.togglePrimarySubtitleBar &&
|
||||||
!args.settings &&
|
!args.settings &&
|
||||||
!args.syncWindow &&
|
!args.syncWindow &&
|
||||||
|
!args.youtubeBrowser &&
|
||||||
!args.show &&
|
!args.show &&
|
||||||
!args.hide &&
|
!args.hide &&
|
||||||
!args.setup &&
|
!args.setup &&
|
||||||
|
|||||||
@@ -79,6 +79,9 @@ ${B}Jellyfin${R}
|
|||||||
--jellyfin-audio-stream-index ${D}N${R} Audio stream override
|
--jellyfin-audio-stream-index ${D}N${R} Audio stream override
|
||||||
--jellyfin-subtitle-stream-index ${D}N${R} Subtitle 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}
|
${B}Stats sync${R}
|
||||||
--sync-window Open the stats sync window
|
--sync-window Open the stats sync window
|
||||||
--sync-cli sync ${D}[host] [opts]${R} Headless stats sync ${D}(same commands as "subminer sync";${R}
|
--sync-cli sync ${D}[host] [opts]${R} Headless stats sync ${D}(same commands as "subminer sync";${R}
|
||||||
|
|||||||
+24
-16
@@ -1757,11 +1757,6 @@ test('parses global shortcuts and startup settings', () => {
|
|||||||
},
|
},
|
||||||
"youtube": {
|
"youtube": {
|
||||||
"primarySubLanguages": ["ja", "jpn", "jp"]
|
"primarySubLanguages": ["ja", "jpn", "jp"]
|
||||||
},
|
|
||||||
"youtubeSubgen": {
|
|
||||||
"whisperVadModel": "/models/vad.bin",
|
|
||||||
"whisperThreads": 12,
|
|
||||||
"fixWithAi": true
|
|
||||||
}
|
}
|
||||||
}`,
|
}`,
|
||||||
'utf-8',
|
'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.toggleVisibleOverlayGlobal, 'Alt+Shift+U');
|
||||||
assert.equal(config.shortcuts.openJimaku, 'Ctrl+Alt+J');
|
assert.equal(config.shortcuts.openJimaku, 'Ctrl+Alt+J');
|
||||||
assert.deepEqual(config.youtube.primarySubLanguages, ['ja', 'jpn', 'jp']);
|
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', () => {
|
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",
|
"model": "openrouter/anki-model",
|
||||||
"systemPrompt": "Translate mined sentence text."
|
"systemPrompt": "Translate mined sentence text."
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"youtubeSubgen": {
|
|
||||||
"ai": {
|
|
||||||
"model": "openrouter/subgen-model",
|
|
||||||
"systemPrompt": "Fix subtitle mistakes only."
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}`,
|
}`,
|
||||||
'utf-8',
|
'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.enabled, true);
|
||||||
assert.equal(config.ankiConnect.ai.model, 'openrouter/anki-model');
|
assert.equal(config.ankiConnect.ai.model, 'openrouter/anki-model');
|
||||||
assert.equal(config.ankiConnect.ai.systemPrompt, 'Translate mined sentence text.');
|
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', () => {
|
test('warns and falls back when ankiConnect.ai override values are invalid', () => {
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ const {
|
|||||||
jellyfin,
|
jellyfin,
|
||||||
discordPresence,
|
discordPresence,
|
||||||
ai,
|
ai,
|
||||||
youtubeSubgen,
|
|
||||||
} = INTEGRATIONS_DEFAULT_CONFIG;
|
} = INTEGRATIONS_DEFAULT_CONFIG;
|
||||||
const { subtitleStyle, subtitleSidebar } = SUBTITLE_DEFAULT_CONFIG;
|
const { subtitleStyle, subtitleSidebar } = SUBTITLE_DEFAULT_CONFIG;
|
||||||
const { immersionTracking } = IMMERSION_DEFAULT_CONFIG;
|
const { immersionTracking } = IMMERSION_DEFAULT_CONFIG;
|
||||||
@@ -85,7 +84,6 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
|
|||||||
jellyfin,
|
jellyfin,
|
||||||
discordPresence,
|
discordPresence,
|
||||||
ai,
|
ai,
|
||||||
youtubeSubgen,
|
|
||||||
immersionTracking,
|
immersionTracking,
|
||||||
stats,
|
stats,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
|||||||
},
|
},
|
||||||
youtube: {
|
youtube: {
|
||||||
primarySubLanguages: ['ja', 'jpn'],
|
primarySubLanguages: ['ja', 'jpn'],
|
||||||
|
subtitleSource: 'youtube',
|
||||||
mediaCache: {
|
mediaCache: {
|
||||||
mode: 'direct',
|
mode: 'direct',
|
||||||
maxHeight: 720,
|
maxHeight: 720,
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
|||||||
| 'jellyfin'
|
| 'jellyfin'
|
||||||
| 'discordPresence'
|
| 'discordPresence'
|
||||||
| 'ai'
|
| 'ai'
|
||||||
| 'youtubeSubgen'
|
|
||||||
> = {
|
> = {
|
||||||
ankiConnect: {
|
ankiConnect: {
|
||||||
enabled: true,
|
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.',
|
'You are a translation engine. Return only the translated text with no explanations.',
|
||||||
requestTimeoutMs: 15_000,
|
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.textShadow',
|
||||||
'subtitleStyle.WebkitTextStroke',
|
'subtitleStyle.WebkitTextStroke',
|
||||||
'subtitleStyle.wordSpacing',
|
'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', () => {
|
test('config option registry includes critical paths and has unique entries', () => {
|
||||||
|
|||||||
@@ -119,6 +119,18 @@ export function buildCoreConfigOptionRegistry(
|
|||||||
description:
|
description:
|
||||||
'Comma-separated primary subtitle language priority for managed subtitle auto-selection.',
|
'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',
|
path: 'youtube.mediaCache.mode',
|
||||||
kind: 'enum',
|
kind: 'enum',
|
||||||
|
|||||||
@@ -183,7 +183,9 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
|||||||
description: [
|
description: [
|
||||||
'Defaults for managed subtitle language preferences and YouTube subtitle loading.',
|
'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',
|
key: 'youtube',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const HOT_RELOAD_EXACT_OR_PREFIX_PATHS = [
|
|||||||
'logging.rotation',
|
'logging.rotation',
|
||||||
'logging.files',
|
'logging.files',
|
||||||
'youtube.primarySubLanguages',
|
'youtube.primarySubLanguages',
|
||||||
|
'youtube.subtitleSource',
|
||||||
'ankiConnect.deck',
|
'ankiConnect.deck',
|
||||||
'ankiConnect.media.normalizeAudio',
|
'ankiConnect.media.normalizeAudio',
|
||||||
'ankiConnect.media.mirrorMpvVolume',
|
'ankiConnect.media.mirrorMpvVolume',
|
||||||
|
|||||||
@@ -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)) {
|
if (isObject(src.youtube.mediaCache)) {
|
||||||
const mode = src.youtube.mediaCache.mode;
|
const mode = src.youtube.mediaCache.mode;
|
||||||
if (mode === 'direct' || mode === 'background') {
|
if (mode === 'direct' || mode === 'background') {
|
||||||
|
|||||||
@@ -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)) {
|
if (isObject(src.subtitleStyle)) {
|
||||||
const fallbackSubtitleStyleEnableJlpt = resolved.subtitleStyle.enableJlpt;
|
const fallbackSubtitleStyleEnableJlpt = resolved.subtitleStyle.enableJlpt;
|
||||||
const fallbackSubtitleStylePrimaryDefaultMode = resolved.subtitleStyle.primaryDefaultMode;
|
const fallbackSubtitleStylePrimaryDefaultMode = resolved.subtitleStyle.primaryDefaultMode;
|
||||||
|
|||||||
@@ -328,7 +328,6 @@ test('settings registry hides app-managed and inactive config surfaces', () => {
|
|||||||
'controller.preferredGamepadId',
|
'controller.preferredGamepadId',
|
||||||
'controller.preferredGamepadLabel',
|
'controller.preferredGamepadLabel',
|
||||||
'controller.profiles',
|
'controller.profiles',
|
||||||
'youtubeSubgen.whisperBin',
|
|
||||||
'jellyfin.defaultLibraryId',
|
'jellyfin.defaultLibraryId',
|
||||||
'subtitleSidebar.toggleKey',
|
'subtitleSidebar.toggleKey',
|
||||||
'jellyfin.recentServers',
|
'jellyfin.recentServers',
|
||||||
|
|||||||
@@ -61,7 +61,6 @@ export const LEGACY_HIDDEN_CONFIG_PATHS = [
|
|||||||
'controller.preferredGamepadId',
|
'controller.preferredGamepadId',
|
||||||
'controller.preferredGamepadLabel',
|
'controller.preferredGamepadLabel',
|
||||||
'controller.profiles',
|
'controller.profiles',
|
||||||
'youtubeSubgen.primarySubLanguages',
|
|
||||||
'anilist.characterDictionary.enabled',
|
'anilist.characterDictionary.enabled',
|
||||||
'anilist.characterDictionary.refreshTtlHours',
|
'anilist.characterDictionary.refreshTtlHours',
|
||||||
'anilist.characterDictionary.evictionPolicy',
|
'anilist.characterDictionary.evictionPolicy',
|
||||||
@@ -76,12 +75,7 @@ export const LEGACY_HIDDEN_CONFIG_PATHS = [
|
|||||||
'jellyfin.recentServers',
|
'jellyfin.recentServers',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const EXCLUDED_PREFIXES = [
|
const EXCLUDED_PREFIXES = ['ai', 'ankiConnect.ai', 'controller.buttonIndices'] as const;
|
||||||
'ai',
|
|
||||||
'ankiConnect.ai',
|
|
||||||
'controller.buttonIndices',
|
|
||||||
'youtubeSubgen',
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const JSON_OBJECT_FIELDS = new Set([
|
const JSON_OBJECT_FIELDS = new Set([
|
||||||
'keybindings',
|
'keybindings',
|
||||||
@@ -525,7 +519,6 @@ function topSection(path: string): string {
|
|||||||
websocket: 'WebSocket server',
|
websocket: 'WebSocket server',
|
||||||
yomitan: 'Yomitan',
|
yomitan: 'Yomitan',
|
||||||
youtube: 'YouTube Playback Settings',
|
youtube: 'YouTube Playback Settings',
|
||||||
youtubeSubgen: 'YouTube subtitle generation',
|
|
||||||
auto_start_overlay: 'Playback Behavior',
|
auto_start_overlay: 'Playback Behavior',
|
||||||
};
|
};
|
||||||
return labels[top] ?? humanizePath(top);
|
return labels[top] ?? humanizePath(top);
|
||||||
@@ -725,9 +718,7 @@ function fieldForLeaf(leaf: Leaf): ConfigSettingsField {
|
|||||||
...(option?.enumLabels ? { enumLabels: option.enumLabels } : {}),
|
...(option?.enumLabels ? { enumLabels: option.enumLabels } : {}),
|
||||||
restartBehavior: getConfigHotReloadField(leaf.path) ? 'hot-reload' : 'restart',
|
restartBehavior: getConfigHotReloadField(leaf.path) ? 'hot-reload' : 'restart',
|
||||||
advanced:
|
advanced:
|
||||||
leaf.path.startsWith('controller.') ||
|
leaf.path.startsWith('controller.') || leaf.path.startsWith('immersionTracking.retention.'),
|
||||||
leaf.path.startsWith('immersionTracking.retention.') ||
|
|
||||||
leaf.path.startsWith('youtubeSubgen.'),
|
|
||||||
secret: SECRET_PATHS.has(leaf.path),
|
secret: SECRET_PATHS.has(leaf.path),
|
||||||
settingsHidden: SUBTITLE_CSS_MANAGED_CONFIG_PATHS.has(leaf.path),
|
settingsHidden: SUBTITLE_CSS_MANAGED_CONFIG_PATHS.has(leaf.path),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -21,13 +21,6 @@ const SUBTITLE_CSS_SCOPES: SubtitleCssScope[] = ['primary', 'secondary', 'sideba
|
|||||||
const HIDDEN_TEMPLATE_PATHS = [
|
const HIDDEN_TEMPLATE_PATHS = [
|
||||||
'anilist.characterDictionary.evictionPolicy',
|
'anilist.characterDictionary.evictionPolicy',
|
||||||
'anilist.characterDictionary.refreshTtlHours',
|
'anilist.characterDictionary.refreshTtlHours',
|
||||||
'youtubeSubgen.ai.model',
|
|
||||||
'youtubeSubgen.ai.systemPrompt',
|
|
||||||
'youtubeSubgen.fixWithAi',
|
|
||||||
'youtubeSubgen.whisperBin',
|
|
||||||
'youtubeSubgen.whisperModel',
|
|
||||||
'youtubeSubgen.whisperThreads',
|
|
||||||
'youtubeSubgen.whisperVadModel',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
function normalizeCommentText(value: string): string {
|
function normalizeCommentText(value: string): string {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
|||||||
yomitan: false,
|
yomitan: false,
|
||||||
settings: false,
|
settings: false,
|
||||||
syncWindow: false,
|
syncWindow: false,
|
||||||
|
youtubeBrowser: false,
|
||||||
setup: false,
|
setup: false,
|
||||||
show: false,
|
show: false,
|
||||||
hide: false,
|
hide: false,
|
||||||
|
|||||||
@@ -15,13 +15,13 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
|||||||
launchMpv: false,
|
launchMpv: false,
|
||||||
launchMpvTargets: [],
|
launchMpvTargets: [],
|
||||||
youtubePlay: undefined,
|
youtubePlay: undefined,
|
||||||
youtubeMode: undefined,
|
|
||||||
stop: false,
|
stop: false,
|
||||||
toggle: false,
|
toggle: false,
|
||||||
toggleVisibleOverlay: false,
|
toggleVisibleOverlay: false,
|
||||||
yomitan: false,
|
yomitan: false,
|
||||||
settings: false,
|
settings: false,
|
||||||
syncWindow: false,
|
syncWindow: false,
|
||||||
|
youtubeBrowser: false,
|
||||||
setup: false,
|
setup: false,
|
||||||
show: false,
|
show: false,
|
||||||
hide: false,
|
hide: false,
|
||||||
@@ -142,6 +142,9 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
|
|||||||
openSyncUiWindow: () => {
|
openSyncUiWindow: () => {
|
||||||
calls.push('openSyncUiWindow');
|
calls.push('openSyncUiWindow');
|
||||||
},
|
},
|
||||||
|
openYoutubeBrowserWindow: () => {
|
||||||
|
calls.push('openYoutubeBrowserWindow');
|
||||||
|
},
|
||||||
openFirstRunSetup: (force?: boolean) => {
|
openFirstRunSetup: (force?: boolean) => {
|
||||||
calls.push(`openFirstRunSetup:${force === true ? 'force' : 'default'}`);
|
calls.push(`openFirstRunSetup:${force === true ? 'force' : 'default'}`);
|
||||||
},
|
},
|
||||||
@@ -243,7 +246,7 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
|
|||||||
calls.push('runJellyfinCommand');
|
calls.push('runJellyfinCommand');
|
||||||
},
|
},
|
||||||
runYoutubePlaybackFlow: async (request) => {
|
runYoutubePlaybackFlow: async (request) => {
|
||||||
calls.push(`runYoutubePlaybackFlow:${request.url}:${request.mode}:${request.source}`);
|
calls.push(`runYoutubePlaybackFlow:${request.url}:${request.source}`);
|
||||||
},
|
},
|
||||||
runUpdateCommand: async (args) => {
|
runUpdateCommand: async (args) => {
|
||||||
calls.push(`runUpdateCommand:${args.updateLauncherPath ?? ''}`);
|
calls.push(`runUpdateCommand:${args.updateLauncherPath ?? ''}`);
|
||||||
@@ -283,46 +286,30 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
|
|||||||
test('handleCliCommand starts youtube playback flow on initial launch', () => {
|
test('handleCliCommand starts youtube playback flow on initial launch', () => {
|
||||||
const { deps, calls } = createDeps({
|
const { deps, calls } = createDeps({
|
||||||
runYoutubePlaybackFlow: async (request) => {
|
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', 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}`);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
handleCliCommand(makeArgs({ youtubePlay: 'https://youtube.com/watch?v=abc' }), 'initial', deps);
|
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', () => {
|
test('handleCliCommand reuses initialized overlay runtime for second-instance youtube playback', () => {
|
||||||
const { deps, calls } = createDeps({
|
const { deps, calls } = createDeps({
|
||||||
isOverlayRuntimeInitialized: () => true,
|
isOverlayRuntimeInitialized: () => true,
|
||||||
runYoutubePlaybackFlow: async (request) => {
|
runYoutubePlaybackFlow: async (request) => {
|
||||||
calls.push(`youtube:${request.url}:${request.mode}:${request.source}`);
|
calls.push(`youtube:${request.url}:${request.source}`);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
handleCliCommand(
|
handleCliCommand(
|
||||||
makeArgs({ youtubePlay: 'https://youtube.com/watch?v=abc', youtubeMode: 'download' }),
|
makeArgs({ youtubePlay: 'https://youtube.com/watch?v=abc' }),
|
||||||
'second-instance',
|
'second-instance',
|
||||||
deps,
|
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 () => {
|
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(
|
handleCliCommand(makeArgs({ youtubePlay: 'https://youtube.com/watch?v=abc' }), 'initial', deps);
|
||||||
makeArgs({ youtubePlay: 'https://youtube.com/watch?v=abc', youtubeMode: 'download' }),
|
|
||||||
'initial',
|
|
||||||
deps,
|
|
||||||
);
|
|
||||||
await new Promise((resolve) => setImmediate(resolve));
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
|
||||||
assert.ok(calls.some((value) => value.startsWith('error:runYoutubePlaybackFlow failed:')));
|
assert.ok(calls.some((value) => value.startsWith('error:runYoutubePlaybackFlow failed:')));
|
||||||
@@ -676,6 +659,7 @@ test('createCliCommandDepsRuntime reconnects MPV client when reconnect hook exis
|
|||||||
openYomitanSettings: () => {},
|
openYomitanSettings: () => {},
|
||||||
openConfigSettingsWindow: () => {},
|
openConfigSettingsWindow: () => {},
|
||||||
openSyncUiWindow: () => {},
|
openSyncUiWindow: () => {},
|
||||||
|
openYoutubeBrowserWindow: () => {},
|
||||||
cycleSecondarySubMode: () => {},
|
cycleSecondarySubMode: () => {},
|
||||||
openRuntimeOptionsPalette: () => {},
|
openRuntimeOptionsPalette: () => {},
|
||||||
printHelp: () => {},
|
printHelp: () => {},
|
||||||
@@ -789,6 +773,7 @@ test('handleCliCommand handles visibility and utility command dispatches', () =>
|
|||||||
}> = [
|
}> = [
|
||||||
{ args: { yomitan: true }, expected: 'openYomitanSettingsDelayed:1000' },
|
{ args: { yomitan: true }, expected: 'openYomitanSettingsDelayed:1000' },
|
||||||
{ args: { settings: true }, expected: 'openConfigSettingsWindow' },
|
{ args: { settings: true }, expected: 'openConfigSettingsWindow' },
|
||||||
|
{ args: { youtubeBrowser: true }, expected: 'openYoutubeBrowserWindow' },
|
||||||
{
|
{
|
||||||
args: { showVisibleOverlay: true },
|
args: { showVisibleOverlay: true },
|
||||||
expected: 'setVisibleOverlayVisible:true',
|
expected: 'setVisibleOverlayVisible:true',
|
||||||
@@ -822,9 +807,10 @@ test('handleCliCommand handles visibility and utility command dispatches', () =>
|
|||||||
for (const entry of cases) {
|
for (const entry of cases) {
|
||||||
const { deps, calls } = createDeps();
|
const { deps, calls } = createDeps();
|
||||||
handleCliCommand(makeArgs(entry.args), 'initial', deps);
|
handleCliCommand(makeArgs(entry.args), 'initial', deps);
|
||||||
assert.ok(
|
assert.equal(
|
||||||
calls.includes(entry.expected),
|
calls.filter((value) => value === entry.expected).length,
|
||||||
`expected call missing for args ${JSON.stringify(entry.args)}: ${entry.expected}`,
|
1,
|
||||||
|
`expected exactly one call for args ${JSON.stringify(entry.args)}: ${entry.expected}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ export interface CliCommandServiceDeps {
|
|||||||
openYomitanSettingsDelayed: (delayMs: number) => void;
|
openYomitanSettingsDelayed: (delayMs: number) => void;
|
||||||
openConfigSettingsWindow: () => void;
|
openConfigSettingsWindow: () => void;
|
||||||
openSyncUiWindow: () => void;
|
openSyncUiWindow: () => void;
|
||||||
|
openYoutubeBrowserWindow: () => void;
|
||||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
setVisibleOverlayVisible: (visible: boolean) => void;
|
||||||
copyCurrentSubtitle: () => void;
|
copyCurrentSubtitle: () => void;
|
||||||
startPendingMultiCopy: (timeoutMs: number) => void;
|
startPendingMultiCopy: (timeoutMs: number) => void;
|
||||||
@@ -102,11 +103,7 @@ export interface CliCommandServiceDeps {
|
|||||||
args: CliArgs,
|
args: CliArgs,
|
||||||
source: CliCommandSource,
|
source: CliCommandSource,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
runYoutubePlaybackFlow: (request: {
|
runYoutubePlaybackFlow: (request: { url: string; source: CliCommandSource }) => Promise<void>;
|
||||||
url: string;
|
|
||||||
mode: NonNullable<CliArgs['youtubeMode']>;
|
|
||||||
source: CliCommandSource;
|
|
||||||
}) => Promise<void>;
|
|
||||||
ensureBackgroundStatsServer?: () => Promise<void> | void;
|
ensureBackgroundStatsServer?: () => Promise<void> | void;
|
||||||
printHelp: () => void;
|
printHelp: () => void;
|
||||||
hasMainWindow: () => boolean;
|
hasMainWindow: () => boolean;
|
||||||
@@ -172,6 +169,7 @@ interface UiCliRuntime {
|
|||||||
openYomitanSettings: () => void;
|
openYomitanSettings: () => void;
|
||||||
openConfigSettingsWindow: () => void;
|
openConfigSettingsWindow: () => void;
|
||||||
openSyncUiWindow: () => void;
|
openSyncUiWindow: () => void;
|
||||||
|
openYoutubeBrowserWindow: () => void;
|
||||||
cycleSecondarySubMode: () => void;
|
cycleSecondarySubMode: () => void;
|
||||||
openRuntimeOptionsPalette: () => void;
|
openRuntimeOptionsPalette: () => void;
|
||||||
printHelp: () => void;
|
printHelp: () => void;
|
||||||
@@ -277,6 +275,7 @@ export function createCliCommandDepsRuntime(
|
|||||||
},
|
},
|
||||||
openConfigSettingsWindow: options.ui.openConfigSettingsWindow,
|
openConfigSettingsWindow: options.ui.openConfigSettingsWindow,
|
||||||
openSyncUiWindow: options.ui.openSyncUiWindow,
|
openSyncUiWindow: options.ui.openSyncUiWindow,
|
||||||
|
openYoutubeBrowserWindow: options.ui.openYoutubeBrowserWindow,
|
||||||
setVisibleOverlayVisible: options.overlay.setVisible,
|
setVisibleOverlayVisible: options.overlay.setVisible,
|
||||||
copyCurrentSubtitle: options.mining.copyCurrentSubtitle,
|
copyCurrentSubtitle: options.mining.copyCurrentSubtitle,
|
||||||
startPendingMultiCopy: options.mining.startPendingMultiCopy,
|
startPendingMultiCopy: options.mining.startPendingMultiCopy,
|
||||||
@@ -429,6 +428,8 @@ export function handleCliCommand(
|
|||||||
deps.openConfigSettingsWindow();
|
deps.openConfigSettingsWindow();
|
||||||
} else if (args.syncWindow) {
|
} else if (args.syncWindow) {
|
||||||
deps.openSyncUiWindow();
|
deps.openSyncUiWindow();
|
||||||
|
} else if (args.youtubeBrowser) {
|
||||||
|
deps.openYoutubeBrowserWindow();
|
||||||
} else if (args.show || args.showVisibleOverlay) {
|
} else if (args.show || args.showVisibleOverlay) {
|
||||||
deps.setVisibleOverlayVisible(true);
|
deps.setVisibleOverlayVisible(true);
|
||||||
} else if (args.hide || args.hideVisibleOverlay) {
|
} else if (args.hide || args.hideVisibleOverlay) {
|
||||||
@@ -639,7 +640,6 @@ export function handleCliCommand(
|
|||||||
() =>
|
() =>
|
||||||
deps.runYoutubePlaybackFlow({
|
deps.runYoutubePlaybackFlow({
|
||||||
url: youtubeUrl,
|
url: youtubeUrl,
|
||||||
mode: args.youtubeMode ?? 'download',
|
|
||||||
source,
|
source,
|
||||||
}),
|
}),
|
||||||
deps,
|
deps,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
|||||||
yomitan: false,
|
yomitan: false,
|
||||||
settings: false,
|
settings: false,
|
||||||
syncWindow: false,
|
syncWindow: false,
|
||||||
|
youtubeBrowser: false,
|
||||||
setup: false,
|
setup: false,
|
||||||
show: false,
|
show: false,
|
||||||
hide: false,
|
hide: false,
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ export function runSubtitleGenerationProcess(input: {
|
|||||||
args: string[];
|
args: string[];
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
onLine?: (line: string) => void;
|
onLine?: (line: string) => void;
|
||||||
|
/** Shown when the executable is missing; defaults to pointing at subtitleGeneration settings. */
|
||||||
|
missingMessage?: string;
|
||||||
}): Promise<string> {
|
}): Promise<string> {
|
||||||
input.signal?.throwIfAborted();
|
input.signal?.throwIfAborted();
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@@ -51,7 +53,8 @@ export function runSubtitleGenerationProcess(input: {
|
|||||||
reject(
|
reject(
|
||||||
new Error(
|
new Error(
|
||||||
'code' in error && error.code === 'ENOENT'
|
'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}`,
|
: `Could not run ${input.command}: ${error.message}`,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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: {
|
export async function generateJapaneseSubtitles(input: {
|
||||||
config: SubtitleGenerationConfig;
|
config: SubtitleGenerationConfig;
|
||||||
modelDirectory: string;
|
modelDirectory: string;
|
||||||
@@ -180,6 +195,8 @@ export async function generateJapaneseSubtitles(input: {
|
|||||||
audioStreamIndex?: number;
|
audioStreamIndex?: number;
|
||||||
references?: readonly SubtitleGenerationReference[];
|
references?: readonly SubtitleGenerationReference[];
|
||||||
outputPath?: string;
|
outputPath?: string;
|
||||||
|
/** Parent for the scratch directory holding the extracted WAV; the system temp dir by default. */
|
||||||
|
workDirectory?: string;
|
||||||
onProgress?: (progress: SubtitleGenerationProgress) => void;
|
onProgress?: (progress: SubtitleGenerationProgress) => void;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
}): Promise<string> {
|
}): Promise<string> {
|
||||||
@@ -193,13 +210,10 @@ export async function generateJapaneseSubtitles(input: {
|
|||||||
await ensureWritableDirectory(
|
await ensureWritableDirectory(
|
||||||
input.outputPath ? path.dirname(path.resolve(input.outputPath)) : path.dirname(mediaPath),
|
input.outputPath ? path.dirname(path.resolve(input.outputPath)) : path.dirname(mediaPath),
|
||||||
);
|
);
|
||||||
const model = await resolveSubtitleGenerationModel(input.config, input.modelDirectory);
|
const { modelPath, tools } = await requireSubtitleGenerationSetup(
|
||||||
if (model.kind === 'missing')
|
input.config,
|
||||||
throw new Error(
|
input.modelDirectory,
|
||||||
'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));
|
|
||||||
input.onProgress?.({ stage: 'extract', message: 'Inspecting audio tracks...' });
|
input.onProgress?.({ stage: 'extract', message: 'Inspecting audio tracks...' });
|
||||||
const probe = await runSubtitleGenerationProcess({
|
const probe = await runSubtitleGenerationProcess({
|
||||||
command: tools.ffprobe,
|
command: tools.ffprobe,
|
||||||
@@ -215,7 +229,9 @@ export async function generateJapaneseSubtitles(input: {
|
|||||||
signal: input.signal,
|
signal: input.signal,
|
||||||
});
|
});
|
||||||
const audio = parseAudioProbe(probe, input.audioStreamIndex);
|
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 {
|
try {
|
||||||
const wavPath = path.join(temporaryDirectory, 'audio.wav');
|
const wavPath = path.join(temporaryDirectory, 'audio.wav');
|
||||||
const subtitleBase = path.join(temporaryDirectory, 'subtitles');
|
const subtitleBase = path.join(temporaryDirectory, 'subtitles');
|
||||||
@@ -277,7 +293,7 @@ export async function generateJapaneseSubtitles(input: {
|
|||||||
config: input.config,
|
config: input.config,
|
||||||
tools,
|
tools,
|
||||||
referenceStarts,
|
referenceStarts,
|
||||||
modelPath: model.path,
|
modelPath,
|
||||||
wavPath,
|
wavPath,
|
||||||
directory: temporaryDirectory,
|
directory: temporaryDirectory,
|
||||||
signal: input.signal,
|
signal: input.signal,
|
||||||
@@ -288,7 +304,7 @@ export async function generateJapaneseSubtitles(input: {
|
|||||||
command: tools.whisper,
|
command: tools.whisper,
|
||||||
args: [
|
args: [
|
||||||
'-m',
|
'-m',
|
||||||
model.path,
|
modelPath,
|
||||||
'-f',
|
'-f',
|
||||||
wavPath,
|
wavPath,
|
||||||
'-l',
|
'-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 () => {
|
test('downloadYoutubeSubtitleTrack sanitizes metadata source language in filenames', async () => {
|
||||||
await withTempDir(async (root) => {
|
await withTempDir(async (root) => {
|
||||||
await withStubFetch(
|
await withStubFetch(
|
||||||
|
|||||||
@@ -221,11 +221,16 @@ export async function downloadYoutubeSubtitleTrack(input: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (canDownloadSubtitleFromUrl(input.track)) {
|
if (canDownloadSubtitleFromUrl(input.track)) {
|
||||||
|
try {
|
||||||
return await downloadSubtitleFromUrl({
|
return await downloadSubtitleFromUrl({
|
||||||
outputDir: input.outputDir,
|
outputDir: input.outputDir,
|
||||||
prefix,
|
prefix,
|
||||||
track: input.track,
|
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 outputTemplate = path.join(input.outputDir, `${prefix}.%(ext)s`);
|
||||||
const args = [
|
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 () => {
|
test('probeYoutubeTracks honors SUBMINER_YTDLP_BIN when yt-dlp is not on PATH', async () => {
|
||||||
if (process.platform === 'win32') {
|
if (process.platform === 'win32') {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -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(
|
function choosePreferredFormat(
|
||||||
formats: YtDlpSubtitleEntry,
|
allFormats: YtDlpSubtitleEntry,
|
||||||
kind: YoutubeTrackKind,
|
kind: YoutubeTrackKind,
|
||||||
): { ext: string; url: string; title?: string } | null {
|
): { 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 =
|
const preferredOrder =
|
||||||
kind === 'auto'
|
kind === 'auto'
|
||||||
? ['srv3', 'srv2', 'srv1', 'vtt', 'srt', 'ttml', 'json3']
|
? ['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
@@ -1,7 +1,8 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import path from 'node:path';
|
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';
|
import { localDateKey } from './shared/log-files';
|
||||||
|
|
||||||
test('resolveDefaultLogFilePath uses APPDATA on windows', () => {
|
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/);
|
||||||
|
});
|
||||||
|
|||||||
@@ -32,6 +32,27 @@ const LEVEL_PRIORITY: Record<LogLevel, number> = {
|
|||||||
|
|
||||||
const DEFAULT_LOG_LEVEL: LogLevel = 'warn';
|
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 cliLogLevel: LogLevel | undefined;
|
||||||
let configLogLevel: LogLevel | undefined;
|
let configLogLevel: LogLevel | undefined;
|
||||||
let configLogRotation: LogRotation = DEFAULT_LOG_ROTATION;
|
let configLogRotation: LogRotation = DEFAULT_LOG_ROTATION;
|
||||||
|
|||||||
+110
-4
@@ -329,6 +329,7 @@ import {
|
|||||||
} from './core/services/youtube/generate';
|
} from './core/services/youtube/generate';
|
||||||
import { applyOverlayClickThrough } from './core/services/overlay-click-through';
|
import { applyOverlayClickThrough } from './core/services/overlay-click-through';
|
||||||
import { createYoutubeMediaCacheService } from './core/services/youtube/media-cache';
|
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 { resolveYoutubePlaybackUrl } from './core/services/youtube/playback-resolve';
|
||||||
import { probeYoutubeTracks } from './core/services/youtube/track-probe';
|
import { probeYoutubeTracks } from './core/services/youtube/track-probe';
|
||||||
import {
|
import {
|
||||||
@@ -484,6 +485,8 @@ import {
|
|||||||
} from './main/runtime/media-timing-review';
|
} from './main/runtime/media-timing-review';
|
||||||
import { openMediaTimingReviewModal } from './main/runtime/media-timing-review-open';
|
import { openMediaTimingReviewModal } from './main/runtime/media-timing-review-open';
|
||||||
import { openYoutubeTrackPicker } from './main/runtime/youtube-picker-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 { openRuntimeOptionsModal as openRuntimeOptionsModalRuntime } from './main/runtime/runtime-options-open';
|
||||||
import { openJimakuModal as openJimakuModalRuntime } from './main/runtime/jimaku-open';
|
import { openJimakuModal as openJimakuModalRuntime } from './main/runtime/jimaku-open';
|
||||||
import { openTsukihimeModal as openTsukihimeModalRuntime } from './main/runtime/tsukihime-open';
|
import { openTsukihimeModal as openTsukihimeModalRuntime } from './main/runtime/tsukihime-open';
|
||||||
@@ -665,6 +668,8 @@ const JELLYFIN_MPV_CONNECT_TIMEOUT_MS = 3000;
|
|||||||
const JELLYFIN_MPV_AUTO_LAUNCH_TIMEOUT_MS = 20000;
|
const JELLYFIN_MPV_AUTO_LAUNCH_TIMEOUT_MS = 20000;
|
||||||
const YOUTUBE_MPV_CONNECT_TIMEOUT_MS = 3000;
|
const YOUTUBE_MPV_CONNECT_TIMEOUT_MS = 3000;
|
||||||
const YOUTUBE_MPV_AUTO_LAUNCH_TIMEOUT_MS = 10000;
|
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_MPV_YTDL_FORMAT = 'bestvideo*+bestaudio/best';
|
||||||
const YOUTUBE_DIRECT_PLAYBACK_FORMAT = 'b';
|
const YOUTUBE_DIRECT_PLAYBACK_FORMAT = 'b';
|
||||||
const MPV_JELLYFIN_DEFAULT_ARGS = [
|
const MPV_JELLYFIN_DEFAULT_ARGS = [
|
||||||
@@ -1022,6 +1027,13 @@ process.on('SIGTERM', () => {
|
|||||||
requestAppQuit();
|
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({
|
const youtubeFlowRuntime = createYoutubeFlowRuntime({
|
||||||
probeYoutubeTracks: (url: string) => probeYoutubeTracks(url),
|
probeYoutubeTracks: (url: string) => probeYoutubeTracks(url),
|
||||||
acquireYoutubeSubtitleTrack: (input) => acquireYoutubeSubtitleTrack(input),
|
acquireYoutubeSubtitleTrack: (input) => acquireYoutubeSubtitleTrack(input),
|
||||||
@@ -1161,6 +1173,25 @@ const youtubeFlowRuntime = createYoutubeFlowRuntime({
|
|||||||
fs.rmSync(dir, { recursive: true, force: true });
|
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({
|
const prepareYoutubePlaybackInMpv = createPrepareYoutubePlaybackInMpvHandler({
|
||||||
requestPath: async () => {
|
requestPath: async () => {
|
||||||
@@ -1388,7 +1419,8 @@ const youtubePlaybackRuntime = createYoutubePlaybackRuntime({
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
waitForYoutubeMpvConnected: (timeoutMs) => waitForYoutubeMpvConnected(timeoutMs),
|
waitForYoutubeMpvConnected: (timeoutMs) => waitForYoutubeMpvConnected(timeoutMs),
|
||||||
prepareYoutubePlaybackInMpv: (request) => prepareYoutubePlaybackInMpv(request),
|
prepareYoutubePlaybackInMpv: (request) =>
|
||||||
|
prepareYoutubePlaybackInMpv({ ...request, timeoutMs: YOUTUBE_MPV_LOAD_TIMEOUT_MS }),
|
||||||
startYoutubeMediaCache: (url) => {
|
startYoutubeMediaCache: (url) => {
|
||||||
const mediaCacheConfig = configService.getConfig().youtube.mediaCache;
|
const mediaCacheConfig = configService.getConfig().youtube.mediaCache;
|
||||||
youtubeMediaCache.start(url, {
|
youtubeMediaCache.start(url, {
|
||||||
@@ -3146,6 +3178,7 @@ const {
|
|||||||
runJellyfinCommand,
|
runJellyfinCommand,
|
||||||
openJellyfinSetupWindow,
|
openJellyfinSetupWindow,
|
||||||
getJellyfinClientInfo,
|
getJellyfinClientInfo,
|
||||||
|
ensureMpvConnectedForPlayback,
|
||||||
} = composeJellyfinRuntimeHandlers({
|
} = composeJellyfinRuntimeHandlers({
|
||||||
getResolvedJellyfinConfigMainDeps: {
|
getResolvedJellyfinConfigMainDeps: {
|
||||||
getResolvedConfig: () => configService.getConfig(),
|
getResolvedConfig: () => configService.getConfig(),
|
||||||
@@ -3423,6 +3456,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({
|
const maybeFocusExistingFirstRunSetupWindow = createMaybeFocusExistingFirstRunSetupWindowHandler({
|
||||||
getSetupWindow: () => appState.firstRunSetupWindow,
|
getSetupWindow: () => appState.firstRunSetupWindow,
|
||||||
});
|
});
|
||||||
@@ -4055,7 +4134,12 @@ const {
|
|||||||
},
|
},
|
||||||
stopJellyfinRemoteSession: () => stopJellyfinRemoteSession(),
|
stopJellyfinRemoteSession: () => stopJellyfinRemoteSession(),
|
||||||
cleanupInternalSubtitleTrackCache: () => cachedInternalSubtitleTrackExtractor.clear(),
|
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(),
|
cleanupYoutubeMediaCache: () => youtubeMediaCache.cleanup(),
|
||||||
cleanupRemoteMediaWindows: () => getSharedRemoteMediaWindowCache().cleanup(),
|
cleanupRemoteMediaWindows: () => getSharedRemoteMediaWindowCache().cleanup(),
|
||||||
cleanupJellyfinSubtitleCache: () => cleanupJellyfinSubtitleCache(),
|
cleanupJellyfinSubtitleCache: () => cleanupJellyfinSubtitleCache(),
|
||||||
@@ -4658,6 +4742,9 @@ const {
|
|||||||
}
|
}
|
||||||
youtubePrimarySubtitleNotificationRuntime.handleMediaPathChange(path);
|
youtubePrimarySubtitleNotificationRuntime.handleMediaPathChange(path);
|
||||||
void youtubeMediaCachePlaybackRuntime.handleMediaPathChange(path);
|
void youtubeMediaCachePlaybackRuntime.handleMediaPathChange(path);
|
||||||
|
youtubeBrowserPlaybackRuntime.handleMediaPathChange(path);
|
||||||
|
youtubeFlowRuntime.handleMediaPathChange(path);
|
||||||
|
subtitleGenerationRuntime.handleMediaPathChange(path);
|
||||||
if (path) {
|
if (path) {
|
||||||
ensureImmersionTrackerStarted();
|
ensureImmersionTrackerStarted();
|
||||||
secondarySubtitleTrackController.scheduleRefresh();
|
secondarySubtitleTrackController.scheduleRefresh();
|
||||||
@@ -5008,6 +5095,13 @@ function createMpvClientRuntimeService(): MpvIpcClient {
|
|||||||
if (connected) {
|
if (connected) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
youtubeBrowserPlaybackRuntime.handleMpvDisconnected();
|
||||||
|
youtubeFlowRuntime.cancelWhisperGeneration();
|
||||||
|
subtitleGenerationRuntime.handleMediaPathChange(null);
|
||||||
|
if (quitWhenMpvClosesAfterYoutubeBrowser && !youtubeBrowserWindowRuntime.isOpen()) {
|
||||||
|
requestAppQuit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!youtubeFlowRuntime.hasActiveSession()) {
|
if (!youtubeFlowRuntime.hasActiveSession()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -6153,6 +6247,7 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({
|
|||||||
openYomitanSettings: () => openYomitanSettings(),
|
openYomitanSettings: () => openYomitanSettings(),
|
||||||
openConfigSettingsWindow: () => configSettingsRuntime.openWindow(),
|
openConfigSettingsWindow: () => configSettingsRuntime.openWindow(),
|
||||||
openSyncUiWindow: () => openSyncUiWindowHandler(),
|
openSyncUiWindow: () => openSyncUiWindowHandler(),
|
||||||
|
openYoutubeBrowserWindow: () => youtubeBrowserWindowRuntime.open(),
|
||||||
cycleSecondarySubMode: () => cycleSecondarySubMode(),
|
cycleSecondarySubMode: () => cycleSecondarySubMode(),
|
||||||
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
|
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
|
||||||
printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT),
|
printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT),
|
||||||
@@ -6416,6 +6511,7 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } =
|
|||||||
openYomitanSettings: () => openYomitanSettings(),
|
openYomitanSettings: () => openYomitanSettings(),
|
||||||
openConfigSettingsWindow: () => configSettingsRuntime.openWindow(),
|
openConfigSettingsWindow: () => configSettingsRuntime.openWindow(),
|
||||||
openSyncUiWindow: () => openSyncUiWindowHandler(),
|
openSyncUiWindow: () => openSyncUiWindowHandler(),
|
||||||
|
openYoutubeBrowserWindow: () => youtubeBrowserWindowRuntime.open(),
|
||||||
exportLogs: () => {
|
exportLogs: () => {
|
||||||
void exportLogsFromTray();
|
void exportLogsFromTray();
|
||||||
},
|
},
|
||||||
@@ -6684,8 +6780,18 @@ registerSubtitleSelectionIpc({
|
|||||||
});
|
});
|
||||||
const subtitleGenerationRuntime = createSubtitleGenerationRuntime({
|
const subtitleGenerationRuntime = createSubtitleGenerationRuntime({
|
||||||
getConfig: () => configService.getConfig().subtitleGeneration,
|
getConfig: () => configService.getConfig().subtitleGeneration,
|
||||||
getModelDirectory: () =>
|
getModelDirectory: getSubtitleGenerationModelDirectory,
|
||||||
path.join(path.dirname(configService.getConfigPath()), 'models', 'whisper'),
|
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,
|
getMpvClient: () => appState.mpvClient,
|
||||||
onProgress: (progress) => {
|
onProgress: (progress) => {
|
||||||
for (const window of [overlayManager.getMainWindow(), overlayManager.getModalWindow()]) {
|
for (const window of [overlayManager.getMainWindow(), overlayManager.getModalWindow()]) {
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export interface CliCommandRuntimeServiceContext {
|
|||||||
openYomitanSettings: () => void;
|
openYomitanSettings: () => void;
|
||||||
openConfigSettingsWindow: () => void;
|
openConfigSettingsWindow: () => void;
|
||||||
openSyncUiWindow: () => void;
|
openSyncUiWindow: () => void;
|
||||||
|
openYoutubeBrowserWindow: () => void;
|
||||||
cycleSecondarySubMode: () => void;
|
cycleSecondarySubMode: () => void;
|
||||||
openRuntimeOptionsPalette: () => void;
|
openRuntimeOptionsPalette: () => void;
|
||||||
printHelp: () => void;
|
printHelp: () => void;
|
||||||
@@ -137,6 +138,7 @@ function createCliCommandDepsFromContext(
|
|||||||
openYomitanSettings: context.openYomitanSettings,
|
openYomitanSettings: context.openYomitanSettings,
|
||||||
openConfigSettingsWindow: context.openConfigSettingsWindow,
|
openConfigSettingsWindow: context.openConfigSettingsWindow,
|
||||||
openSyncUiWindow: context.openSyncUiWindow,
|
openSyncUiWindow: context.openSyncUiWindow,
|
||||||
|
openYoutubeBrowserWindow: context.openYoutubeBrowserWindow,
|
||||||
cycleSecondarySubMode: context.cycleSecondarySubMode,
|
cycleSecondarySubMode: context.cycleSecondarySubMode,
|
||||||
openRuntimeOptionsPalette: context.openRuntimeOptionsPalette,
|
openRuntimeOptionsPalette: context.openRuntimeOptionsPalette,
|
||||||
printHelp: context.printHelp,
|
printHelp: context.printHelp,
|
||||||
|
|||||||
@@ -219,6 +219,7 @@ export interface CliCommandRuntimeServiceDepsParams {
|
|||||||
openYomitanSettings: CliCommandDepsRuntimeOptions['ui']['openYomitanSettings'];
|
openYomitanSettings: CliCommandDepsRuntimeOptions['ui']['openYomitanSettings'];
|
||||||
openConfigSettingsWindow: CliCommandDepsRuntimeOptions['ui']['openConfigSettingsWindow'];
|
openConfigSettingsWindow: CliCommandDepsRuntimeOptions['ui']['openConfigSettingsWindow'];
|
||||||
openSyncUiWindow: CliCommandDepsRuntimeOptions['ui']['openSyncUiWindow'];
|
openSyncUiWindow: CliCommandDepsRuntimeOptions['ui']['openSyncUiWindow'];
|
||||||
|
openYoutubeBrowserWindow: CliCommandDepsRuntimeOptions['ui']['openYoutubeBrowserWindow'];
|
||||||
cycleSecondarySubMode: CliCommandDepsRuntimeOptions['ui']['cycleSecondarySubMode'];
|
cycleSecondarySubMode: CliCommandDepsRuntimeOptions['ui']['cycleSecondarySubMode'];
|
||||||
openRuntimeOptionsPalette: CliCommandDepsRuntimeOptions['ui']['openRuntimeOptionsPalette'];
|
openRuntimeOptionsPalette: CliCommandDepsRuntimeOptions['ui']['openRuntimeOptionsPalette'];
|
||||||
printHelp: CliCommandDepsRuntimeOptions['ui']['printHelp'];
|
printHelp: CliCommandDepsRuntimeOptions['ui']['printHelp'];
|
||||||
@@ -432,6 +433,7 @@ export function createCliCommandRuntimeServiceDeps(
|
|||||||
openYomitanSettings: params.ui.openYomitanSettings,
|
openYomitanSettings: params.ui.openYomitanSettings,
|
||||||
openConfigSettingsWindow: params.ui.openConfigSettingsWindow,
|
openConfigSettingsWindow: params.ui.openConfigSettingsWindow,
|
||||||
openSyncUiWindow: params.ui.openSyncUiWindow,
|
openSyncUiWindow: params.ui.openSyncUiWindow,
|
||||||
|
openYoutubeBrowserWindow: params.ui.openYoutubeBrowserWindow,
|
||||||
cycleSecondarySubMode: params.ui.cycleSecondarySubMode,
|
cycleSecondarySubMode: params.ui.cycleSecondarySubMode,
|
||||||
openRuntimeOptionsPalette: params.ui.openRuntimeOptionsPalette,
|
openRuntimeOptionsPalette: params.ui.openRuntimeOptionsPalette,
|
||||||
printHelp: params.ui.printHelp,
|
printHelp: params.ui.printHelp,
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ test('build cli command context deps maps handlers and values', () => {
|
|||||||
openYomitanSettings: () => calls.push('yomitan'),
|
openYomitanSettings: () => calls.push('yomitan'),
|
||||||
openConfigSettingsWindow: () => calls.push('config-settings'),
|
openConfigSettingsWindow: () => calls.push('config-settings'),
|
||||||
openSyncUiWindow: () => {},
|
openSyncUiWindow: () => {},
|
||||||
|
openYoutubeBrowserWindow: () => {},
|
||||||
cycleSecondarySubMode: () => calls.push('cycle-secondary'),
|
cycleSecondarySubMode: () => calls.push('cycle-secondary'),
|
||||||
openRuntimeOptionsPalette: () => calls.push('runtime-options'),
|
openRuntimeOptionsPalette: () => calls.push('runtime-options'),
|
||||||
printHelp: () => calls.push('help'),
|
printHelp: () => calls.push('help'),
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
|
|||||||
openYomitanSettings: () => void;
|
openYomitanSettings: () => void;
|
||||||
openConfigSettingsWindow: () => void;
|
openConfigSettingsWindow: () => void;
|
||||||
openSyncUiWindow: () => void;
|
openSyncUiWindow: () => void;
|
||||||
|
openYoutubeBrowserWindow: () => void;
|
||||||
cycleSecondarySubMode: () => void;
|
cycleSecondarySubMode: () => void;
|
||||||
openRuntimeOptionsPalette: () => void;
|
openRuntimeOptionsPalette: () => void;
|
||||||
printHelp: () => void;
|
printHelp: () => void;
|
||||||
@@ -109,6 +110,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
|
|||||||
openYomitanSettings: deps.openYomitanSettings,
|
openYomitanSettings: deps.openYomitanSettings,
|
||||||
openConfigSettingsWindow: deps.openConfigSettingsWindow,
|
openConfigSettingsWindow: deps.openConfigSettingsWindow,
|
||||||
openSyncUiWindow: deps.openSyncUiWindow,
|
openSyncUiWindow: deps.openSyncUiWindow,
|
||||||
|
openYoutubeBrowserWindow: deps.openYoutubeBrowserWindow,
|
||||||
cycleSecondarySubMode: deps.cycleSecondarySubMode,
|
cycleSecondarySubMode: deps.cycleSecondarySubMode,
|
||||||
openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette,
|
openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette,
|
||||||
printHelp: deps.printHelp,
|
printHelp: deps.printHelp,
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ test('cli command context factory composes main deps and context handlers', () =
|
|||||||
openYomitanSettings: () => {},
|
openYomitanSettings: () => {},
|
||||||
openConfigSettingsWindow: () => {},
|
openConfigSettingsWindow: () => {},
|
||||||
openSyncUiWindow: () => {},
|
openSyncUiWindow: () => {},
|
||||||
|
openYoutubeBrowserWindow: () => {},
|
||||||
cycleSecondarySubMode: () => {},
|
cycleSecondarySubMode: () => {},
|
||||||
openRuntimeOptionsPalette: () => {},
|
openRuntimeOptionsPalette: () => {},
|
||||||
printHelp: () => {},
|
printHelp: () => {},
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ test('cli command context main deps builder maps state and callbacks', async ()
|
|||||||
openYomitanSettings: () => calls.push('open-yomitan'),
|
openYomitanSettings: () => calls.push('open-yomitan'),
|
||||||
openConfigSettingsWindow: () => calls.push('open-config-settings'),
|
openConfigSettingsWindow: () => calls.push('open-config-settings'),
|
||||||
openSyncUiWindow: () => {},
|
openSyncUiWindow: () => {},
|
||||||
|
openYoutubeBrowserWindow: () => {},
|
||||||
cycleSecondarySubMode: () => calls.push('cycle-secondary'),
|
cycleSecondarySubMode: () => calls.push('cycle-secondary'),
|
||||||
openRuntimeOptionsPalette: () => calls.push('open-runtime-options'),
|
openRuntimeOptionsPalette: () => calls.push('open-runtime-options'),
|
||||||
printHelp: () => calls.push('help'),
|
printHelp: () => calls.push('help'),
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
|
|||||||
openYomitanSettings: () => void;
|
openYomitanSettings: () => void;
|
||||||
openConfigSettingsWindow: () => void;
|
openConfigSettingsWindow: () => void;
|
||||||
openSyncUiWindow: () => void;
|
openSyncUiWindow: () => void;
|
||||||
|
openYoutubeBrowserWindow: () => void;
|
||||||
cycleSecondarySubMode: () => void;
|
cycleSecondarySubMode: () => void;
|
||||||
openRuntimeOptionsPalette: () => void;
|
openRuntimeOptionsPalette: () => void;
|
||||||
printHelp: () => void;
|
printHelp: () => void;
|
||||||
@@ -146,6 +147,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
|
|||||||
openYomitanSettings: () => deps.openYomitanSettings(),
|
openYomitanSettings: () => deps.openYomitanSettings(),
|
||||||
openConfigSettingsWindow: () => deps.openConfigSettingsWindow(),
|
openConfigSettingsWindow: () => deps.openConfigSettingsWindow(),
|
||||||
openSyncUiWindow: () => deps.openSyncUiWindow(),
|
openSyncUiWindow: () => deps.openSyncUiWindow(),
|
||||||
|
openYoutubeBrowserWindow: () => deps.openYoutubeBrowserWindow(),
|
||||||
cycleSecondarySubMode: () => deps.cycleSecondarySubMode(),
|
cycleSecondarySubMode: () => deps.cycleSecondarySubMode(),
|
||||||
openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(),
|
openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(),
|
||||||
printHelp: () => deps.printHelp(),
|
printHelp: () => deps.printHelp(),
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ function createDeps() {
|
|||||||
openYomitanSettings: () => {},
|
openYomitanSettings: () => {},
|
||||||
openConfigSettingsWindow: () => {},
|
openConfigSettingsWindow: () => {},
|
||||||
openSyncUiWindow: () => {},
|
openSyncUiWindow: () => {},
|
||||||
|
openYoutubeBrowserWindow: () => {},
|
||||||
cycleSecondarySubMode: () => {},
|
cycleSecondarySubMode: () => {},
|
||||||
openRuntimeOptionsPalette: () => {},
|
openRuntimeOptionsPalette: () => {},
|
||||||
printHelp: () => {},
|
printHelp: () => {},
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export type CliCommandContextFactoryDeps = {
|
|||||||
openYomitanSettings: () => void;
|
openYomitanSettings: () => void;
|
||||||
openConfigSettingsWindow: () => void;
|
openConfigSettingsWindow: () => void;
|
||||||
openSyncUiWindow: () => void;
|
openSyncUiWindow: () => void;
|
||||||
|
openYoutubeBrowserWindow: () => void;
|
||||||
cycleSecondarySubMode: () => void;
|
cycleSecondarySubMode: () => void;
|
||||||
openRuntimeOptionsPalette: () => void;
|
openRuntimeOptionsPalette: () => void;
|
||||||
printHelp: () => void;
|
printHelp: () => void;
|
||||||
@@ -136,6 +137,7 @@ export function createCliCommandContext(
|
|||||||
openYomitanSettings: deps.openYomitanSettings,
|
openYomitanSettings: deps.openYomitanSettings,
|
||||||
openConfigSettingsWindow: deps.openConfigSettingsWindow,
|
openConfigSettingsWindow: deps.openConfigSettingsWindow,
|
||||||
openSyncUiWindow: deps.openSyncUiWindow,
|
openSyncUiWindow: deps.openSyncUiWindow,
|
||||||
|
openYoutubeBrowserWindow: deps.openYoutubeBrowserWindow,
|
||||||
cycleSecondarySubMode: deps.cycleSecondarySubMode,
|
cycleSecondarySubMode: deps.cycleSecondarySubMode,
|
||||||
openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette,
|
openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette,
|
||||||
printHelp: deps.printHelp,
|
printHelp: deps.printHelp,
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ test('composeCliStartupHandlers returns callable CLI startup handlers', () => {
|
|||||||
openYomitanSettings: () => {},
|
openYomitanSettings: () => {},
|
||||||
openConfigSettingsWindow: () => {},
|
openConfigSettingsWindow: () => {},
|
||||||
openSyncUiWindow: () => {},
|
openSyncUiWindow: () => {},
|
||||||
|
openYoutubeBrowserWindow: () => {},
|
||||||
cycleSecondarySubMode: () => {},
|
cycleSecondarySubMode: () => {},
|
||||||
openRuntimeOptionsPalette: () => {},
|
openRuntimeOptionsPalette: () => {},
|
||||||
printHelp: () => {},
|
printHelp: () => {},
|
||||||
|
|||||||
@@ -146,6 +146,8 @@ export type JellyfinRuntimeComposerResult = ComposerOutputs<{
|
|||||||
typeof composeJellyfinRemoteHandlers
|
typeof composeJellyfinRemoteHandlers
|
||||||
>['handleJellyfinRemoteGeneralCommand'];
|
>['handleJellyfinRemoteGeneralCommand'];
|
||||||
playJellyfinItemInMpv: ReturnType<typeof createPlayJellyfinItemInMpvHandler>;
|
playJellyfinItemInMpv: ReturnType<typeof createPlayJellyfinItemInMpvHandler>;
|
||||||
|
/** Connects to mpv, launching an idle player when none is running. */
|
||||||
|
ensureMpvConnectedForPlayback: () => Promise<boolean>;
|
||||||
cleanupJellyfinSubtitleCache: () => void;
|
cleanupJellyfinSubtitleCache: () => void;
|
||||||
startJellyfinRemoteSession: ReturnType<typeof createStartJellyfinRemoteSessionHandler>;
|
startJellyfinRemoteSession: ReturnType<typeof createStartJellyfinRemoteSessionHandler>;
|
||||||
stopJellyfinRemoteSession: ReturnType<typeof createStopJellyfinRemoteSessionHandler>;
|
stopJellyfinRemoteSession: ReturnType<typeof createStopJellyfinRemoteSessionHandler>;
|
||||||
@@ -303,6 +305,7 @@ export function composeJellyfinRuntimeHandlers(
|
|||||||
handleJellyfinRemotePlaystate,
|
handleJellyfinRemotePlaystate,
|
||||||
handleJellyfinRemoteGeneralCommand,
|
handleJellyfinRemoteGeneralCommand,
|
||||||
playJellyfinItemInMpv,
|
playJellyfinItemInMpv,
|
||||||
|
ensureMpvConnectedForPlayback: () => ensureMpvConnectedForJellyfinPlayback(),
|
||||||
cleanupJellyfinSubtitleCache: () => preloadJellyfinExternalSubtitles.cleanupCachedSubtitles(),
|
cleanupJellyfinSubtitleCache: () => preloadJellyfinExternalSubtitles.cleanupCachedSubtitles(),
|
||||||
startJellyfinRemoteSession,
|
startJellyfinRemoteSession,
|
||||||
stopJellyfinRemoteSession,
|
stopJellyfinRemoteSession,
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
|||||||
yomitan: false,
|
yomitan: false,
|
||||||
settings: false,
|
settings: false,
|
||||||
syncWindow: false,
|
syncWindow: false,
|
||||||
|
youtubeBrowser: false,
|
||||||
setup: false,
|
setup: false,
|
||||||
show: false,
|
show: false,
|
||||||
hide: false,
|
hide: false,
|
||||||
|
|||||||
@@ -313,3 +313,133 @@ test('speech model downloads share the job lock and cancellation', async () => {
|
|||||||
runtime.cancel();
|
runtime.cancel();
|
||||||
assert.deepEqual(await download, { ok: false, message: 'Cancelled.' });
|
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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import type {
|
|||||||
SubtitleGenerationResult,
|
SubtitleGenerationResult,
|
||||||
SubtitleGenerationStatus,
|
SubtitleGenerationStatus,
|
||||||
} from '../../shared/subtitle-generation-ipc';
|
} from '../../shared/subtitle-generation-ipc';
|
||||||
|
import { extractYoutubeVideoId, toYoutubeWatchUrl } from './youtube-playback';
|
||||||
|
|
||||||
interface GenerationMpvClient {
|
interface GenerationMpvClient {
|
||||||
connected: boolean;
|
connected: boolean;
|
||||||
@@ -40,6 +41,16 @@ export interface SubtitleGenerationRuntimeDeps {
|
|||||||
detectAcceleration?: typeof detectSubtitleGenerationAcceleration;
|
detectAcceleration?: typeof detectSubtitleGenerationAcceleration;
|
||||||
downloadVad?: typeof downloadSubtitleGenerationVadModel;
|
downloadVad?: typeof downloadSubtitleGenerationVadModel;
|
||||||
resolveVadModel?: typeof resolveSubtitleGenerationVadModel;
|
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> {
|
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;
|
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 {
|
function selectedAudioIndex(tracks: unknown): number {
|
||||||
if (!Array.isArray(tracks)) throw new Error('Unable to inspect the selected audio track.');
|
if (!Array.isArray(tracks)) throw new Error('Unable to inspect the selected audio track.');
|
||||||
for (const track of tracks) {
|
for (const track of tracks) {
|
||||||
@@ -83,6 +113,8 @@ export function createSubtitleGenerationRuntime(deps: SubtitleGenerationRuntimeD
|
|||||||
let lastResult: SubtitleGenerationResult | null = null;
|
let lastResult: SubtitleGenerationResult | null = null;
|
||||||
let selectedModel: SubtitleGenerationModelId | null = null;
|
let selectedModel: SubtitleGenerationModelId | null = null;
|
||||||
let vadEnabled: boolean | 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:
|
let accelerationCheck:
|
||||||
| {
|
| {
|
||||||
path: string;
|
path: string;
|
||||||
@@ -155,7 +187,11 @@ export function createSubtitleGenerationRuntime(deps: SubtitleGenerationRuntimeD
|
|||||||
config,
|
config,
|
||||||
deps.getModelDirectory(),
|
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 {
|
return {
|
||||||
model,
|
model,
|
||||||
vad: {
|
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 {
|
return {
|
||||||
getStatus,
|
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> {
|
async setVadEnabled(enabled: boolean): Promise<SubtitleGenerationStatus> {
|
||||||
if (controller)
|
if (controller)
|
||||||
throw new Error('Wait for the current operation before changing speech detection.');
|
throw new Error('Wait for the current operation before changing speech detection.');
|
||||||
@@ -210,6 +280,39 @@ export function createSubtitleGenerationRuntime(deps: SubtitleGenerationRuntimeD
|
|||||||
cancel(): void {
|
cancel(): void {
|
||||||
controller?.abort();
|
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> {
|
download(): Promise<SubtitleGenerationResult> {
|
||||||
return run(async (signal) => {
|
return run(async (signal) => {
|
||||||
await (deps.download ?? downloadSubtitleGenerationModel)({
|
await (deps.download ?? downloadSubtitleGenerationModel)({
|
||||||
@@ -236,9 +339,12 @@ export function createSubtitleGenerationRuntime(deps: SubtitleGenerationRuntimeD
|
|||||||
if (vad.kind === 'invalid') throw new Error(vad.message);
|
if (vad.kind === 'invalid') throw new Error(vad.message);
|
||||||
}
|
}
|
||||||
const client = deps.getMpvClient();
|
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);
|
const mediaPath = await currentLocalMedia(client);
|
||||||
if (!client || !mediaPath)
|
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 tracks = await client.requestProperty('track-list');
|
||||||
const audioStreamIndex = selectedAudioIndex(tracks);
|
const audioStreamIndex = selectedAudioIndex(tracks);
|
||||||
const references = await readSubtitleGenerationReferences(tracks, (name) =>
|
const references = await readSubtitleGenerationReferences(tracks, (name) =>
|
||||||
@@ -260,16 +366,7 @@ export function createSubtitleGenerationRuntime(deps: SubtitleGenerationRuntimeD
|
|||||||
try {
|
try {
|
||||||
const playingMedia = await currentLocalMedia(client);
|
const playingMedia = await currentLocalMedia(client);
|
||||||
if (!signal.aborted && deps.getMpvClient() === client && playingMedia === mediaPath) {
|
if (!signal.aborted && deps.getMpvClient() === client && playingMedia === mediaPath) {
|
||||||
const loaded = await client.request([
|
await loadGeneratedSubtitles(client, outputPath);
|
||||||
'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);
|
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
outputPath,
|
outputPath,
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ test('build tray template handler wires actions and init guards', () => {
|
|||||||
handlers.openYomitanSettings();
|
handlers.openYomitanSettings();
|
||||||
handlers.openConfigSettings();
|
handlers.openConfigSettings();
|
||||||
handlers.openSyncUi();
|
handlers.openSyncUi();
|
||||||
|
handlers.openYoutubeBrowser();
|
||||||
handlers.exportLogs();
|
handlers.exportLogs();
|
||||||
handlers.openJellyfinSetup();
|
handlers.openJellyfinSetup();
|
||||||
handlers.toggleJellyfinDiscovery(true);
|
handlers.toggleJellyfinDiscovery(true);
|
||||||
@@ -74,6 +75,7 @@ test('build tray template handler wires actions and init guards', () => {
|
|||||||
openYomitanSettings: () => calls.push('yomitan'),
|
openYomitanSettings: () => calls.push('yomitan'),
|
||||||
openConfigSettingsWindow: () => calls.push('configuration'),
|
openConfigSettingsWindow: () => calls.push('configuration'),
|
||||||
openSyncUiWindow: () => calls.push('sync-ui'),
|
openSyncUiWindow: () => calls.push('sync-ui'),
|
||||||
|
openYoutubeBrowserWindow: () => calls.push('youtube'),
|
||||||
exportLogs: () => calls.push('export-logs'),
|
exportLogs: () => calls.push('export-logs'),
|
||||||
openJellyfinSetupWindow: () => calls.push('jellyfin'),
|
openJellyfinSetupWindow: () => calls.push('jellyfin'),
|
||||||
isJellyfinConfigured: () => true,
|
isJellyfinConfigured: () => true,
|
||||||
@@ -100,6 +102,7 @@ test('build tray template handler wires actions and init guards', () => {
|
|||||||
'yomitan',
|
'yomitan',
|
||||||
'configuration',
|
'configuration',
|
||||||
'sync-ui',
|
'sync-ui',
|
||||||
|
'youtube',
|
||||||
'export-logs',
|
'export-logs',
|
||||||
'jellyfin',
|
'jellyfin',
|
||||||
'jellyfin-discovery:true',
|
'jellyfin-discovery:true',
|
||||||
@@ -130,6 +133,7 @@ test('windows mpv launcher tray action force-opens completed setup', () => {
|
|||||||
openYomitanSettings: () => calls.push('yomitan'),
|
openYomitanSettings: () => calls.push('yomitan'),
|
||||||
openConfigSettingsWindow: () => calls.push('configuration'),
|
openConfigSettingsWindow: () => calls.push('configuration'),
|
||||||
openSyncUiWindow: () => calls.push('configuration'),
|
openSyncUiWindow: () => calls.push('configuration'),
|
||||||
|
openYoutubeBrowserWindow: () => calls.push('youtube'),
|
||||||
exportLogs: () => calls.push('export-logs'),
|
exportLogs: () => calls.push('export-logs'),
|
||||||
openJellyfinSetupWindow: () => calls.push('jellyfin'),
|
openJellyfinSetupWindow: () => calls.push('jellyfin'),
|
||||||
isJellyfinConfigured: () => false,
|
isJellyfinConfigured: () => false,
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
|
|||||||
openYomitanSettings: () => void;
|
openYomitanSettings: () => void;
|
||||||
openConfigSettings: () => void;
|
openConfigSettings: () => void;
|
||||||
openSyncUi: () => void;
|
openSyncUi: () => void;
|
||||||
|
openYoutubeBrowser: () => void;
|
||||||
exportLogs: () => void;
|
exportLogs: () => void;
|
||||||
openJellyfinSetup: () => void;
|
openJellyfinSetup: () => void;
|
||||||
showJellyfinDiscovery: boolean;
|
showJellyfinDiscovery: boolean;
|
||||||
@@ -70,6 +71,7 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
|
|||||||
openYomitanSettings: () => void;
|
openYomitanSettings: () => void;
|
||||||
openConfigSettingsWindow: () => void;
|
openConfigSettingsWindow: () => void;
|
||||||
openSyncUiWindow: () => void;
|
openSyncUiWindow: () => void;
|
||||||
|
openYoutubeBrowserWindow: () => void;
|
||||||
exportLogs: () => void;
|
exportLogs: () => void;
|
||||||
openJellyfinSetupWindow: () => void;
|
openJellyfinSetupWindow: () => void;
|
||||||
isJellyfinConfigured: () => boolean;
|
isJellyfinConfigured: () => boolean;
|
||||||
@@ -116,6 +118,9 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
|
|||||||
openSyncUi: () => {
|
openSyncUi: () => {
|
||||||
deps.openSyncUiWindow();
|
deps.openSyncUiWindow();
|
||||||
},
|
},
|
||||||
|
openYoutubeBrowser: () => {
|
||||||
|
deps.openYoutubeBrowserWindow();
|
||||||
|
},
|
||||||
exportLogs: () => {
|
exportLogs: () => {
|
||||||
deps.exportLogs();
|
deps.exportLogs();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ test('tray main deps builders return mapped handlers', () => {
|
|||||||
openYomitanSettings: () => calls.push('yomitan'),
|
openYomitanSettings: () => calls.push('yomitan'),
|
||||||
openConfigSettingsWindow: () => calls.push('configuration'),
|
openConfigSettingsWindow: () => calls.push('configuration'),
|
||||||
openSyncUiWindow: () => calls.push('sync-ui'),
|
openSyncUiWindow: () => calls.push('sync-ui'),
|
||||||
|
openYoutubeBrowserWindow: () => calls.push('youtube'),
|
||||||
exportLogs: () => calls.push('export-logs'),
|
exportLogs: () => calls.push('export-logs'),
|
||||||
openJellyfinSetupWindow: () => calls.push('jellyfin'),
|
openJellyfinSetupWindow: () => calls.push('jellyfin'),
|
||||||
isJellyfinConfigured: () => true,
|
isJellyfinConfigured: () => true,
|
||||||
@@ -61,6 +62,7 @@ test('tray main deps builders return mapped handlers', () => {
|
|||||||
openYomitanSettings: () => calls.push('open-yomitan'),
|
openYomitanSettings: () => calls.push('open-yomitan'),
|
||||||
openConfigSettings: () => calls.push('open-configuration'),
|
openConfigSettings: () => calls.push('open-configuration'),
|
||||||
openSyncUi: () => {},
|
openSyncUi: () => {},
|
||||||
|
openYoutubeBrowser: () => {},
|
||||||
exportLogs: () => calls.push('open-export-logs'),
|
exportLogs: () => calls.push('open-export-logs'),
|
||||||
openJellyfinSetup: () => calls.push('open-jellyfin'),
|
openJellyfinSetup: () => calls.push('open-jellyfin'),
|
||||||
showJellyfinDiscovery: true,
|
showJellyfinDiscovery: true,
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
|
|||||||
openYomitanSettings: () => void;
|
openYomitanSettings: () => void;
|
||||||
openConfigSettings: () => void;
|
openConfigSettings: () => void;
|
||||||
openSyncUi: () => void;
|
openSyncUi: () => void;
|
||||||
|
openYoutubeBrowser: () => void;
|
||||||
exportLogs: () => void;
|
exportLogs: () => void;
|
||||||
openJellyfinSetup: () => void;
|
openJellyfinSetup: () => void;
|
||||||
showJellyfinDiscovery: boolean;
|
showJellyfinDiscovery: boolean;
|
||||||
@@ -60,6 +61,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
|
|||||||
openYomitanSettings: () => void;
|
openYomitanSettings: () => void;
|
||||||
openConfigSettingsWindow: () => void;
|
openConfigSettingsWindow: () => void;
|
||||||
openSyncUiWindow: () => void;
|
openSyncUiWindow: () => void;
|
||||||
|
openYoutubeBrowserWindow: () => void;
|
||||||
exportLogs: () => void;
|
exportLogs: () => void;
|
||||||
openJellyfinSetupWindow: () => void;
|
openJellyfinSetupWindow: () => void;
|
||||||
isJellyfinConfigured: () => boolean;
|
isJellyfinConfigured: () => boolean;
|
||||||
@@ -85,6 +87,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
|
|||||||
openYomitanSettings: deps.openYomitanSettings,
|
openYomitanSettings: deps.openYomitanSettings,
|
||||||
openConfigSettingsWindow: deps.openConfigSettingsWindow,
|
openConfigSettingsWindow: deps.openConfigSettingsWindow,
|
||||||
openSyncUiWindow: deps.openSyncUiWindow,
|
openSyncUiWindow: deps.openSyncUiWindow,
|
||||||
|
openYoutubeBrowserWindow: deps.openYoutubeBrowserWindow,
|
||||||
exportLogs: deps.exportLogs,
|
exportLogs: deps.exportLogs,
|
||||||
openJellyfinSetupWindow: deps.openJellyfinSetupWindow,
|
openJellyfinSetupWindow: deps.openJellyfinSetupWindow,
|
||||||
isJellyfinConfigured: deps.isJellyfinConfigured,
|
isJellyfinConfigured: deps.isJellyfinConfigured,
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ test('tray runtime handlers compose resolve/menu/ensure/destroy handlers', () =>
|
|||||||
openYomitanSettings: () => {},
|
openYomitanSettings: () => {},
|
||||||
openConfigSettingsWindow: () => {},
|
openConfigSettingsWindow: () => {},
|
||||||
openSyncUiWindow: () => {},
|
openSyncUiWindow: () => {},
|
||||||
|
openYoutubeBrowserWindow: () => {},
|
||||||
exportLogs: () => {},
|
exportLogs: () => {},
|
||||||
openJellyfinSetupWindow: () => {},
|
openJellyfinSetupWindow: () => {},
|
||||||
isJellyfinConfigured: () => false,
|
isJellyfinConfigured: () => false,
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ test('tray menu template contains expected entries and handlers', () => {
|
|||||||
openYomitanSettings: () => calls.push('yomitan'),
|
openYomitanSettings: () => calls.push('yomitan'),
|
||||||
openConfigSettings: () => calls.push('configuration'),
|
openConfigSettings: () => calls.push('configuration'),
|
||||||
openSyncUi: () => calls.push('sync-ui'),
|
openSyncUi: () => calls.push('sync-ui'),
|
||||||
|
openYoutubeBrowser: () => calls.push('youtube'),
|
||||||
exportLogs: () => calls.push('export-logs'),
|
exportLogs: () => calls.push('export-logs'),
|
||||||
openJellyfinSetup: () => calls.push('jellyfin'),
|
openJellyfinSetup: () => calls.push('jellyfin'),
|
||||||
showJellyfinDiscovery: true,
|
showJellyfinDiscovery: true,
|
||||||
@@ -69,6 +70,7 @@ test('tray menu template contains expected entries and handlers', () => {
|
|||||||
'Open Yomitan Settings',
|
'Open Yomitan Settings',
|
||||||
'Open SubMiner Settings',
|
'Open SubMiner Settings',
|
||||||
'Sync Stats && History',
|
'Sync Stats && History',
|
||||||
|
'Browse YouTube',
|
||||||
'Export Logs',
|
'Export Logs',
|
||||||
'Configure Jellyfin',
|
'Configure Jellyfin',
|
||||||
'Jellyfin Discovery',
|
'Jellyfin Discovery',
|
||||||
@@ -88,6 +90,7 @@ test('tray menu template contains expected entries and handlers', () => {
|
|||||||
entryFor('View Changelog').click?.();
|
entryFor('View Changelog').click?.();
|
||||||
entryFor('Open Texthooker').click?.();
|
entryFor('Open Texthooker').click?.();
|
||||||
entryFor('Sync Stats && History').click?.();
|
entryFor('Sync Stats && History').click?.();
|
||||||
|
entryFor('Browse YouTube').click?.();
|
||||||
entryFor('Export Logs').click?.();
|
entryFor('Export Logs').click?.();
|
||||||
entryFor('Check for Updates').click?.();
|
entryFor('Check for Updates').click?.();
|
||||||
calls.push(template.some((entry) => entry.type === 'separator') ? 'separator' : 'bad');
|
calls.push(template.some((entry) => entry.type === 'separator') ? 'separator' : 'bad');
|
||||||
@@ -99,6 +102,7 @@ test('tray menu template contains expected entries and handlers', () => {
|
|||||||
'changelog',
|
'changelog',
|
||||||
'texthooker',
|
'texthooker',
|
||||||
'sync-ui',
|
'sync-ui',
|
||||||
|
'youtube',
|
||||||
'export-logs',
|
'export-logs',
|
||||||
'updates',
|
'updates',
|
||||||
'separator',
|
'separator',
|
||||||
@@ -119,6 +123,7 @@ test('tray menu template omits first-run setup entry when setup is complete', ()
|
|||||||
openYomitanSettings: () => undefined,
|
openYomitanSettings: () => undefined,
|
||||||
openConfigSettings: () => undefined,
|
openConfigSettings: () => undefined,
|
||||||
openSyncUi: () => undefined,
|
openSyncUi: () => undefined,
|
||||||
|
openYoutubeBrowser: () => undefined,
|
||||||
exportLogs: () => undefined,
|
exportLogs: () => undefined,
|
||||||
openJellyfinSetup: () => undefined,
|
openJellyfinSetup: () => undefined,
|
||||||
showJellyfinDiscovery: false,
|
showJellyfinDiscovery: false,
|
||||||
@@ -149,6 +154,7 @@ test('tray menu template omits texthooker entry when texthooker page is disabled
|
|||||||
openYomitanSettings: () => undefined,
|
openYomitanSettings: () => undefined,
|
||||||
openConfigSettings: () => undefined,
|
openConfigSettings: () => undefined,
|
||||||
openSyncUi: () => undefined,
|
openSyncUi: () => undefined,
|
||||||
|
openYoutubeBrowser: () => undefined,
|
||||||
exportLogs: () => undefined,
|
exportLogs: () => undefined,
|
||||||
openJellyfinSetup: () => undefined,
|
openJellyfinSetup: () => undefined,
|
||||||
showJellyfinDiscovery: false,
|
showJellyfinDiscovery: false,
|
||||||
@@ -177,6 +183,7 @@ test('tray menu template renders active jellyfin discovery checkbox', () => {
|
|||||||
openYomitanSettings: () => undefined,
|
openYomitanSettings: () => undefined,
|
||||||
openConfigSettings: () => undefined,
|
openConfigSettings: () => undefined,
|
||||||
openSyncUi: () => undefined,
|
openSyncUi: () => undefined,
|
||||||
|
openYoutubeBrowser: () => undefined,
|
||||||
exportLogs: () => undefined,
|
exportLogs: () => undefined,
|
||||||
openJellyfinSetup: () => undefined,
|
openJellyfinSetup: () => undefined,
|
||||||
showJellyfinDiscovery: true,
|
showJellyfinDiscovery: true,
|
||||||
@@ -206,6 +213,7 @@ test('tray menu template renders a visible linux discovery check mark when activ
|
|||||||
openYomitanSettings: () => undefined,
|
openYomitanSettings: () => undefined,
|
||||||
openConfigSettings: () => undefined,
|
openConfigSettings: () => undefined,
|
||||||
openSyncUi: () => undefined,
|
openSyncUi: () => undefined,
|
||||||
|
openYoutubeBrowser: () => undefined,
|
||||||
exportLogs: () => undefined,
|
exportLogs: () => undefined,
|
||||||
openJellyfinSetup: () => undefined,
|
openJellyfinSetup: () => undefined,
|
||||||
showJellyfinDiscovery: true,
|
showJellyfinDiscovery: true,
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export type TrayMenuActionHandlers = {
|
|||||||
openYomitanSettings: () => void;
|
openYomitanSettings: () => void;
|
||||||
openConfigSettings: () => void;
|
openConfigSettings: () => void;
|
||||||
openSyncUi: () => void;
|
openSyncUi: () => void;
|
||||||
|
openYoutubeBrowser: () => void;
|
||||||
exportLogs: () => void;
|
exportLogs: () => void;
|
||||||
openJellyfinSetup: () => void;
|
openJellyfinSetup: () => void;
|
||||||
showJellyfinDiscovery: boolean;
|
showJellyfinDiscovery: boolean;
|
||||||
@@ -113,6 +114,10 @@ export function buildTrayMenuTemplateRuntime(handlers: TrayMenuActionHandlers):
|
|||||||
label: 'Sync Stats && History',
|
label: 'Sync Stats && History',
|
||||||
click: handlers.openSyncUi,
|
click: handlers.openSyncUi,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'Browse YouTube',
|
||||||
|
click: handlers.openYoutubeBrowser,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: 'Export Logs',
|
label: 'Export Logs',
|
||||||
click: handlers.exportLogs,
|
click: handlers.exportLogs,
|
||||||
|
|||||||
@@ -0,0 +1,210 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import {
|
||||||
|
createYoutubeBrowserPlaybackRuntime,
|
||||||
|
parseYoutubeBrowserVideoRequest,
|
||||||
|
type YoutubeBrowserPlaybackDeps,
|
||||||
|
} from './youtube-browser-playback';
|
||||||
|
|
||||||
|
const VIDEO_A = 'https://www.youtube.com/watch?v=aaaaaaaaaaa';
|
||||||
|
const VIDEO_B = 'https://www.youtube.com/watch?v=bbbbbbbbbbb';
|
||||||
|
const VIDEO_C = 'https://www.youtube.com/watch?v=ccccccccccc';
|
||||||
|
|
||||||
|
function createHarness(overrides: Partial<YoutubeBrowserPlaybackDeps> = {}) {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const pendingFlows: Array<() => void> = [];
|
||||||
|
let mpvPlaying = false;
|
||||||
|
const runtime = createYoutubeBrowserPlaybackRuntime({
|
||||||
|
isMpvPlaying: () => mpvPlaying,
|
||||||
|
ensureMpvReady: async () => true,
|
||||||
|
runPlaybackFlow: (url) => {
|
||||||
|
calls.push(`flow:${url}`);
|
||||||
|
return new Promise<void>((resolve) => pendingFlows.push(resolve));
|
||||||
|
},
|
||||||
|
appendToMpvPlaylist: (url) => calls.push(`append:${url}`),
|
||||||
|
notifyFailure: (message) => calls.push(`notify:${message}`),
|
||||||
|
logWarn: () => {},
|
||||||
|
onStartupFailed: () => calls.push('startup-failed'),
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
runtime,
|
||||||
|
calls,
|
||||||
|
setMpvPlaying: (next: boolean) => {
|
||||||
|
mpvPlaying = next;
|
||||||
|
},
|
||||||
|
finishFlows: async () => {
|
||||||
|
for (const resolve of pendingFlows.splice(0)) resolve();
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('parseYoutubeBrowserVideoRequest accepts play and queue requests only', () => {
|
||||||
|
assert.deepEqual(parseYoutubeBrowserVideoRequest({ action: 'queue', url: VIDEO_A }), {
|
||||||
|
action: 'queue',
|
||||||
|
url: VIDEO_A,
|
||||||
|
});
|
||||||
|
assert.equal(parseYoutubeBrowserVideoRequest({ action: 'delete', url: VIDEO_A }), null);
|
||||||
|
assert.equal(parseYoutubeBrowserVideoRequest({ action: 'play' }), null);
|
||||||
|
assert.equal(parseYoutubeBrowserVideoRequest(null), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('play runs the playback flow with a single-video url', async () => {
|
||||||
|
const { runtime, calls } = createHarness();
|
||||||
|
const result = await runtime.openVideo({
|
||||||
|
action: 'play',
|
||||||
|
url: `${VIDEO_A}&list=WL&index=2`,
|
||||||
|
});
|
||||||
|
assert.deepEqual(result, { ok: true, message: 'Opening in mpv' });
|
||||||
|
assert.deepEqual(calls, [`flow:${VIDEO_A}`]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('queue appends to mpv while something is playing and plays otherwise', async () => {
|
||||||
|
const { runtime, calls, setMpvPlaying } = createHarness();
|
||||||
|
await runtime.openVideo({ action: 'queue', url: VIDEO_A });
|
||||||
|
runtime.handleMediaPathChange(VIDEO_A);
|
||||||
|
setMpvPlaying(true);
|
||||||
|
const queued = await runtime.openVideo({ action: 'queue', url: VIDEO_B });
|
||||||
|
assert.deepEqual(queued, { ok: true, message: 'Queued in mpv' });
|
||||||
|
assert.deepEqual(calls, [`flow:${VIDEO_A}`, `append:${VIDEO_B}`]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('queue requests while the first video starts append behind it in order', async () => {
|
||||||
|
const { runtime, calls } = createHarness();
|
||||||
|
await runtime.openVideo({ action: 'play', url: VIDEO_A });
|
||||||
|
assert.equal(runtime.isStartingPlayback(), true);
|
||||||
|
assert.deepEqual(await runtime.openVideo({ action: 'queue', url: VIDEO_B }), {
|
||||||
|
ok: true,
|
||||||
|
message: 'Queued in mpv',
|
||||||
|
});
|
||||||
|
await runtime.openVideo({ action: 'queue', url: VIDEO_C });
|
||||||
|
assert.deepEqual(calls, [`flow:${VIDEO_A}`]);
|
||||||
|
|
||||||
|
runtime.handleMediaPathChange(VIDEO_A);
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
assert.equal(runtime.isStartingPlayback(), false);
|
||||||
|
assert.deepEqual(calls, [`flow:${VIDEO_A}`, `append:${VIDEO_B}`, `append:${VIDEO_C}`]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('queue requests play instead when the first video fails to start', async () => {
|
||||||
|
let failFlow = true;
|
||||||
|
const calls: string[] = [];
|
||||||
|
const { runtime } = createHarness({
|
||||||
|
runPlaybackFlow: async (url) => {
|
||||||
|
calls.push(`flow:${url}`);
|
||||||
|
if (failFlow) {
|
||||||
|
failFlow = false;
|
||||||
|
throw new Error('no formats');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// The fallback play for the queued video must already count as starting.
|
||||||
|
onStartupFailed: () => calls.push(`startup-failed:${runtime.isStartingPlayback()}`),
|
||||||
|
});
|
||||||
|
await runtime.openVideo({ action: 'play', url: VIDEO_A });
|
||||||
|
await runtime.openVideo({ action: 'queue', url: VIDEO_B });
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
assert.deepEqual(calls, [`flow:${VIDEO_A}`, 'startup-failed:true', `flow:${VIDEO_B}`]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('repeat play clicks while mpv is starting open the video once', async () => {
|
||||||
|
let releaseMpv: (ready: boolean) => void = () => {};
|
||||||
|
const { runtime, calls } = createHarness({
|
||||||
|
ensureMpvReady: () =>
|
||||||
|
new Promise<boolean>((resolve) => {
|
||||||
|
releaseMpv = resolve;
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const first = runtime.openVideo({ action: 'play', url: VIDEO_A });
|
||||||
|
const second = await runtime.openVideo({ action: 'play', url: VIDEO_A });
|
||||||
|
releaseMpv(true);
|
||||||
|
await first;
|
||||||
|
assert.deepEqual(second, { ok: true, message: 'Already opening in mpv' });
|
||||||
|
assert.deepEqual(calls, [`flow:${VIDEO_A}`]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects non-video links and reports when mpv cannot start', async () => {
|
||||||
|
const { runtime, calls } = createHarness({ ensureMpvReady: async () => false });
|
||||||
|
assert.equal(
|
||||||
|
(await runtime.openVideo({ action: 'play', url: 'https://www.youtube.com/' })).ok,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.deepEqual(await runtime.openVideo({ action: 'play', url: VIDEO_A }), {
|
||||||
|
ok: false,
|
||||||
|
message: 'Could not start mpv.',
|
||||||
|
});
|
||||||
|
assert.deepEqual(calls, ['startup-failed']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('advancing onto a queued video runs the playback flow for it', async () => {
|
||||||
|
const { runtime, calls, setMpvPlaying, finishFlows } = createHarness();
|
||||||
|
await runtime.openVideo({ action: 'play', url: VIDEO_A });
|
||||||
|
runtime.handleMediaPathChange(VIDEO_A);
|
||||||
|
await finishFlows();
|
||||||
|
setMpvPlaying(true);
|
||||||
|
await runtime.openVideo({ action: 'queue', url: VIDEO_B });
|
||||||
|
|
||||||
|
runtime.handleMediaPathChange('');
|
||||||
|
runtime.handleMediaPathChange(VIDEO_B);
|
||||||
|
runtime.handleMediaPathChange(VIDEO_B);
|
||||||
|
|
||||||
|
assert.deepEqual(calls, [`flow:${VIDEO_A}`, `append:${VIDEO_B}`, `flow:${VIDEO_B}`]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('path changes are ignored while a flow is loading its own video', async () => {
|
||||||
|
const { runtime, calls } = createHarness();
|
||||||
|
await runtime.openVideo({ action: 'play', url: VIDEO_A });
|
||||||
|
await runtime.openVideo({ action: 'play', url: VIDEO_B });
|
||||||
|
runtime.handleMediaPathChange(VIDEO_A);
|
||||||
|
runtime.handleMediaPathChange(VIDEO_B);
|
||||||
|
assert.deepEqual(calls, [`flow:${VIDEO_A}`, `flow:${VIDEO_B}`]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a stuck flow does not block queued videos from getting theirs', async () => {
|
||||||
|
const { runtime, calls, setMpvPlaying } = createHarness();
|
||||||
|
await runtime.openVideo({ action: 'play', url: VIDEO_A });
|
||||||
|
runtime.handleMediaPathChange(VIDEO_A);
|
||||||
|
setMpvPlaying(true);
|
||||||
|
await runtime.openVideo({ action: 'queue', url: VIDEO_B });
|
||||||
|
runtime.handleMediaPathChange(VIDEO_B);
|
||||||
|
assert.deepEqual(calls, [`flow:${VIDEO_A}`, `append:${VIDEO_B}`, `flow:${VIDEO_B}`]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a queued video gets the flow only the first time mpv reaches it', async () => {
|
||||||
|
const { runtime, calls, setMpvPlaying, finishFlows } = createHarness();
|
||||||
|
setMpvPlaying(true);
|
||||||
|
await runtime.openVideo({ action: 'queue', url: VIDEO_B });
|
||||||
|
runtime.handleMediaPathChange(VIDEO_B);
|
||||||
|
await finishFlows();
|
||||||
|
// A later launcher-started play of the same video runs its own flow.
|
||||||
|
runtime.handleMediaPathChange(VIDEO_A);
|
||||||
|
runtime.handleMediaPathChange(VIDEO_B);
|
||||||
|
assert.deepEqual(calls, [`append:${VIDEO_B}`, `flow:${VIDEO_B}`]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('videos not sent from the browser never trigger the flow', async () => {
|
||||||
|
const { runtime, calls } = createHarness();
|
||||||
|
runtime.handleMediaPathChange(VIDEO_A);
|
||||||
|
assert.deepEqual(calls, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mpv disconnect forgets queued videos', async () => {
|
||||||
|
const { runtime, calls, setMpvPlaying, finishFlows } = createHarness();
|
||||||
|
setMpvPlaying(true);
|
||||||
|
await runtime.openVideo({ action: 'queue', url: VIDEO_B });
|
||||||
|
runtime.handleMpvDisconnected();
|
||||||
|
runtime.handleMediaPathChange(VIDEO_B);
|
||||||
|
await finishFlows();
|
||||||
|
assert.deepEqual(calls, [`append:${VIDEO_B}`]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('flow failures are reported', async () => {
|
||||||
|
const { runtime, calls } = createHarness({
|
||||||
|
runPlaybackFlow: async () => {
|
||||||
|
throw new Error('mpv went away');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await runtime.openVideo({ action: 'play', url: VIDEO_A });
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
assert.deepEqual(calls, ['notify:YouTube playback failed: mpv went away', 'startup-failed']);
|
||||||
|
});
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import { extractYoutubeVideoId, toYoutubeWatchUrl } from './youtube-playback';
|
||||||
|
|
||||||
|
export type YoutubeBrowserVideoAction = 'play' | 'queue';
|
||||||
|
|
||||||
|
export type YoutubeBrowserVideoRequest = {
|
||||||
|
action: YoutubeBrowserVideoAction;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type YoutubeBrowserVideoResult = {
|
||||||
|
ok: boolean;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type YoutubeBrowserPlaybackDeps = {
|
||||||
|
/** True when mpv is connected and has a file loaded, so appending queues behind it. */
|
||||||
|
isMpvPlaying: () => boolean;
|
||||||
|
/** Connect to (or launch) mpv so the playback flow has a player to drive. */
|
||||||
|
ensureMpvReady: () => Promise<boolean>;
|
||||||
|
/** SubMiner's YouTube playback flow: load the video, pick subtitles, release the play gate. */
|
||||||
|
runPlaybackFlow: (url: string) => Promise<void>;
|
||||||
|
appendToMpvPlaylist: (url: string) => void;
|
||||||
|
notifyFailure: (message: string) => void;
|
||||||
|
logWarn: (message: string) => void;
|
||||||
|
/** A play request ended without loading its video, after queued requests behind it were released. */
|
||||||
|
onStartupFailed: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function parseYoutubeBrowserVideoRequest(
|
||||||
|
payload: unknown,
|
||||||
|
): YoutubeBrowserVideoRequest | null {
|
||||||
|
if (typeof payload !== 'object' || payload === null) return null;
|
||||||
|
const { action, url } = payload as Record<string, unknown>;
|
||||||
|
if ((action !== 'play' && action !== 'queue') || typeof url !== 'string') return null;
|
||||||
|
return { action, url };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hands videos picked in the YouTube browser window to mpv. Queued videos land in mpv's own
|
||||||
|
* playlist; when mpv advances onto one, `handleMediaPathChange` runs the playback flow for it so
|
||||||
|
* queued videos get the same subtitle setup as videos played directly.
|
||||||
|
*/
|
||||||
|
type PlaybackStartup = {
|
||||||
|
videoId: string;
|
||||||
|
/** Resolves true once mpv has the video loaded, false if mpv or the flow failed first. */
|
||||||
|
loaded: Promise<boolean>;
|
||||||
|
settle: (loaded: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createYoutubeBrowserPlaybackRuntime(deps: YoutubeBrowserPlaybackDeps) {
|
||||||
|
// Videos queued from the browser that mpv has not reached yet. Only these get the flow on
|
||||||
|
// playlist advance; launcher-started YouTube playback already runs its own flow.
|
||||||
|
const queuedVideoIds = new Set<string>();
|
||||||
|
// Videos whose flow is still loading; the flow issues its own loadfile, so the path change
|
||||||
|
// that follows must not start a second flow for the same video.
|
||||||
|
const loadingVideoIds = new Set<string>();
|
||||||
|
// The latest play request while mpv starts and loads it. Queue requests arriving meanwhile wait
|
||||||
|
// on it so they append behind that video instead of starting a second flow that replaces it.
|
||||||
|
let startup: PlaybackStartup | null = null;
|
||||||
|
let lastSeenVideoId: string | null = null;
|
||||||
|
|
||||||
|
const beginStartup = (videoId: string): PlaybackStartup => {
|
||||||
|
let resolveLoaded: (loaded: boolean) => void = () => {};
|
||||||
|
let settled = false;
|
||||||
|
const entry: PlaybackStartup = {
|
||||||
|
videoId,
|
||||||
|
loaded: new Promise<boolean>((resolve) => {
|
||||||
|
resolveLoaded = resolve;
|
||||||
|
}),
|
||||||
|
settle: (loaded) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
if (startup === entry) startup = null;
|
||||||
|
resolveLoaded(loaded);
|
||||||
|
// Registered after the queued requests' handlers, so one that plays instead has already
|
||||||
|
// begun its own startup by the time this runs.
|
||||||
|
if (!loaded) void entry.loaded.then(() => deps.onStartupFailed());
|
||||||
|
},
|
||||||
|
};
|
||||||
|
startup = entry;
|
||||||
|
return entry;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Runs the playback flow; resolves false (after reporting) when it fails. */
|
||||||
|
const startFlow = async (url: string, videoId: string): Promise<boolean> => {
|
||||||
|
loadingVideoIds.add(videoId);
|
||||||
|
try {
|
||||||
|
await deps.runPlaybackFlow(url);
|
||||||
|
return true;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const reason = error instanceof Error ? error.message : String(error);
|
||||||
|
deps.logWarn(`YouTube browser playback failed for ${url}: ${reason}`);
|
||||||
|
deps.notifyFailure(`YouTube playback failed: ${reason}`);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
loadingVideoIds.delete(videoId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const appendToMpv = (url: string, videoId: string): YoutubeBrowserVideoResult => {
|
||||||
|
queuedVideoIds.add(videoId);
|
||||||
|
deps.appendToMpvPlaylist(url);
|
||||||
|
return { ok: true, message: 'Queued in mpv' };
|
||||||
|
};
|
||||||
|
|
||||||
|
const queueBehindStartup = (
|
||||||
|
pending: PlaybackStartup,
|
||||||
|
request: YoutubeBrowserVideoRequest,
|
||||||
|
url: string,
|
||||||
|
videoId: string,
|
||||||
|
): void => {
|
||||||
|
// Chained in click order. If the earlier video never loaded, this one plays instead.
|
||||||
|
void pending.loaded
|
||||||
|
.then((loaded) => (loaded ? appendToMpv(url, videoId) : openVideo(request)))
|
||||||
|
.then(
|
||||||
|
(result) => {
|
||||||
|
if (!result.ok) deps.notifyFailure(`YouTube queue failed: ${result.message}`);
|
||||||
|
},
|
||||||
|
(error: unknown) => {
|
||||||
|
const reason = error instanceof Error ? error.message : String(error);
|
||||||
|
deps.logWarn(`YouTube browser queue failed for ${url}: ${reason}`);
|
||||||
|
deps.notifyFailure(`YouTube queue failed: ${reason}`);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openVideo = async (
|
||||||
|
request: YoutubeBrowserVideoRequest,
|
||||||
|
): Promise<YoutubeBrowserVideoResult> => {
|
||||||
|
const url = toYoutubeWatchUrl(request.url);
|
||||||
|
const videoId = extractYoutubeVideoId(url);
|
||||||
|
if (!url || !videoId) {
|
||||||
|
return { ok: false, message: 'Not a YouTube video link.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.action === 'queue') {
|
||||||
|
if (startup) {
|
||||||
|
queueBehindStartup(startup, request, url, videoId);
|
||||||
|
return { ok: true, message: 'Queued in mpv' };
|
||||||
|
}
|
||||||
|
if (deps.isMpvPlaying()) return appendToMpv(url, videoId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Starting mpv can take seconds; repeat clicks during that wait must not start a second flow.
|
||||||
|
if (loadingVideoIds.has(videoId)) {
|
||||||
|
return { ok: true, message: 'Already opening in mpv' };
|
||||||
|
}
|
||||||
|
loadingVideoIds.add(videoId);
|
||||||
|
const entry = beginStartup(videoId);
|
||||||
|
let mpvReady = false;
|
||||||
|
try {
|
||||||
|
mpvReady = await deps.ensureMpvReady();
|
||||||
|
} finally {
|
||||||
|
if (!mpvReady) {
|
||||||
|
loadingVideoIds.delete(videoId);
|
||||||
|
entry.settle(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!mpvReady) {
|
||||||
|
return { ok: false, message: 'Could not start mpv.' };
|
||||||
|
}
|
||||||
|
void startFlow(url, videoId).then(entry.settle);
|
||||||
|
return { ok: true, message: 'Opening in mpv' };
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMediaPathChange = (mediaPath: string | null | undefined): void => {
|
||||||
|
const videoId = extractYoutubeVideoId(mediaPath);
|
||||||
|
const previousVideoId = lastSeenVideoId;
|
||||||
|
lastSeenVideoId = videoId;
|
||||||
|
if (videoId && startup?.videoId === videoId) startup.settle(true);
|
||||||
|
if (!videoId || videoId === previousVideoId || loadingVideoIds.has(videoId)) return;
|
||||||
|
if (!queuedVideoIds.delete(videoId)) return;
|
||||||
|
const url = toYoutubeWatchUrl(mediaPath);
|
||||||
|
if (url) void startFlow(url, videoId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMpvDisconnected = (): void => {
|
||||||
|
queuedVideoIds.clear();
|
||||||
|
lastSeenVideoId = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** True while a browser play request is still starting mpv or loading its video. */
|
||||||
|
const isStartingPlayback = (): boolean => startup !== null;
|
||||||
|
|
||||||
|
return { openVideo, handleMediaPathChange, handleMpvDisconnected, isStartingPlayback };
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { buildYoutubeBrowserUserAgent } from './youtube-browser-window';
|
||||||
|
|
||||||
|
test('buildYoutubeBrowserUserAgent strips Electron and app tokens from the user agent', () => {
|
||||||
|
assert.equal(
|
||||||
|
buildYoutubeBrowserUserAgent(
|
||||||
|
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) SubMiner/0.19.6 Chrome/140.0.7339.41 Electron/42.6.0 Safari/537.36',
|
||||||
|
),
|
||||||
|
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.7339.41 Safari/537.36',
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import type {
|
||||||
|
BrowserWindow,
|
||||||
|
BrowserWindowConstructorOptions,
|
||||||
|
IpcMain,
|
||||||
|
MenuItemConstructorOptions,
|
||||||
|
Session,
|
||||||
|
} from 'electron';
|
||||||
|
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||||
|
import {
|
||||||
|
parseYoutubeBrowserVideoRequest,
|
||||||
|
type YoutubeBrowserVideoRequest,
|
||||||
|
type YoutubeBrowserVideoResult,
|
||||||
|
} from './youtube-browser-playback';
|
||||||
|
import { toYoutubeWatchUrl } from './youtube-playback';
|
||||||
|
|
||||||
|
/** Persistent partition: cookies (and so the YouTube login) survive app restarts. */
|
||||||
|
export const YOUTUBE_BROWSER_PARTITION = 'persist:youtube-browser';
|
||||||
|
export const YOUTUBE_BROWSER_HOME_URL = 'https://www.youtube.com/';
|
||||||
|
|
||||||
|
const ALLOWED_PERMISSIONS = new Set(['fullscreen', 'clipboard-sanitized-write']);
|
||||||
|
const STANDARD_UA_PRODUCTS = '(?:Mozilla|AppleWebKit|Chrome|Safari)';
|
||||||
|
const NON_BROWSER_UA_TOKEN = new RegExp(
|
||||||
|
`\\s+(?!${STANDARD_UA_PRODUCTS}/)[A-Za-z][\\w.-]*/\\S+`,
|
||||||
|
'g',
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plain Chrome user agent. Google refuses sign-in from agents that advertise Electron or an
|
||||||
|
* embedding app, so every product token other than the standard Chrome ones is dropped.
|
||||||
|
*/
|
||||||
|
export function buildYoutubeBrowserUserAgent(defaultUserAgent: string): string {
|
||||||
|
return defaultUserAgent.replace(NON_BROWSER_UA_TOKEN, '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export type YoutubeBrowserWindowDeps = {
|
||||||
|
createBrowserWindow: (options: BrowserWindowConstructorOptions) => BrowserWindow;
|
||||||
|
preloadPath: string;
|
||||||
|
ipcMain: Pick<IpcMain, 'on'>;
|
||||||
|
showContextMenu: (window: BrowserWindow, template: MenuItemConstructorOptions[]) => void;
|
||||||
|
openExternal: (url: string) => void;
|
||||||
|
openVideo: (request: YoutubeBrowserVideoRequest) => Promise<YoutubeBrowserVideoResult>;
|
||||||
|
logWarn: (message: string, error?: unknown) => void;
|
||||||
|
logDebug: (message: string) => void;
|
||||||
|
onClosed?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function isHttpUrl(url: string): boolean {
|
||||||
|
return url.startsWith('https://') || url.startsWith('http://');
|
||||||
|
}
|
||||||
|
|
||||||
|
function configureSession(session: Session): void {
|
||||||
|
session.setUserAgent(buildYoutubeBrowserUserAgent(session.getUserAgent()));
|
||||||
|
session.setPermissionRequestHandler((_webContents, permission, callback) => {
|
||||||
|
callback(ALLOWED_PERMISSIONS.has(permission));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A logged-in YouTube window whose video links open in mpv instead of the page player.
|
||||||
|
* The preload intercepts link clicks; navigation hooks here catch anything that slips past it.
|
||||||
|
*/
|
||||||
|
export function createYoutubeBrowserWindowRuntime(deps: YoutubeBrowserWindowDeps) {
|
||||||
|
let browserWindow: BrowserWindow | null = null;
|
||||||
|
let ipcRegistered = false;
|
||||||
|
|
||||||
|
// `trigger` names the path that caught the video, to tell duplicate requests apart in logs.
|
||||||
|
const sendVideo = (request: YoutubeBrowserVideoRequest, trigger: string): void => {
|
||||||
|
deps.logDebug(`YouTube browser ${request.action} via ${trigger}: ${request.url}`);
|
||||||
|
void deps
|
||||||
|
.openVideo(request)
|
||||||
|
.catch((error: unknown): YoutubeBrowserVideoResult => {
|
||||||
|
deps.logWarn('YouTube browser failed to open video in mpv', error);
|
||||||
|
return { ok: false, message: 'Could not open the video in mpv.' };
|
||||||
|
})
|
||||||
|
.then((result) => {
|
||||||
|
if (browserWindow && !browserWindow.isDestroyed()) {
|
||||||
|
browserWindow.webContents.send(IPC_CHANNELS.event.youtubeBrowserToast, result);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const registerIpc = (): void => {
|
||||||
|
if (ipcRegistered) return;
|
||||||
|
ipcRegistered = true;
|
||||||
|
deps.ipcMain.on(IPC_CHANNELS.command.youtubeBrowserOpenVideo, (event, payload: unknown) => {
|
||||||
|
if (!browserWindow || event.sender !== browserWindow.webContents) return;
|
||||||
|
const request = parseYoutubeBrowserVideoRequest(payload);
|
||||||
|
if (request) sendVideo(request, 'click');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildContextMenu = (window: BrowserWindow, linkUrl: string) => {
|
||||||
|
const history = window.webContents.navigationHistory;
|
||||||
|
const videoItems: MenuItemConstructorOptions[] = toYoutubeWatchUrl(linkUrl)
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
label: 'Play in mpv',
|
||||||
|
click: () => sendVideo({ action: 'play', url: linkUrl }, 'context-menu'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Queue in mpv',
|
||||||
|
click: () => sendVideo({ action: 'queue', url: linkUrl }, 'context-menu'),
|
||||||
|
},
|
||||||
|
{ type: 'separator' },
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
return [
|
||||||
|
...videoItems,
|
||||||
|
{ label: 'Back', enabled: history.canGoBack(), click: () => history.goBack() },
|
||||||
|
{ label: 'Forward', enabled: history.canGoForward(), click: () => history.goForward() },
|
||||||
|
{ label: 'Reload', click: () => window.webContents.reload() },
|
||||||
|
] satisfies MenuItemConstructorOptions[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const createWindow = (): BrowserWindow => {
|
||||||
|
const window = deps.createBrowserWindow({
|
||||||
|
width: 1280,
|
||||||
|
height: 860,
|
||||||
|
title: 'SubMiner YouTube',
|
||||||
|
autoHideMenuBar: true,
|
||||||
|
backgroundColor: '#0f0f0f',
|
||||||
|
webPreferences: {
|
||||||
|
partition: YOUTUBE_BROWSER_PARTITION,
|
||||||
|
preload: deps.preloadPath,
|
||||||
|
nodeIntegration: false,
|
||||||
|
contextIsolation: true,
|
||||||
|
sandbox: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { webContents } = window;
|
||||||
|
const { session } = webContents;
|
||||||
|
configureSession(session);
|
||||||
|
|
||||||
|
webContents.setWindowOpenHandler(({ url }) => {
|
||||||
|
if (toYoutubeWatchUrl(url)) {
|
||||||
|
sendVideo({ action: 'queue', url }, 'window-open');
|
||||||
|
} else if (isHttpUrl(url)) {
|
||||||
|
deps.openExternal(url);
|
||||||
|
}
|
||||||
|
return { action: 'deny' };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Full page loads of a video (e.g. from a redirect) never reach the preload's click hook.
|
||||||
|
webContents.on('will-navigate', (event, url) => {
|
||||||
|
if (!toYoutubeWatchUrl(url)) return;
|
||||||
|
event.preventDefault();
|
||||||
|
sendVideo({ action: 'play', url }, 'will-navigate');
|
||||||
|
});
|
||||||
|
|
||||||
|
// In-app navigations the preload missed (keyboard shortcuts, script-driven links): stop the
|
||||||
|
// page player, hand the video to mpv, and return to the page the user came from.
|
||||||
|
webContents.on('did-navigate-in-page', (_event, url, isMainFrame) => {
|
||||||
|
if (!isMainFrame || !toYoutubeWatchUrl(url)) return;
|
||||||
|
void webContents
|
||||||
|
.executeJavaScript("document.querySelectorAll('video').forEach((v) => v.pause())")
|
||||||
|
.catch(() => {});
|
||||||
|
sendVideo({ action: 'play', url }, 'in-page-navigation');
|
||||||
|
if (webContents.navigationHistory.canGoBack()) {
|
||||||
|
webContents.navigationHistory.goBack();
|
||||||
|
} else {
|
||||||
|
void webContents.loadURL(YOUTUBE_BROWSER_HOME_URL).catch(() => {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
webContents.on('context-menu', (_event, params) => {
|
||||||
|
deps.showContextMenu(window, buildContextMenu(window, params.linkURL));
|
||||||
|
});
|
||||||
|
|
||||||
|
webContents.on('before-input-event', (event, input) => {
|
||||||
|
if (input.type !== 'keyDown') return;
|
||||||
|
const history = webContents.navigationHistory;
|
||||||
|
if (input.alt && input.key === 'ArrowLeft' && history.canGoBack()) {
|
||||||
|
event.preventDefault();
|
||||||
|
history.goBack();
|
||||||
|
} else if (input.alt && input.key === 'ArrowRight' && history.canGoForward()) {
|
||||||
|
event.preventDefault();
|
||||||
|
history.goForward();
|
||||||
|
} else if (input.key === 'F5' || ((input.control || input.meta) && input.key === 'r')) {
|
||||||
|
event.preventDefault();
|
||||||
|
webContents.reload();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.on('closed', () => {
|
||||||
|
browserWindow = null;
|
||||||
|
void session.cookies.flushStore().catch((error: unknown) => {
|
||||||
|
deps.logWarn('Failed to flush YouTube browser cookies', error);
|
||||||
|
});
|
||||||
|
deps.onClosed?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
void window.loadURL(YOUTUBE_BROWSER_HOME_URL).catch((error: unknown) => {
|
||||||
|
// YouTube redirects its landing page (e.g. `?themeRefresh=1`), which aborts the first load.
|
||||||
|
if ((error as { code?: unknown }).code === 'ERR_ABORTED') return;
|
||||||
|
deps.logWarn('Failed to load YouTube in the browser window', error);
|
||||||
|
});
|
||||||
|
return window;
|
||||||
|
};
|
||||||
|
|
||||||
|
const open = (): void => {
|
||||||
|
registerIpc();
|
||||||
|
if (browserWindow && !browserWindow.isDestroyed()) {
|
||||||
|
if (browserWindow.isMinimized()) browserWindow.restore();
|
||||||
|
browserWindow.show();
|
||||||
|
browserWindow.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
browserWindow = createWindow();
|
||||||
|
};
|
||||||
|
|
||||||
|
const isOpen = (): boolean => Boolean(browserWindow && !browserWindow.isDestroyed());
|
||||||
|
|
||||||
|
return { open, isOpen };
|
||||||
|
}
|
||||||
@@ -1733,7 +1733,6 @@ test('youtube flow injects downloaded primary even when reusable manual youtube
|
|||||||
|
|
||||||
await runtime.runYoutubePlaybackFlow({
|
await runtime.runYoutubePlaybackFlow({
|
||||||
url: 'https://example.com/watch?v=video123',
|
url: 'https://example.com/watch?v=video123',
|
||||||
mode: 'download',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(selectedPrimarySid, 9);
|
assert.equal(selectedPrimarySid, 9);
|
||||||
@@ -1884,9 +1883,145 @@ test('youtube flow falls back to existing auto secondary track when auto seconda
|
|||||||
|
|
||||||
await runtime.runYoutubePlaybackFlow({
|
await runtime.runYoutubePlaybackFlow({
|
||||||
url: 'https://example.com/watch?v=video123',
|
url: 'https://example.com/watch?v=video123',
|
||||||
mode: 'download',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(selectedPrimarySid, 4);
|
assert.equal(selectedPrimarySid, 4);
|
||||||
assert.equal(selectedSecondarySid, 1);
|
assert.equal(selectedSecondarySid, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function createWhisperFlowHarness(
|
||||||
|
generateWhisperSubtitles: (input: {
|
||||||
|
url: string;
|
||||||
|
outputPath: string;
|
||||||
|
signal: AbortSignal;
|
||||||
|
}) => Promise<string | null>,
|
||||||
|
) {
|
||||||
|
const events: string[] = [];
|
||||||
|
const failures: string[] = [];
|
||||||
|
let addedSubtitlePath: string | null = null;
|
||||||
|
const runtime = createYoutubeFlowRuntime({
|
||||||
|
probeYoutubeTracks: async () => {
|
||||||
|
throw new Error('Whisper mode must not probe YouTube captions');
|
||||||
|
},
|
||||||
|
acquireYoutubeSubtitleTracks: async () => {
|
||||||
|
throw new Error('Whisper mode must not download YouTube captions');
|
||||||
|
},
|
||||||
|
acquireYoutubeSubtitleTrack: async () => {
|
||||||
|
throw new Error('Whisper mode must not download YouTube captions');
|
||||||
|
},
|
||||||
|
openPicker: async () => false,
|
||||||
|
pauseMpv: () => events.push('pause'),
|
||||||
|
resumeMpv: () => events.push('resume'),
|
||||||
|
sendMpvCommand: (command) => {
|
||||||
|
if (command[0] === 'sub-add') {
|
||||||
|
addedSubtitlePath = String(command[1]);
|
||||||
|
events.push('sub-add');
|
||||||
|
}
|
||||||
|
if (command[0] === 'script-message') events.push(String(command[1]));
|
||||||
|
},
|
||||||
|
requestMpvProperty: async (name) => {
|
||||||
|
if (name === 'track-list') {
|
||||||
|
return addedSubtitlePath
|
||||||
|
? [{ type: 'sub', id: 3, external: true, 'external-filename': addedSubtitlePath }]
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
if (name === 'sid') return addedSubtitlePath ? 3 : null;
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
refreshCurrentSubtitle: () => {},
|
||||||
|
startTokenizationWarmups: async () => {},
|
||||||
|
waitForTokenizationReady: async () => {},
|
||||||
|
waitForAnkiReady: async () => {},
|
||||||
|
wait: async () => {},
|
||||||
|
waitForPlaybackWindowReady: async () => {},
|
||||||
|
waitForOverlayGeometryReady: async () => {},
|
||||||
|
focusOverlayWindow: () => {},
|
||||||
|
showMpvOsd: () => {},
|
||||||
|
reportSubtitleFailure: (message) => failures.push(message),
|
||||||
|
warn: () => {},
|
||||||
|
log: () => {},
|
||||||
|
getYoutubeOutputDir: () => '/tmp',
|
||||||
|
getSubtitleSource: () => 'whisper',
|
||||||
|
generateWhisperSubtitles: (input) => {
|
||||||
|
events.push('generate');
|
||||||
|
return generateWhisperSubtitles(input);
|
||||||
|
},
|
||||||
|
openSubtitleGenerationModal: async () => {
|
||||||
|
events.push('open-modal');
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
runtime,
|
||||||
|
failures,
|
||||||
|
// The modal opens once the player window is ready, so its position in the order varies.
|
||||||
|
events: () => events.filter((event) => event !== 'open-modal'),
|
||||||
|
openedModal: () => events.includes('open-modal'),
|
||||||
|
getAddedSubtitlePath: () => addedSubtitlePath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const WHISPER_URL = 'https://www.youtube.com/watch?v=abcdefghijk';
|
||||||
|
|
||||||
|
test('whisper subtitle source keeps the video paused until the generated subtitles load', async () => {
|
||||||
|
const harness = createWhisperFlowHarness(async (input) => input.outputPath);
|
||||||
|
|
||||||
|
await harness.runtime.runYoutubePlaybackFlow({ url: WHISPER_URL });
|
||||||
|
|
||||||
|
assert.deepEqual(harness.events(), [
|
||||||
|
'pause',
|
||||||
|
'subminer-autoplay-hold',
|
||||||
|
'generate',
|
||||||
|
'sub-add',
|
||||||
|
'subminer-autoplay-ready',
|
||||||
|
'resume',
|
||||||
|
]);
|
||||||
|
assert.equal(harness.openedModal(), true);
|
||||||
|
assert.equal(path.basename(harness.getAddedSubtitlePath() ?? ''), 'youtube-whisper.ja.srt');
|
||||||
|
assert.deepEqual(harness.failures, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cancelling whisper generation resumes playback without subtitles or an error', async () => {
|
||||||
|
const harness = createWhisperFlowHarness(async () => null);
|
||||||
|
|
||||||
|
await harness.runtime.runYoutubePlaybackFlow({ url: WHISPER_URL });
|
||||||
|
|
||||||
|
assert.equal(harness.getAddedSubtitlePath(), null);
|
||||||
|
assert.deepEqual(harness.failures, []);
|
||||||
|
assert.equal(harness.events().at(-1), 'resume');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('whisper generation stops quietly and leaves playback alone when the video changes', async () => {
|
||||||
|
let signal: AbortSignal | null = null;
|
||||||
|
const harness = createWhisperFlowHarness(
|
||||||
|
(input) =>
|
||||||
|
new Promise((_, reject) => {
|
||||||
|
signal = input.signal;
|
||||||
|
input.signal.addEventListener('abort', () => reject(new Error('cancelled')));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const flow = harness.runtime.runYoutubePlaybackFlow({ url: WHISPER_URL });
|
||||||
|
while (!signal) await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
harness.runtime.handleMediaPathChange(WHISPER_URL);
|
||||||
|
assert.equal((signal as AbortSignal).aborted, false);
|
||||||
|
harness.runtime.handleMediaPathChange('https://www.youtube.com/watch?v=zyxwvutsrqp');
|
||||||
|
await flow;
|
||||||
|
|
||||||
|
assert.equal((signal as AbortSignal).aborted, true);
|
||||||
|
assert.equal(harness.getAddedSubtitlePath(), null);
|
||||||
|
assert.deepEqual(harness.failures, []);
|
||||||
|
assert.equal(harness.events().includes('resume'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('whisper generation failures are reported and playback resumes', async () => {
|
||||||
|
const harness = createWhisperFlowHarness(async () => {
|
||||||
|
throw new Error('No Whisper model found.');
|
||||||
|
});
|
||||||
|
|
||||||
|
await harness.runtime.runYoutubePlaybackFlow({ url: WHISPER_URL });
|
||||||
|
|
||||||
|
assert.deepEqual(harness.failures, ['Whisper subtitles failed: No Whisper model found.']);
|
||||||
|
assert.equal(harness.getAddedSubtitlePath(), null);
|
||||||
|
assert.equal(harness.events().at(-1), 'resume');
|
||||||
|
});
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import type {
|
|||||||
YoutubePickerResolveRequest,
|
YoutubePickerResolveRequest,
|
||||||
YoutubePickerResolveResult,
|
YoutubePickerResolveResult,
|
||||||
} from '../../types';
|
} from '../../types';
|
||||||
|
import type { YoutubeSubtitleSource } from '../../types/integrations';
|
||||||
|
import type { SubtitleGenerationProgress } from '../../shared/subtitle-generation';
|
||||||
import type {
|
import type {
|
||||||
YoutubeTrackOption,
|
YoutubeTrackOption,
|
||||||
YoutubeTrackProbeResult,
|
YoutubeTrackProbeResult,
|
||||||
@@ -18,9 +20,9 @@ import {
|
|||||||
acquireYoutubeSubtitleTracks,
|
acquireYoutubeSubtitleTracks,
|
||||||
} from '../../core/services/youtube/generate';
|
} from '../../core/services/youtube/generate';
|
||||||
import { resolveSubtitleSourcePath } from './subtitle-prefetch-source';
|
import { resolveSubtitleSourcePath } from './subtitle-prefetch-source';
|
||||||
|
import { extractYoutubeVideoId } from './youtube-playback';
|
||||||
|
|
||||||
type YoutubeFlowOpenPicker = (payload: YoutubePickerOpenPayload) => Promise<boolean>;
|
type YoutubeFlowOpenPicker = (payload: YoutubePickerOpenPayload) => Promise<boolean>;
|
||||||
type YoutubeFlowMode = 'download' | 'generate';
|
|
||||||
|
|
||||||
type YoutubeFlowDeps = {
|
type YoutubeFlowDeps = {
|
||||||
probeYoutubeTracks: (url: string) => Promise<YoutubeTrackProbeResult>;
|
probeYoutubeTracks: (url: string) => Promise<YoutubeTrackProbeResult>;
|
||||||
@@ -48,6 +50,20 @@ type YoutubeFlowDeps = {
|
|||||||
getYoutubeOutputDir: () => string;
|
getYoutubeOutputDir: () => string;
|
||||||
createSubtitleTempDir?: () => Promise<string>;
|
createSubtitleTempDir?: () => Promise<string>;
|
||||||
cleanupSubtitleTempDirs?: (dirs: string[]) => void;
|
cleanupSubtitleTempDirs?: (dirs: string[]) => void;
|
||||||
|
/** Current `youtube.subtitleSource`; YouTube captions when absent. */
|
||||||
|
getSubtitleSource?: () => YoutubeSubtitleSource;
|
||||||
|
/**
|
||||||
|
* Transcribes the video's audio into `outputPath`. Resolves to the written file, or null
|
||||||
|
* when the user cancelled generation.
|
||||||
|
*/
|
||||||
|
generateWhisperSubtitles?: (input: {
|
||||||
|
url: string;
|
||||||
|
outputPath: string;
|
||||||
|
signal: AbortSignal;
|
||||||
|
onProgress: (progress: SubtitleGenerationProgress) => void;
|
||||||
|
}) => Promise<string | null>;
|
||||||
|
/** Shows Whisper progress; closing it leaves generation running. */
|
||||||
|
openSubtitleGenerationModal?: () => Promise<boolean>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type YoutubeFlowSession = {
|
type YoutubeFlowSession = {
|
||||||
@@ -58,6 +74,13 @@ type YoutubeFlowSession = {
|
|||||||
|
|
||||||
const YOUTUBE_PICKER_SETTLE_DELAY_MS = 150;
|
const YOUTUBE_PICKER_SETTLE_DELAY_MS = 150;
|
||||||
const YOUTUBE_SECONDARY_RETRY_DELAY_MS = 350;
|
const YOUTUBE_SECONDARY_RETRY_DELAY_MS = 350;
|
||||||
|
const WHISPER_TRACK: YoutubeTrackOption = {
|
||||||
|
id: 'whisper:ja',
|
||||||
|
language: 'ja',
|
||||||
|
sourceLanguage: 'ja',
|
||||||
|
kind: 'auto',
|
||||||
|
label: 'Japanese (Whisper)',
|
||||||
|
};
|
||||||
|
|
||||||
function createSessionId(): string {
|
function createSessionId(): string {
|
||||||
return `yt-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
return `yt-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
@@ -102,6 +125,21 @@ function createYoutubeFlowOsdProgress(showMpvOsd: (text: string) => void) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Shows Whisper progress, skipping updates that would not change the text. */
|
||||||
|
function createWhisperProgressReporter(
|
||||||
|
showMpvOsd: (text: string) => void,
|
||||||
|
): (progress: SubtitleGenerationProgress) => void {
|
||||||
|
let lastText = '';
|
||||||
|
return (progress) => {
|
||||||
|
const percent =
|
||||||
|
progress.percent === undefined ? '' : ` ${Math.floor(progress.percent / 5) * 5}%`;
|
||||||
|
const text = `Whisper: ${progress.message.replace(/\.+$/, '')}${percent}`;
|
||||||
|
if (text === lastText) return;
|
||||||
|
lastText = text;
|
||||||
|
showMpvOsd(text);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function releasePlaybackGate(deps: YoutubeFlowDeps): void {
|
function releasePlaybackGate(deps: YoutubeFlowDeps): void {
|
||||||
deps.sendMpvCommand(['script-message', 'subminer-autoplay-ready']);
|
deps.sendMpvCommand(['script-message', 'subminer-autoplay-ready']);
|
||||||
deps.resumeMpv();
|
deps.resumeMpv();
|
||||||
@@ -429,6 +467,20 @@ async function injectDownloadedSubtitles(
|
|||||||
export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
|
export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
|
||||||
let activeSession: YoutubeFlowSession | null = null;
|
let activeSession: YoutubeFlowSession | null = null;
|
||||||
const activeSubtitleTempDirs = new Set<string>();
|
const activeSubtitleTempDirs = new Set<string>();
|
||||||
|
let activeWhisperJob: { videoId: string | null; controller: AbortController } | null = null;
|
||||||
|
|
||||||
|
/** Aborting also deletes the job's downloaded audio. */
|
||||||
|
const cancelWhisperGeneration = (): void => {
|
||||||
|
activeWhisperJob?.controller.abort();
|
||||||
|
activeWhisperJob = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMediaPathChange = (mediaPath: string | null | undefined): void => {
|
||||||
|
if (!activeWhisperJob) return;
|
||||||
|
const videoId = extractYoutubeVideoId(mediaPath);
|
||||||
|
if (videoId && videoId === activeWhisperJob.videoId) return;
|
||||||
|
cancelWhisperGeneration();
|
||||||
|
};
|
||||||
|
|
||||||
const cleanupSubtitleTempDirs = (): void => {
|
const cleanupSubtitleTempDirs = (): void => {
|
||||||
const dirs = [...activeSubtitleTempDirs];
|
const dirs = [...activeSubtitleTempDirs];
|
||||||
@@ -476,6 +528,14 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** A temp dir for subtitles loaded outside the flow; removed with the flow's own. */
|
||||||
|
const createSubtitleTempDir = async (): Promise<string> => {
|
||||||
|
if (!deps.createSubtitleTempDir) return normalizeOutputPath(deps.getYoutubeOutputDir());
|
||||||
|
const dir = await deps.createSubtitleTempDir();
|
||||||
|
activeSubtitleTempDirs.add(dir);
|
||||||
|
return dir;
|
||||||
|
};
|
||||||
|
|
||||||
const acquireSelectedTracks = async (input: {
|
const acquireSelectedTracks = async (input: {
|
||||||
targetUrl: string;
|
targetUrl: string;
|
||||||
outputDir: string;
|
outputDir: string;
|
||||||
@@ -604,7 +664,6 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
|
|||||||
|
|
||||||
const loadTracksIntoMpv = async (input: {
|
const loadTracksIntoMpv = async (input: {
|
||||||
url: string;
|
url: string;
|
||||||
mode: YoutubeFlowMode;
|
|
||||||
outputDir: string;
|
outputDir: string;
|
||||||
primaryTrack: YoutubeTrackOption;
|
primaryTrack: YoutubeTrackOption;
|
||||||
secondaryTrack: YoutubeTrackOption | null;
|
secondaryTrack: YoutubeTrackOption | null;
|
||||||
@@ -751,10 +810,7 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const openManualPicker = async (input: {
|
const openManualPicker = async (input: { url: string }): Promise<void> => {
|
||||||
url: string;
|
|
||||||
mode?: YoutubeFlowMode;
|
|
||||||
}): Promise<void> => {
|
|
||||||
deps.showMpvOsd('Opening YouTube subtitle picker...');
|
deps.showMpvOsd('Opening YouTube subtitle picker...');
|
||||||
|
|
||||||
let probe: YoutubeTrackProbeResult;
|
let probe: YoutubeTrackProbeResult;
|
||||||
@@ -818,11 +874,12 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
|
|||||||
});
|
});
|
||||||
const secondaryTrack = getTrackById(probe.tracks, selected.secondaryTrackId);
|
const secondaryTrack = getTrackById(probe.tracks, selected.secondaryTrackId);
|
||||||
|
|
||||||
|
// A track picked by hand replaces whatever Whisper would have loaded.
|
||||||
|
cancelWhisperGeneration();
|
||||||
try {
|
try {
|
||||||
deps.showMpvOsd('Getting subtitles...');
|
deps.showMpvOsd('Getting subtitles...');
|
||||||
const loaded = await loadTracksIntoMpv({
|
const loaded = await loadTracksIntoMpv({
|
||||||
url: input.url,
|
url: input.url,
|
||||||
mode: input.mode ?? 'download',
|
|
||||||
outputDir: normalizeOutputPath(deps.getYoutubeOutputDir()),
|
outputDir: normalizeOutputPath(deps.getYoutubeOutputDir()),
|
||||||
primaryTrack,
|
primaryTrack,
|
||||||
secondaryTrack,
|
secondaryTrack,
|
||||||
@@ -844,10 +901,86 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
async function runYoutubePlaybackFlow(input: {
|
const openWhisperProgressModal = async (jobSignal: AbortSignal): Promise<void> => {
|
||||||
url: string;
|
if (!deps.openSubtitleGenerationModal) return;
|
||||||
mode: YoutubeFlowMode;
|
// Like the picker, wait for mpv's window so the modal lands on top of it.
|
||||||
}): Promise<void> {
|
await deps.waitForPlaybackWindowReady();
|
||||||
|
await deps.waitForOverlayGeometryReady();
|
||||||
|
if (jobSignal.aborted) return;
|
||||||
|
if (!(await deps.openSubtitleGenerationModal().catch(() => false))) {
|
||||||
|
deps.warn('Unable to open the subtitle generation modal for Whisper progress.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadWhisperSubtitles = async (
|
||||||
|
url: string,
|
||||||
|
generate: NonNullable<YoutubeFlowDeps['generateWhisperSubtitles']>,
|
||||||
|
tokenizationWarmupPromise: Promise<void>,
|
||||||
|
): Promise<void> => {
|
||||||
|
const job = { videoId: extractYoutubeVideoId(url), controller: new AbortController() };
|
||||||
|
activeWhisperJob = job;
|
||||||
|
// Playback stays paused until the subtitles load. Generation outlasts the mpv plugin's
|
||||||
|
// pause-until-ready timeout, so ask it to hold the pause instead of resuming on its own.
|
||||||
|
deps.sendMpvCommand(['script-message', 'subminer-autoplay-hold']);
|
||||||
|
deps.showMpvOsd('Generating Japanese subtitles with Whisper...');
|
||||||
|
try {
|
||||||
|
const outputDir = await prepareSubtitleOutputDir(
|
||||||
|
normalizeOutputPath(deps.getYoutubeOutputDir()),
|
||||||
|
);
|
||||||
|
const generation = generate({
|
||||||
|
url,
|
||||||
|
outputPath: path.join(outputDir, 'youtube-whisper.ja.srt'),
|
||||||
|
signal: job.controller.signal,
|
||||||
|
onProgress: createWhisperProgressReporter(deps.showMpvOsd),
|
||||||
|
});
|
||||||
|
void openWhisperProgressModal(job.controller.signal);
|
||||||
|
const subtitlePath = await generation;
|
||||||
|
job.controller.signal.throwIfAborted();
|
||||||
|
if (subtitlePath === null) {
|
||||||
|
deps.log('Whisper subtitle generation was cancelled.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const loaded = await injectDownloadedSubtitles(
|
||||||
|
deps,
|
||||||
|
{ track: WHISPER_TRACK, existingTrackId: null, injectedPath: subtitlePath },
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
if (!loaded) {
|
||||||
|
reportPrimarySubtitleFailure();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
deps.notifyPrimarySubtitleLoaded?.();
|
||||||
|
try {
|
||||||
|
await deps.refreshSubtitleSidebarSource?.(subtitlePath, url);
|
||||||
|
} catch (error) {
|
||||||
|
deps.warn(
|
||||||
|
`Failed to refresh parsed subtitle cues for sidebar: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await tokenizationWarmupPromise;
|
||||||
|
await deps.waitForTokenizationReady();
|
||||||
|
await deps.waitForAnkiReady();
|
||||||
|
} catch (error) {
|
||||||
|
if (job.controller.signal.aborted) return;
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
deps.warn(`Failed to generate YouTube subtitles with Whisper: ${message}`);
|
||||||
|
deps.reportSubtitleFailure(`Whisper subtitles failed: ${message}`);
|
||||||
|
} finally {
|
||||||
|
if (activeWhisperJob === job) activeWhisperJob = null;
|
||||||
|
if (job.controller.signal.aborted) {
|
||||||
|
// The video changed or closed; playback now belongs to whatever replaced it.
|
||||||
|
deps.log('Stopped Whisper subtitle generation because the video changed or closed.');
|
||||||
|
} else {
|
||||||
|
releasePlaybackGate(deps);
|
||||||
|
restoreOverlayInputFocus(deps);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
async function runYoutubePlaybackFlow(input: { url: string }): Promise<void> {
|
||||||
deps.showMpvOsd('Opening YouTube video');
|
deps.showMpvOsd('Opening YouTube video');
|
||||||
const tokenizationWarmupPromise = deps.startTokenizationWarmups().catch((error) => {
|
const tokenizationWarmupPromise = deps.startTokenizationWarmups().catch((error) => {
|
||||||
deps.warn(
|
deps.warn(
|
||||||
@@ -859,6 +992,15 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
|
|||||||
|
|
||||||
deps.pauseMpv();
|
deps.pauseMpv();
|
||||||
suppressYoutubeSubtitleState(deps);
|
suppressYoutubeSubtitleState(deps);
|
||||||
|
cancelWhisperGeneration();
|
||||||
|
if (deps.getSubtitleSource?.() === 'whisper' && deps.generateWhisperSubtitles) {
|
||||||
|
await loadWhisperSubtitles(
|
||||||
|
input.url,
|
||||||
|
deps.generateWhisperSubtitles,
|
||||||
|
tokenizationWarmupPromise,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const outputDir = normalizeOutputPath(deps.getYoutubeOutputDir());
|
const outputDir = normalizeOutputPath(deps.getYoutubeOutputDir());
|
||||||
|
|
||||||
let probe: YoutubeTrackProbeResult;
|
let probe: YoutubeTrackProbeResult;
|
||||||
@@ -890,14 +1032,10 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
|
|||||||
deps.showMpvOsd('Getting subtitles...');
|
deps.showMpvOsd('Getting subtitles...');
|
||||||
const loaded = await loadTracksIntoMpv({
|
const loaded = await loadTracksIntoMpv({
|
||||||
url: input.url,
|
url: input.url,
|
||||||
mode: input.mode,
|
|
||||||
outputDir,
|
outputDir,
|
||||||
primaryTrack,
|
primaryTrack,
|
||||||
secondaryTrack,
|
secondaryTrack,
|
||||||
secondaryFailureLabel:
|
secondaryFailureLabel: 'Failed to download secondary YouTube subtitle track',
|
||||||
input.mode === 'generate'
|
|
||||||
? 'Failed to generate secondary YouTube subtitle track'
|
|
||||||
: 'Failed to download secondary YouTube subtitle track',
|
|
||||||
tokenizationWarmupPromise,
|
tokenizationWarmupPromise,
|
||||||
showDownloadProgress: false,
|
showDownloadProgress: false,
|
||||||
});
|
});
|
||||||
@@ -906,9 +1044,7 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
deps.warn(
|
deps.warn(
|
||||||
`Failed to ${
|
`Failed to download primary YouTube subtitle track: ${
|
||||||
input.mode === 'generate' ? 'generate' : 'download'
|
|
||||||
} primary YouTube subtitle track: ${
|
|
||||||
error instanceof Error ? error.message : String(error)
|
error instanceof Error ? error.message : String(error)
|
||||||
}`,
|
}`,
|
||||||
);
|
);
|
||||||
@@ -926,5 +1062,8 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
|
|||||||
cancelActivePicker,
|
cancelActivePicker,
|
||||||
hasActiveSession: () => Boolean(activeSession),
|
hasActiveSession: () => Boolean(activeSession),
|
||||||
cleanupSubtitleTempDirs,
|
cleanupSubtitleTempDirs,
|
||||||
|
createSubtitleTempDir,
|
||||||
|
cancelWhisperGeneration,
|
||||||
|
handleMediaPathChange,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,8 +38,8 @@ test('youtube playback runtime resets flow ownership after a successful run', as
|
|||||||
calls.push(`prepare:${url}`);
|
calls.push(`prepare:${url}`);
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
runYoutubePlaybackFlow: async ({ url, mode }) => {
|
runYoutubePlaybackFlow: async ({ url }) => {
|
||||||
calls.push(`run-flow:${url}:${mode}`);
|
calls.push(`run-flow:${url}`);
|
||||||
},
|
},
|
||||||
logInfo: (message) => {
|
logInfo: (message) => {
|
||||||
calls.push(`info:${message}`);
|
calls.push(`info:${message}`);
|
||||||
@@ -59,7 +59,6 @@ test('youtube playback runtime resets flow ownership after a successful run', as
|
|||||||
|
|
||||||
await runtime.runYoutubePlaybackFlow({
|
await runtime.runYoutubePlaybackFlow({
|
||||||
url: 'https://youtu.be/demo',
|
url: 'https://youtu.be/demo',
|
||||||
mode: 'download',
|
|
||||||
source: 'initial',
|
source: 'initial',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -117,8 +116,8 @@ test('youtube playback runtime resolves the socket path lazily for windows start
|
|||||||
calls.push(`prepare:${url}`);
|
calls.push(`prepare:${url}`);
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
runYoutubePlaybackFlow: async ({ url, mode }) => {
|
runYoutubePlaybackFlow: async ({ url }) => {
|
||||||
calls.push(`run-flow:${url}:${mode}`);
|
calls.push(`run-flow:${url}`);
|
||||||
},
|
},
|
||||||
logInfo: (message) => {
|
logInfo: (message) => {
|
||||||
calls.push(`info:${message}`);
|
calls.push(`info:${message}`);
|
||||||
@@ -140,7 +139,6 @@ test('youtube playback runtime resolves the socket path lazily for windows start
|
|||||||
|
|
||||||
await runtime.runYoutubePlaybackFlow({
|
await runtime.runYoutubePlaybackFlow({
|
||||||
url: 'https://youtu.be/demo',
|
url: 'https://youtu.be/demo',
|
||||||
mode: 'download',
|
|
||||||
source: 'initial',
|
source: 'initial',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -185,8 +183,8 @@ test('youtube playback runtime starts media cache without blocking the subtitle
|
|||||||
await cachePromise;
|
await cachePromise;
|
||||||
calls.push('cache-done');
|
calls.push('cache-done');
|
||||||
},
|
},
|
||||||
runYoutubePlaybackFlow: async ({ url, mode }) => {
|
runYoutubePlaybackFlow: async ({ url }) => {
|
||||||
calls.push(`run-flow:${url}:${mode}`);
|
calls.push(`run-flow:${url}`);
|
||||||
},
|
},
|
||||||
logInfo: (message) => {
|
logInfo: (message) => {
|
||||||
calls.push(`info:${message}`);
|
calls.push(`info:${message}`);
|
||||||
@@ -200,13 +198,12 @@ test('youtube playback runtime starts media cache without blocking the subtitle
|
|||||||
|
|
||||||
await runtime.runYoutubePlaybackFlow({
|
await runtime.runYoutubePlaybackFlow({
|
||||||
url: 'https://youtu.be/demo',
|
url: 'https://youtu.be/demo',
|
||||||
mode: 'download',
|
|
||||||
source: 'second-instance',
|
source: 'second-instance',
|
||||||
});
|
});
|
||||||
|
|
||||||
const prepareIndex = calls.indexOf('prepare:https://youtu.be/demo');
|
const prepareIndex = calls.indexOf('prepare:https://youtu.be/demo');
|
||||||
const cacheIndex = calls.indexOf('cache:https://youtu.be/demo');
|
const cacheIndex = calls.indexOf('cache:https://youtu.be/demo');
|
||||||
const runFlowIndex = calls.indexOf('run-flow:https://youtu.be/demo:download');
|
const runFlowIndex = calls.indexOf('run-flow:https://youtu.be/demo');
|
||||||
assert.notEqual(prepareIndex, -1);
|
assert.notEqual(prepareIndex, -1);
|
||||||
assert.notEqual(cacheIndex, -1);
|
assert.notEqual(cacheIndex, -1);
|
||||||
assert.notEqual(runFlowIndex, -1);
|
assert.notEqual(runFlowIndex, -1);
|
||||||
@@ -251,8 +248,8 @@ test('youtube playback runtime logs synchronous media cache startup failures', a
|
|||||||
calls.push('cache');
|
calls.push('cache');
|
||||||
throw new Error('cache exploded');
|
throw new Error('cache exploded');
|
||||||
},
|
},
|
||||||
runYoutubePlaybackFlow: async ({ url, mode }) => {
|
runYoutubePlaybackFlow: async ({ url }) => {
|
||||||
calls.push(`run-flow:${url}:${mode}`);
|
calls.push(`run-flow:${url}`);
|
||||||
},
|
},
|
||||||
logInfo: (message) => {
|
logInfo: (message) => {
|
||||||
calls.push(`info:${message}`);
|
calls.push(`info:${message}`);
|
||||||
@@ -266,12 +263,11 @@ test('youtube playback runtime logs synchronous media cache startup failures', a
|
|||||||
|
|
||||||
await runtime.runYoutubePlaybackFlow({
|
await runtime.runYoutubePlaybackFlow({
|
||||||
url: 'https://youtu.be/demo',
|
url: 'https://youtu.be/demo',
|
||||||
mode: 'download',
|
|
||||||
source: 'second-instance',
|
source: 'second-instance',
|
||||||
});
|
});
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
|
|
||||||
assert.ok(calls.includes('run-flow:https://youtu.be/demo:download'));
|
assert.ok(calls.includes('run-flow:https://youtu.be/demo'));
|
||||||
assert.ok(
|
assert.ok(
|
||||||
calls.some((entry) =>
|
calls.some((entry) =>
|
||||||
entry.startsWith('warn:Failed to start YouTube media cache: cache exploded'),
|
entry.startsWith('warn:Failed to start YouTube media cache: cache exploded'),
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import type { CliArgs, CliCommandSource } from '../../cli/args';
|
import type { CliCommandSource } from '../../cli/args';
|
||||||
|
|
||||||
|
/** Where a playback flow request came from; only `initial` arms quit-on-disconnect. */
|
||||||
|
export type YoutubePlaybackFlowSource = CliCommandSource | 'youtube-browser';
|
||||||
|
|
||||||
type LaunchResult = {
|
type LaunchResult = {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
@@ -21,10 +24,7 @@ export type YoutubePlaybackRuntimeDeps = {
|
|||||||
waitForYoutubeMpvConnected: (timeoutMs: number) => Promise<boolean>;
|
waitForYoutubeMpvConnected: (timeoutMs: number) => Promise<boolean>;
|
||||||
prepareYoutubePlaybackInMpv: (request: { url: string }) => Promise<boolean>;
|
prepareYoutubePlaybackInMpv: (request: { url: string }) => Promise<boolean>;
|
||||||
startYoutubeMediaCache?: (url: string) => void | Promise<void>;
|
startYoutubeMediaCache?: (url: string) => void | Promise<void>;
|
||||||
runYoutubePlaybackFlow: (request: {
|
runYoutubePlaybackFlow: (request: { url: string }) => Promise<void>;
|
||||||
url: string;
|
|
||||||
mode: NonNullable<CliArgs['youtubeMode']>;
|
|
||||||
}) => Promise<void>;
|
|
||||||
logInfo: (message: string) => void;
|
logInfo: (message: string) => void;
|
||||||
logWarn: (message: string) => void;
|
logWarn: (message: string) => void;
|
||||||
schedule: (callback: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
|
schedule: (callback: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
|
||||||
@@ -45,8 +45,7 @@ export function createYoutubePlaybackRuntime(deps: YoutubePlaybackRuntimeDeps) {
|
|||||||
|
|
||||||
const runYoutubePlaybackFlow = async (request: {
|
const runYoutubePlaybackFlow = async (request: {
|
||||||
url: string;
|
url: string;
|
||||||
mode: NonNullable<CliArgs['youtubeMode']>;
|
source: YoutubePlaybackFlowSource;
|
||||||
source: CliCommandSource;
|
|
||||||
}): Promise<void> => {
|
}): Promise<void> => {
|
||||||
const flowGeneration = ++playbackFlowGeneration;
|
const flowGeneration = ++playbackFlowGeneration;
|
||||||
deps.invalidatePendingAutoplayReadyFallbacks();
|
deps.invalidatePendingAutoplayReadyFallbacks();
|
||||||
@@ -139,10 +138,7 @@ export function createYoutubePlaybackRuntime(deps: YoutubePlaybackRuntimeDeps) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await deps.runYoutubePlaybackFlow({
|
await deps.runYoutubePlaybackFlow({ url: request.url });
|
||||||
url: request.url,
|
|
||||||
mode: request.mode,
|
|
||||||
});
|
|
||||||
flowCompleted = true;
|
flowCompleted = true;
|
||||||
deps.logInfo(`YouTube playback flow completed from ${request.source}.`);
|
deps.logInfo(`YouTube playback flow completed from ${request.source}.`);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import {
|
import {
|
||||||
|
toYoutubeWatchUrl,
|
||||||
isSameYoutubeMediaPath,
|
isSameYoutubeMediaPath,
|
||||||
isYoutubeMediaPath,
|
isYoutubeMediaPath,
|
||||||
isYoutubePlaybackActive,
|
isYoutubePlaybackActive,
|
||||||
@@ -76,3 +77,25 @@ test('shouldUseCachedYoutubeParsedCues requires cached cues for the same youtube
|
|||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('toYoutubeWatchUrl reduces watch, shorts, and short links to a single-video url', () => {
|
||||||
|
assert.equal(
|
||||||
|
toYoutubeWatchUrl('https://www.youtube.com/watch?v=dQw4w9WgXcQ&list=WL&index=3&t=42s'),
|
||||||
|
'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
toYoutubeWatchUrl('https://m.youtube.com/shorts/dQw4w9WgXcQ'),
|
||||||
|
'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
toYoutubeWatchUrl('https://youtu.be/dQw4w9WgXcQ?si=abc'),
|
||||||
|
'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toYoutubeWatchUrl rejects non-video pages and malformed ids', () => {
|
||||||
|
assert.equal(toYoutubeWatchUrl('https://www.youtube.com/feed/subscriptions'), null);
|
||||||
|
assert.equal(toYoutubeWatchUrl('https://www.youtube.com/watch?v=bad id'), null);
|
||||||
|
assert.equal(toYoutubeWatchUrl('https://example.com/watch?v=dQw4w9WgXcQ'), null);
|
||||||
|
assert.equal(toYoutubeWatchUrl('/tmp/video.mkv'), null);
|
||||||
|
});
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ function matchesYoutubeHost(hostname: string, expectedHost: string): boolean {
|
|||||||
return hostname === expectedHost || hostname.endsWith(`.${expectedHost}`);
|
return hostname === expectedHost || hostname.endsWith(`.${expectedHost}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractYoutubeVideoId(mediaPath: string | null | undefined): string | null {
|
export function extractYoutubeVideoId(mediaPath: string | null | undefined): string | null {
|
||||||
const normalized = trimToNull(mediaPath);
|
const normalized = trimToNull(mediaPath);
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
return null;
|
return null;
|
||||||
@@ -43,6 +43,19 @@ function extractYoutubeVideoId(mediaPath: string | null | undefined): string | n
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const YOUTUBE_VIDEO_ID_PATTERN = /^[A-Za-z0-9_-]{11}$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single-video watch URL for a YouTube link. Drops `list=`/`index=`/`t=` so mpv's ytdl hook
|
||||||
|
* loads one video instead of expanding the whole playlist.
|
||||||
|
*/
|
||||||
|
export function toYoutubeWatchUrl(mediaPath: string | null | undefined): string | null {
|
||||||
|
const videoId = extractYoutubeVideoId(mediaPath);
|
||||||
|
return videoId && YOUTUBE_VIDEO_ID_PATTERN.test(videoId)
|
||||||
|
? `https://www.youtube.com/watch?v=${videoId}`
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
export function isYoutubeMediaPath(mediaPath: string | null | undefined): boolean {
|
export function isYoutubeMediaPath(mediaPath: string | null | undefined): boolean {
|
||||||
const normalized = trimToNull(mediaPath);
|
const normalized = trimToNull(mediaPath);
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { ipcRenderer } from 'electron';
|
||||||
|
|
||||||
|
// Sandboxed preloads can only require `electron`, so these mirror IPC_CHANNELS in
|
||||||
|
// src/shared/ipc/contracts.ts (youtubeBrowserOpenVideo / youtubeBrowserToast).
|
||||||
|
const OPEN_VIDEO_CHANNEL = 'youtube-browser:open-video';
|
||||||
|
const TOAST_CHANNEL = 'youtube-browser:toast';
|
||||||
|
|
||||||
|
const MOUSE_BUTTON_MIDDLE = 1;
|
||||||
|
const MOUSE_BUTTON_BACK = 3;
|
||||||
|
const MOUSE_BUTTON_FORWARD = 4;
|
||||||
|
|
||||||
|
function isYoutubeVideoHref(href: string): boolean {
|
||||||
|
let url: URL;
|
||||||
|
try {
|
||||||
|
url = new URL(href);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const host = url.hostname.toLowerCase();
|
||||||
|
if (host !== 'youtube.com' && !host.endsWith('.youtube.com')) return false;
|
||||||
|
return (
|
||||||
|
(url.pathname === '/watch' && url.searchParams.has('v')) ||
|
||||||
|
/^\/shorts\/[^/]+/.test(url.pathname)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findVideoLink(event: MouseEvent): HTMLAnchorElement | null {
|
||||||
|
for (const node of event.composedPath()) {
|
||||||
|
if (node instanceof HTMLAnchorElement && node.href && isYoutubeVideoHref(node.href)) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture phase runs before YouTube's own click handlers, so the page never starts its player.
|
||||||
|
// Plain click plays now; middle click or a modifier-click queues behind the current video.
|
||||||
|
function interceptVideoClick(event: MouseEvent): void {
|
||||||
|
const isPrimary = event.type === 'click' && event.button === 0;
|
||||||
|
const isMiddle = event.type === 'auxclick' && event.button === MOUSE_BUTTON_MIDDLE;
|
||||||
|
if (!isPrimary && !isMiddle) return;
|
||||||
|
const link = findVideoLink(event);
|
||||||
|
if (!link) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
const queue = isMiddle || event.shiftKey || event.ctrlKey || event.metaKey;
|
||||||
|
ipcRenderer.send(OPEN_VIDEO_CHANNEL, { action: queue ? 'queue' : 'play', url: link.href });
|
||||||
|
// Immediate feedback: starting mpv can take a few seconds before the result toast arrives.
|
||||||
|
showToast(queue ? 'Queueing in mpv…' : 'Starting mpv…', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('click', interceptVideoClick, true);
|
||||||
|
window.addEventListener('auxclick', interceptVideoClick, true);
|
||||||
|
|
||||||
|
// Chromium only maps mouse back/forward buttons to history inside a real browser.
|
||||||
|
window.addEventListener('mouseup', (event) => {
|
||||||
|
if (event.button === MOUSE_BUTTON_BACK) history.back();
|
||||||
|
if (event.button === MOUSE_BUTTON_FORWARD) history.forward();
|
||||||
|
});
|
||||||
|
|
||||||
|
let toastTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
function showToast(message: string, ok: boolean): void {
|
||||||
|
let toast = document.getElementById('subminer-youtube-toast');
|
||||||
|
if (!toast) {
|
||||||
|
toast = document.createElement('div');
|
||||||
|
toast.id = 'subminer-youtube-toast';
|
||||||
|
Object.assign(toast.style, {
|
||||||
|
position: 'fixed',
|
||||||
|
left: '50%',
|
||||||
|
bottom: '32px',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
zIndex: '2147483647',
|
||||||
|
padding: '10px 18px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
font: '500 14px/1.4 Roboto, Arial, sans-serif',
|
||||||
|
color: '#fff',
|
||||||
|
boxShadow: '0 4px 16px rgba(0, 0, 0, 0.4)',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
transition: 'opacity 150ms ease',
|
||||||
|
});
|
||||||
|
document.body.appendChild(toast);
|
||||||
|
}
|
||||||
|
toast.textContent = message;
|
||||||
|
toast.style.background = ok ? '#1f1f1f' : '#8c1d18';
|
||||||
|
toast.style.opacity = '1';
|
||||||
|
if (toastTimer) clearTimeout(toastTimer);
|
||||||
|
toastTimer = setTimeout(() => {
|
||||||
|
if (toast) toast.style.opacity = '0';
|
||||||
|
}, 2200);
|
||||||
|
}
|
||||||
|
|
||||||
|
ipcRenderer.on(TOAST_CHANNEL, (_event, payload: unknown) => {
|
||||||
|
if (typeof payload !== 'object' || payload === null) return;
|
||||||
|
const { message, ok } = payload as { message?: unknown; ok?: unknown };
|
||||||
|
if (typeof message === 'string') showToast(message, ok !== false);
|
||||||
|
});
|
||||||
@@ -88,7 +88,7 @@ export function createSubtitleGenerationModal(
|
|||||||
const vad = snapshot ? describeGenerationVad(snapshot.vad) : null;
|
const vad = snapshot ? describeGenerationVad(snapshot.vad) : null;
|
||||||
const tools = snapshot ? describeGenerationTools(snapshot.tools) : null;
|
const tools = snapshot ? describeGenerationTools(snapshot.tools) : null;
|
||||||
const readyMessage = !snapshot?.mediaPath
|
const readyMessage = !snapshot?.mediaPath
|
||||||
? 'Open local media to generate subtitles.'
|
? 'Open a local file or a YouTube video to generate subtitles.'
|
||||||
: !tools?.ready
|
: !tools?.ready
|
||||||
? 'Install the missing tools or set their paths in Settings, then click Check again.'
|
? 'Install the missing tools or set their paths in Settings, then click Check again.'
|
||||||
: !model?.ready
|
: !model?.ready
|
||||||
@@ -96,7 +96,8 @@ export function createSubtitleGenerationModal(
|
|||||||
: !vad?.ready
|
: !vad?.ready
|
||||||
? 'Download the speech detection model or uncheck Focus on spoken dialogue.'
|
? 'Download the speech detection model or uncheck Focus on spoken dialogue.'
|
||||||
: 'Ready when you are.';
|
: 'Ready when you are.';
|
||||||
dom.media.textContent = snapshot?.mediaPath ?? 'Open a local media file in the player first.';
|
dom.media.textContent =
|
||||||
|
snapshot?.mediaPath ?? 'Open a local file or a YouTube video in the player first.';
|
||||||
dom.tools.textContent = tools?.text ?? 'Checking local tools...';
|
dom.tools.textContent = tools?.text ?? 'Checking local tools...';
|
||||||
dom.model.textContent = model?.text ?? 'Checking local models...';
|
dom.model.textContent = model?.text ?? 'Checking local models...';
|
||||||
dom.modelPicker.classList.toggle('hidden', !snapshot || Boolean(snapshot.externalModelPath));
|
dom.modelPicker.classList.toggle('hidden', !snapshot || Boolean(snapshot.externalModelPath));
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ export const IPC_CHANNELS = {
|
|||||||
toggleStatsOverlay: 'stats:toggle-overlay',
|
toggleStatsOverlay: 'stats:toggle-overlay',
|
||||||
markActiveVideoWatched: 'immersion:mark-active-video-watched',
|
markActiveVideoWatched: 'immersion:mark-active-video-watched',
|
||||||
dispatchSessionAction: 'session-action:dispatch',
|
dispatchSessionAction: 'session-action:dispatch',
|
||||||
|
youtubeBrowserOpenVideo: 'youtube-browser:open-video',
|
||||||
},
|
},
|
||||||
request: {
|
request: {
|
||||||
getSubtitleSelection: 'subtitle-selection:get',
|
getSubtitleSelection: 'subtitle-selection:get',
|
||||||
@@ -184,6 +185,7 @@ export const IPC_CHANNELS = {
|
|||||||
notificationHistoryToggle: 'notification-history:toggle',
|
notificationHistoryToggle: 'notification-history:toggle',
|
||||||
syncUiProgress: 'sync-ui:progress',
|
syncUiProgress: 'sync-ui:progress',
|
||||||
syncUiStateChanged: 'sync-ui:state-changed',
|
syncUiStateChanged: 'sync-ui:state-changed',
|
||||||
|
youtubeBrowserToast: 'youtube-browser:toast',
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|||||||
+2
-10
@@ -20,7 +20,7 @@ import type {
|
|||||||
YomitanConfig,
|
YomitanConfig,
|
||||||
YoutubeConfig,
|
YoutubeConfig,
|
||||||
YoutubeMediaCacheMode,
|
YoutubeMediaCacheMode,
|
||||||
YoutubeSubgenConfig,
|
YoutubeSubtitleSource,
|
||||||
} from './integrations';
|
} from './integrations';
|
||||||
import type {
|
import type {
|
||||||
ControllerButtonIndicesConfig,
|
ControllerButtonIndicesConfig,
|
||||||
@@ -170,7 +170,6 @@ export interface Config {
|
|||||||
discordPresence?: DiscordPresenceConfig;
|
discordPresence?: DiscordPresenceConfig;
|
||||||
ai?: AiConfig;
|
ai?: AiConfig;
|
||||||
youtube?: YoutubeConfig;
|
youtube?: YoutubeConfig;
|
||||||
youtubeSubgen?: YoutubeSubgenConfig;
|
|
||||||
immersionTracking?: ImmersionTrackingConfig;
|
immersionTracking?: ImmersionTrackingConfig;
|
||||||
stats?: StatsConfig;
|
stats?: StatsConfig;
|
||||||
updates?: UpdatesConfig;
|
updates?: UpdatesConfig;
|
||||||
@@ -388,19 +387,12 @@ export interface ResolvedConfig {
|
|||||||
};
|
};
|
||||||
youtube: YoutubeConfig & {
|
youtube: YoutubeConfig & {
|
||||||
primarySubLanguages: string[];
|
primarySubLanguages: string[];
|
||||||
|
subtitleSource: YoutubeSubtitleSource;
|
||||||
mediaCache: {
|
mediaCache: {
|
||||||
mode: YoutubeMediaCacheMode;
|
mode: YoutubeMediaCacheMode;
|
||||||
maxHeight: number;
|
maxHeight: number;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
youtubeSubgen: YoutubeSubgenConfig & {
|
|
||||||
whisperBin: string;
|
|
||||||
whisperModel: string;
|
|
||||||
whisperVadModel: string;
|
|
||||||
whisperThreads: number;
|
|
||||||
fixWithAi: boolean;
|
|
||||||
ai: AiFeatureConfig;
|
|
||||||
};
|
|
||||||
immersionTracking: {
|
immersionTracking: {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
dbPath?: string;
|
dbPath?: string;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user