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

- Add `subminer youtube`/`yt` and tray "Browse YouTube" window that plays or queues picked videos in mpv
- Add `youtube.subtitleSource` (`youtube` | `whisper`) to transcribe YouTube audio via subtitle generation
- Allow the subtitle generation modal on YouTube videos
- Pick the real Japanese track over machine-translated ones and fall back to yt-dlp on HTTP 429
- Remove legacy launcher youtubeSubgen/whisper/AI args and `--youtube-mode`
- Fix EPIPE crash when the launching terminal is closed with Ctrl+C
- Thin media timing review handles with orange original-boundary bars
- Update docs and change fragments
This commit is contained in:
2026-09-28 20:46:11 -07:00
105 changed files with 2297 additions and 623 deletions
+110 -4
View File
@@ -338,6 +338,7 @@ import {
} from './core/services/youtube/generate';
import { applyOverlayClickThrough } from './core/services/overlay-click-through';
import { createYoutubeMediaCacheService } from './core/services/youtube/media-cache';
import { createYoutubeWhisperSubtitleService } from './core/services/youtube/whisper-subtitles';
import { resolveYoutubePlaybackUrl } from './core/services/youtube/playback-resolve';
import { probeYoutubeTracks } from './core/services/youtube/track-probe';
import {
@@ -494,6 +495,8 @@ import {
} from './main/runtime/media-timing-review';
import { openMediaTimingReviewModal } from './main/runtime/media-timing-review-open';
import { openYoutubeTrackPicker } from './main/runtime/youtube-picker-open';
import { createYoutubeBrowserPlaybackRuntime } from './main/runtime/youtube-browser-playback';
import { createYoutubeBrowserWindowRuntime } from './main/runtime/youtube-browser-window';
import { openRuntimeOptionsModal as openRuntimeOptionsModalRuntime } from './main/runtime/runtime-options-open';
import { openJimakuModal as openJimakuModalRuntime } from './main/runtime/jimaku-open';
import { openTsukihimeModal as openTsukihimeModalRuntime } from './main/runtime/tsukihime-open';
@@ -675,6 +678,8 @@ const JELLYFIN_MPV_CONNECT_TIMEOUT_MS = 3000;
const JELLYFIN_MPV_AUTO_LAUNCH_TIMEOUT_MS = 20000;
const YOUTUBE_MPV_CONNECT_TIMEOUT_MS = 3000;
const YOUTUBE_MPV_AUTO_LAUNCH_TIMEOUT_MS = 10000;
// mpv's ytdl hook resolves the stream before tracks appear; a cold yt-dlp run takes well over 5s.
const YOUTUBE_MPV_LOAD_TIMEOUT_MS = 20000;
const YOUTUBE_MPV_YTDL_FORMAT = 'bestvideo*+bestaudio/best';
const YOUTUBE_DIRECT_PLAYBACK_FORMAT = 'b';
const MPV_JELLYFIN_DEFAULT_ARGS = [
@@ -1054,6 +1059,13 @@ process.on('SIGTERM', () => {
requestAppQuit();
});
// Managed Whisper models live next to the config file.
const getSubtitleGenerationModelDirectory = (): string =>
path.join(path.dirname(configService.getConfigPath()), 'models', 'whisper');
const youtubeWhisperSubtitles = createYoutubeWhisperSubtitleService({
getConfig: () => configService.getConfig().subtitleGeneration,
getModelDirectory: getSubtitleGenerationModelDirectory,
});
const youtubeFlowRuntime = createYoutubeFlowRuntime({
probeYoutubeTracks: (url: string) => probeYoutubeTracks(url),
acquireYoutubeSubtitleTrack: (input) => acquireYoutubeSubtitleTrack(input),
@@ -1193,6 +1205,25 @@ const youtubeFlowRuntime = createYoutubeFlowRuntime({
fs.rmSync(dir, { recursive: true, force: true });
}
},
getSubtitleSource: () => configService.getConfig().youtube.subtitleSource,
// Runs as the subtitle generation modal's active job, so the modal shows it and can cancel it.
generateWhisperSubtitles: ({ url, outputPath, signal, onProgress }) =>
subtitleGenerationRuntime.generateYoutubeSubtitles({
url,
signal,
generate: (jobSignal, reportProgress) =>
youtubeWhisperSubtitles.generate({
url,
outputPath,
signal: jobSignal,
onProgress: (progress) => {
reportProgress(progress);
onProgress(progress);
},
}),
}),
openSubtitleGenerationModal: () =>
openSubtitleGenerationModal(createOverlayHostedModalOpenDeps()),
});
const prepareYoutubePlaybackInMpv = createPrepareYoutubePlaybackInMpvHandler({
requestPath: async () => {
@@ -1420,7 +1451,8 @@ const youtubePlaybackRuntime = createYoutubePlaybackRuntime({
);
},
waitForYoutubeMpvConnected: (timeoutMs) => waitForYoutubeMpvConnected(timeoutMs),
prepareYoutubePlaybackInMpv: (request) => prepareYoutubePlaybackInMpv(request),
prepareYoutubePlaybackInMpv: (request) =>
prepareYoutubePlaybackInMpv({ ...request, timeoutMs: YOUTUBE_MPV_LOAD_TIMEOUT_MS }),
startYoutubeMediaCache: (url) => {
const mediaCacheConfig = configService.getConfig().youtube.mediaCache;
youtubeMediaCache.start(url, {
@@ -3234,6 +3266,7 @@ const {
runJellyfinCommand,
openJellyfinSetupWindow,
getJellyfinClientInfo,
ensureMpvConnectedForPlayback,
} = composeJellyfinRuntimeHandlers({
getResolvedJellyfinConfigMainDeps: {
getResolvedConfig: () => configService.getConfig(),
@@ -3511,6 +3544,52 @@ const {
},
});
let quitWhenMpvClosesAfterYoutubeBrowser = false;
const isMpvPlayingMedia = (): boolean =>
Boolean(appState.mpvClient?.connected && appState.currentMediaPath?.trim());
const youtubeBrowserPlaybackRuntime = createYoutubeBrowserPlaybackRuntime({
isMpvPlaying: isMpvPlayingMedia,
// Windows playback bootstraps its own mpv inside the playback flow.
ensureMpvReady: async () =>
process.platform === 'win32' ? true : await ensureMpvConnectedForPlayback(),
runPlaybackFlow: (url) =>
youtubePlaybackRuntime.runYoutubePlaybackFlow({ url, source: 'youtube-browser' }),
appendToMpvPlaylist: (url) => {
sendMpvCommandRuntime(appState.mpvClient, ['loadfile', url, 'append']);
},
notifyFailure: (message) =>
overlayNotificationsRuntime.showConfiguredStatusNotification(message, { title: 'YouTube' }),
logWarn: (message) => logger.warn(message),
onStartupFailed: () => quitAfterYoutubeBrowserIfIdle(),
});
const youtubeBrowserWindowRuntime = createYoutubeBrowserWindowRuntime({
createBrowserWindow: (options) => new BrowserWindow(options),
preloadPath: path.join(__dirname, 'preload-youtube-browser.js'),
ipcMain,
showContextMenu: (window, template) => {
Menu.buildFromTemplate(template).popup({ window });
},
openExternal: (url) => {
void shell.openExternal(url);
},
openVideo: (request) => youtubeBrowserPlaybackRuntime.openVideo(request),
logWarn: (message, error) => logger.warn(message, error),
logDebug: (message) => logger.debug(message),
onClosed: () => {
if (!appState.initialArgs?.youtubeBrowser) return;
quitWhenMpvClosesAfterYoutubeBrowser = true;
quitAfterYoutubeBrowserIfIdle();
},
});
// A process started just for the browser (`--youtube-browser`) exits once the window is closed,
// but keeps the overlay alive until mpv closes if a video is playing or still starting. Rechecked
// when a start fails, so a failed start after the window closed does not leave the app running.
const quitAfterYoutubeBrowserIfIdle = (): void => {
if (!quitWhenMpvClosesAfterYoutubeBrowser || youtubeBrowserWindowRuntime.isOpen()) return;
if (isMpvPlayingMedia() || youtubeBrowserPlaybackRuntime.isStartingPlayback()) return;
requestAppQuit();
};
const maybeFocusExistingFirstRunSetupWindow = createMaybeFocusExistingFirstRunSetupWindowHandler({
getSetupWindow: () => appState.firstRunSetupWindow,
});
@@ -4172,7 +4251,12 @@ const {
},
stopJellyfinRemoteSession: () => stopJellyfinRemoteSession(),
cleanupInternalSubtitleTrackCache: () => cachedInternalSubtitleTrackExtractor.clear(),
cleanupYoutubeSubtitleTempDirs: () => youtubeFlowRuntime.cleanupSubtitleTempDirs(),
cleanupYoutubeSubtitleTempDirs: () => {
// Stops yt-dlp/whisper-cli and deletes the audio they were working on.
youtubeFlowRuntime.cancelWhisperGeneration();
youtubeWhisperSubtitles.removeActiveAudio();
youtubeFlowRuntime.cleanupSubtitleTempDirs();
},
cleanupYoutubeMediaCache: () => youtubeMediaCache.cleanup(),
cleanupRemoteMediaWindows: () => getSharedRemoteMediaWindowCache().cleanup(),
cleanupJellyfinSubtitleCache: () => cleanupJellyfinSubtitleCache(),
@@ -4775,6 +4859,9 @@ const {
}
youtubePrimarySubtitleNotificationRuntime.handleMediaPathChange(path);
void youtubeMediaCachePlaybackRuntime.handleMediaPathChange(path);
youtubeBrowserPlaybackRuntime.handleMediaPathChange(path);
youtubeFlowRuntime.handleMediaPathChange(path);
subtitleGenerationRuntime.handleMediaPathChange(path);
if (path) {
ensureImmersionTrackerStarted();
secondarySubtitleTrackController.scheduleRefresh();
@@ -5125,6 +5212,13 @@ function createMpvClientRuntimeService(): MpvIpcClient {
if (connected) {
return;
}
youtubeBrowserPlaybackRuntime.handleMpvDisconnected();
youtubeFlowRuntime.cancelWhisperGeneration();
subtitleGenerationRuntime.handleMediaPathChange(null);
if (quitWhenMpvClosesAfterYoutubeBrowser && !youtubeBrowserWindowRuntime.isOpen()) {
requestAppQuit();
return;
}
if (!youtubeFlowRuntime.hasActiveSession()) {
return;
}
@@ -6339,6 +6433,7 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({
openYomitanSettings: () => openYomitanSettings(),
openConfigSettingsWindow: () => configSettingsRuntime.openWindow(),
openSyncUiWindow: () => openSyncUiWindowHandler(),
openYoutubeBrowserWindow: () => youtubeBrowserWindowRuntime.open(),
cycleSecondarySubMode: () => cycleSecondarySubMode(),
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT),
@@ -6605,6 +6700,7 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } =
openYomitanSettings: () => openYomitanSettings(),
openConfigSettingsWindow: () => configSettingsRuntime.openWindow(),
openSyncUiWindow: () => openSyncUiWindowHandler(),
openYoutubeBrowserWindow: () => youtubeBrowserWindowRuntime.open(),
exportLogs: () => {
void exportLogsFromTray();
},
@@ -6889,8 +6985,18 @@ registerSubtitleSelectionIpc({
});
const subtitleGenerationRuntime = createSubtitleGenerationRuntime({
getConfig: () => configService.getConfig().subtitleGeneration,
getModelDirectory: () =>
path.join(path.dirname(configService.getConfigPath()), 'models', 'whisper'),
getModelDirectory: getSubtitleGenerationModelDirectory,
generateYoutube: async ({ url, config, signal, onProgress }) =>
youtubeWhisperSubtitles.generate({
url,
config,
signal,
onProgress,
outputPath: path.join(
await youtubeFlowRuntime.createSubtitleTempDir(),
'youtube-whisper.ja.srt',
),
}),
getMpvClient: () => appState.mpvClient,
onProgress: (progress) => {
for (const window of [overlayManager.getMainWindow(), overlayManager.getModalWindow()]) {