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

This commit is contained in:
sudacode authored and GitHub committed 2026-09-28 20:35:47 -07:00
1 parent c68b73c8c1
commit 07b32626a6
101 files changed
+2269 -726

No files matched your search

+1
View File
@@ -17,6 +17,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
yomitan: false,
settings: false,
syncWindow: false,
youtubeBrowser: false,
setup: false,
show: false,
hide: false,
+17 -31
View File
@@ -15,13 +15,13 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
launchMpv: false,
launchMpvTargets: [],
youtubePlay: undefined,
youtubeMode: undefined,
stop: false,
toggle: false,
toggleVisibleOverlay: false,
yomitan: false,
settings: false,
syncWindow: false,
youtubeBrowser: false,
setup: false,
show: false,
hide: false,
@@ -142,6 +142,9 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
openSyncUiWindow: () => {
calls.push('openSyncUiWindow');
},
openYoutubeBrowserWindow: () => {
calls.push('openYoutubeBrowserWindow');
},
openFirstRunSetup: (force?: boolean) => {
calls.push(`openFirstRunSetup:${force === true ? 'force' : 'default'}`);
},
@@ -243,7 +246,7 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
calls.push('runJellyfinCommand');
},
runYoutubePlaybackFlow: async (request) => {
calls.push(`runYoutubePlaybackFlow:${request.url}:${request.mode}:${request.source}`);
calls.push(`runYoutubePlaybackFlow:${request.url}:${request.source}`);
},
runUpdateCommand: async (args) => {
calls.push(`runUpdateCommand:${args.updateLauncherPath ?? ''}`);
@@ -283,46 +286,30 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
test('handleCliCommand starts youtube playback flow on initial launch', () => {
const { deps, calls } = createDeps({
runYoutubePlaybackFlow: async (request) => {
calls.push(`youtube:${request.url}:${request.mode}`);
},
});
handleCliCommand(
makeArgs({ youtubePlay: 'https://youtube.com/watch?v=abc', youtubeMode: 'generate' }),
'initial',
deps,
);
assert.deepEqual(calls, ['youtube:https://youtube.com/watch?v=abc:generate']);
});
test('handleCliCommand defaults youtube mode to download when omitted', () => {
const { deps, calls } = createDeps({
runYoutubePlaybackFlow: async (request) => {
calls.push(`youtube:${request.url}:${request.mode}`);
calls.push(`youtube:${request.url}:${request.source}`);
},
});
handleCliCommand(makeArgs({ youtubePlay: 'https://youtube.com/watch?v=abc' }), 'initial', deps);
assert.deepEqual(calls, ['youtube:https://youtube.com/watch?v=abc:download']);
assert.deepEqual(calls, ['youtube:https://youtube.com/watch?v=abc:initial']);
});
test('handleCliCommand reuses initialized overlay runtime for second-instance youtube playback', () => {
const { deps, calls } = createDeps({
isOverlayRuntimeInitialized: () => true,
runYoutubePlaybackFlow: async (request) => {
calls.push(`youtube:${request.url}:${request.mode}:${request.source}`);
calls.push(`youtube:${request.url}:${request.source}`);
},
});
handleCliCommand(
makeArgs({ youtubePlay: 'https://youtube.com/watch?v=abc', youtubeMode: 'download' }),
makeArgs({ youtubePlay: 'https://youtube.com/watch?v=abc' }),
'second-instance',
deps,
);
assert.deepEqual(calls, ['youtube:https://youtube.com/watch?v=abc:download:second-instance']);
assert.deepEqual(calls, ['youtube:https://youtube.com/watch?v=abc:second-instance']);
});
test('handleCliCommand reports youtube playback flow failures to logs and OSD', async () => {
@@ -332,11 +319,7 @@ test('handleCliCommand reports youtube playback flow failures to logs and OSD',
},
});
handleCliCommand(
makeArgs({ youtubePlay: 'https://youtube.com/watch?v=abc', youtubeMode: 'download' }),
'initial',
deps,
);
handleCliCommand(makeArgs({ youtubePlay: 'https://youtube.com/watch?v=abc' }), 'initial', deps);
await new Promise((resolve) => setImmediate(resolve));
assert.ok(calls.some((value) => value.startsWith('error:runYoutubePlaybackFlow failed:')));
@@ -676,6 +659,7 @@ test('createCliCommandDepsRuntime reconnects MPV client when reconnect hook exis
openYomitanSettings: () => {},
openConfigSettingsWindow: () => {},
openSyncUiWindow: () => {},
openYoutubeBrowserWindow: () => {},
cycleSecondarySubMode: () => {},
openRuntimeOptionsPalette: () => {},
printHelp: () => {},
@@ -789,6 +773,7 @@ test('handleCliCommand handles visibility and utility command dispatches', () =>
}> = [
{ args: { yomitan: true }, expected: 'openYomitanSettingsDelayed:1000' },
{ args: { settings: true }, expected: 'openConfigSettingsWindow' },
{ args: { youtubeBrowser: true }, expected: 'openYoutubeBrowserWindow' },
{
args: { showVisibleOverlay: true },
expected: 'setVisibleOverlayVisible:true',
@@ -822,9 +807,10 @@ test('handleCliCommand handles visibility and utility command dispatches', () =>
for (const entry of cases) {
const { deps, calls } = createDeps();
handleCliCommand(makeArgs(entry.args), 'initial', deps);
assert.ok(
calls.includes(entry.expected),
`expected call missing for args ${JSON.stringify(entry.args)}: ${entry.expected}`,
assert.equal(
calls.filter((value) => value === entry.expected).length,
1,
`expected exactly one call for args ${JSON.stringify(entry.args)}: ${entry.expected}`,
);
}
});
+6 -6
View File
@@ -45,6 +45,7 @@ export interface CliCommandServiceDeps {
openYomitanSettingsDelayed: (delayMs: number) => void;
openConfigSettingsWindow: () => void;
openSyncUiWindow: () => void;
openYoutubeBrowserWindow: () => void;
setVisibleOverlayVisible: (visible: boolean) => void;
copyCurrentSubtitle: () => void;
startPendingMultiCopy: (timeoutMs: number) => void;
@@ -102,11 +103,7 @@ export interface CliCommandServiceDeps {
args: CliArgs,
source: CliCommandSource,
) => Promise<void>;
runYoutubePlaybackFlow: (request: {
url: string;
mode: NonNullable<CliArgs['youtubeMode']>;
source: CliCommandSource;
}) => Promise<void>;
runYoutubePlaybackFlow: (request: { url: string; source: CliCommandSource }) => Promise<void>;
ensureBackgroundStatsServer?: () => Promise<void> | void;
printHelp: () => void;
hasMainWindow: () => boolean;
@@ -172,6 +169,7 @@ interface UiCliRuntime {
openYomitanSettings: () => void;
openConfigSettingsWindow: () => void;
openSyncUiWindow: () => void;
openYoutubeBrowserWindow: () => void;
cycleSecondarySubMode: () => void;
openRuntimeOptionsPalette: () => void;
printHelp: () => void;
@@ -277,6 +275,7 @@ export function createCliCommandDepsRuntime(
},
openConfigSettingsWindow: options.ui.openConfigSettingsWindow,
openSyncUiWindow: options.ui.openSyncUiWindow,
openYoutubeBrowserWindow: options.ui.openYoutubeBrowserWindow,
setVisibleOverlayVisible: options.overlay.setVisible,
copyCurrentSubtitle: options.mining.copyCurrentSubtitle,
startPendingMultiCopy: options.mining.startPendingMultiCopy,
@@ -429,6 +428,8 @@ export function handleCliCommand(
deps.openConfigSettingsWindow();
} else if (args.syncWindow) {
deps.openSyncUiWindow();
} else if (args.youtubeBrowser) {
deps.openYoutubeBrowserWindow();
} else if (args.show || args.showVisibleOverlay) {
deps.setVisibleOverlayVisible(true);
} else if (args.hide || args.hideVisibleOverlay) {
@@ -639,7 +640,6 @@ export function handleCliCommand(
() =>
deps.runYoutubePlaybackFlow({
url: youtubeUrl,
mode: args.youtubeMode ?? 'download',
source,
}),
deps,
@@ -17,6 +17,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
yomitan: false,
settings: false,
syncWindow: false,
youtubeBrowser: false,
setup: false,
show: false,
hide: false,
@@ -9,6 +9,8 @@ export function runSubtitleGenerationProcess(input: {
args: string[];
signal?: AbortSignal;
onLine?: (line: string) => void;
/** Shown when the executable is missing; defaults to pointing at subtitleGeneration settings. */
missingMessage?: string;
}): Promise<string> {
input.signal?.throwIfAborted();
return new Promise((resolve, reject) => {
@@ -51,7 +53,8 @@ export function runSubtitleGenerationProcess(input: {
reject(
new Error(
'code' in error && error.code === 'ENOENT'
? `${input.command} was not found. Install it or set its path under subtitleGeneration in Settings.`
? (input.missingMessage ??
`${input.command} was not found. Install it or set its path under subtitleGeneration in Settings.`)
: `Could not run ${input.command}: ${error.message}`,
),
);
+26 -10
View File
@@ -173,6 +173,21 @@ async function writeSubtitles(input: {
}
}
/** Resolve the model and tools, throwing the message to show when generation cannot run. */
export async function requireSubtitleGenerationSetup(
config: SubtitleGenerationConfig,
modelDirectory: string,
) {
const model = await resolveSubtitleGenerationModel(config, modelDirectory);
if (model.kind === 'missing')
throw new Error(
'No Whisper model found. Download a model or configure an existing model path.',
);
if (model.kind === 'invalid') throw new Error(model.message);
const tools = requireSubtitleGenerationTools(await resolveSubtitleGenerationTools(config));
return { modelPath: model.path, tools };
}
export async function generateJapaneseSubtitles(input: {
config: SubtitleGenerationConfig;
modelDirectory: string;
@@ -180,6 +195,8 @@ export async function generateJapaneseSubtitles(input: {
audioStreamIndex?: number;
references?: readonly SubtitleGenerationReference[];
outputPath?: string;
/** Parent for the scratch directory holding the extracted WAV; the system temp dir by default. */
workDirectory?: string;
onProgress?: (progress: SubtitleGenerationProgress) => void;
signal?: AbortSignal;
}): Promise<string> {
@@ -193,13 +210,10 @@ export async function generateJapaneseSubtitles(input: {
await ensureWritableDirectory(
input.outputPath ? path.dirname(path.resolve(input.outputPath)) : path.dirname(mediaPath),
);
const model = await resolveSubtitleGenerationModel(input.config, input.modelDirectory);
if (model.kind === 'missing')
throw new Error(
'No Whisper model found. Download a model or configure an existing model path.',
);
if (model.kind === 'invalid') throw new Error(model.message);
const tools = requireSubtitleGenerationTools(await resolveSubtitleGenerationTools(input.config));
const { modelPath, tools } = await requireSubtitleGenerationSetup(
input.config,
input.modelDirectory,
);
input.onProgress?.({ stage: 'extract', message: 'Inspecting audio tracks...' });
const probe = await runSubtitleGenerationProcess({
command: tools.ffprobe,
@@ -215,7 +229,9 @@ export async function generateJapaneseSubtitles(input: {
signal: input.signal,
});
const audio = parseAudioProbe(probe, input.audioStreamIndex);
const temporaryDirectory = await mkdtemp(path.join(tmpdir(), 'subminer-whisper-'));
const temporaryDirectory = await mkdtemp(
path.join(input.workDirectory ?? tmpdir(), 'subminer-whisper-'),
);
try {
const wavPath = path.join(temporaryDirectory, 'audio.wav');
const subtitleBase = path.join(temporaryDirectory, 'subtitles');
@@ -277,7 +293,7 @@ export async function generateJapaneseSubtitles(input: {
config: input.config,
tools,
referenceStarts,
modelPath: model.path,
modelPath,
wavPath,
directory: temporaryDirectory,
signal: input.signal,
@@ -288,7 +304,7 @@ export async function generateJapaneseSubtitles(input: {
command: tools.whisper,
args: [
'-m',
model.path,
modelPath,
'-f',
wavPath,
'-l',
@@ -478,6 +478,35 @@ test('downloadYoutubeSubtitleTrack prefers direct download URL when available',
});
});
test('downloadYoutubeSubtitleTrack falls back to yt-dlp when the direct URL is rejected', async () => {
if (process.platform === 'win32') {
return;
}
await withFakeYtDlp('both', async (root) => {
await withStubFetch(
async () => new Response('Too Many Requests', { status: 429 }),
async () => {
const result = await downloadYoutubeSubtitleTrack({
targetUrl: 'https://www.youtube.com/watch?v=abc123',
outputDir: path.join(root, 'out'),
track: {
id: 'auto:ja',
language: 'ja',
sourceLanguage: 'ja',
kind: 'auto',
label: 'Japanese (auto)',
downloadUrl: 'https://www.youtube.com/api/timedtext?v=abc123&lang=ja&fmt=vtt',
fileExtension: 'vtt',
},
});
assert.equal(path.extname(result.path), '.vtt');
},
);
});
});
test('downloadYoutubeSubtitleTrack sanitizes metadata source language in filenames', async () => {
await withTempDir(async (root) => {
await withStubFetch(
+10 -5
View File
@@ -221,11 +221,16 @@ export async function downloadYoutubeSubtitleTrack(input: {
}
}
if (canDownloadSubtitleFromUrl(input.track)) {
return await downloadSubtitleFromUrl({
outputDir: input.outputDir,
prefix,
track: input.track,
});
try {
return await downloadSubtitleFromUrl({
outputDir: input.outputDir,
prefix,
track: input.track,
});
} catch {
// YouTube rejects some direct timedtext requests (HTTP 429) that yt-dlp still gets
// through with its own client handling, so retry the same track via yt-dlp.
}
}
const outputTemplate = path.join(input.outputDir, `${prefix}.%(ext)s`);
const args = [
@@ -116,6 +116,31 @@ test('probeYoutubeTracks prefers srv3 over vtt for automatic captions', async ()
);
});
test('probeYoutubeTracks skips machine-translated caption URLs when the real track exists', async () => {
const translated = 'https://www.youtube.com/api/timedtext?v=abc123&lang=en-US&tlang=ja&fmt=srv3';
const original = 'https://www.youtube.com/api/timedtext?v=abc123&lang=ja&fmt=srv3';
await withFakeYtDlp(
{
id: 'abc123',
title: 'Example',
automatic_captions: {
ja: [
{ ext: 'srv3', url: translated, name: 'Japanese' },
{ ext: 'srv3', url: original, name: 'Japanese' },
],
fr: [{ ext: 'srv3', url: `${translated}&x=fr`, name: 'French' }],
},
},
async () => {
const result = await probeYoutubeTracks('https://www.youtube.com/watch?v=abc123');
const byLanguage = new Map(result.tracks.map((track) => [track.sourceLanguage, track]));
assert.equal(byLanguage.get('ja')?.downloadUrl, original);
// A translation-only language still gets a track rather than disappearing.
assert.equal(byLanguage.get('fr')?.downloadUrl, `${translated}&x=fr`);
},
);
});
test('probeYoutubeTracks honors SUBMINER_YTDLP_BIN when yt-dlp is not on PATH', async () => {
if (process.platform === 'win32') {
return;
+16 -1
View File
@@ -56,10 +56,25 @@ function runCapture(
});
}
// YouTube machine-translates captions on request (`tlang`) and lists them under the target
// language, next to the real track. Those URLs reliably fail with HTTP 429, so they are only
// used when a language has nothing else.
function isTranslatedCaptionUrl(url: string): boolean {
try {
return new URL(url).searchParams.has('tlang');
} catch {
return false;
}
}
function choosePreferredFormat(
formats: YtDlpSubtitleEntry,
allFormats: YtDlpSubtitleEntry,
kind: YoutubeTrackKind,
): { ext: string; url: string; title?: string } | null {
const untranslated = allFormats.filter(
(format) => typeof format.url === 'string' && !isTranslatedCaptionUrl(format.url),
);
const formats = untranslated.length > 0 ? untranslated : allFormats;
const preferredOrder =
kind === 'auto'
? ['srv3', 'srv2', 'srv1', 'vtt', 'srt', 'ttml', 'json3']
@@ -0,0 +1,168 @@
import assert from 'node:assert/strict';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import test from 'node:test';
import { DEFAULT_SUBTITLE_GENERATION_CONFIG } from '../../../shared/subtitle-generation';
import {
buildYoutubeWhisperAudioArgs,
createYoutubeWhisperSubtitleService,
parseYtDlpDownloadPercent,
YOUTUBE_WHISPER_AUDIO_DIR_PREFIX,
} from './whisper-subtitles';
const URL = 'https://www.youtube.com/watch?v=abcdefghijk';
function makeTempRoot(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-youtube-whisper-test-'));
}
function audioDirs(root: string): string[] {
return fs.readdirSync(root).filter((name) => name.startsWith(YOUTUBE_WHISPER_AUDIO_DIR_PREFIX));
}
/** Stands in for yt-dlp: writes the file its `-o` template names and reports progress. */
function fakeYtDlp(calls: string[][]) {
return async (input: { args: string[]; onLine?: (line: string) => void }) => {
calls.push(input.args);
const template = input.args[input.args.indexOf('-o') + 1]!;
fs.writeFileSync(template.replace('%(ext)s', 'm4a'), 'audio');
input.onLine?.('[download] 50.0% of 3.89MiB at 1.00MiB/s ETA 00:02');
return '';
};
}
function createService(root: string, overrides: Record<string, unknown> = {}) {
return createYoutubeWhisperSubtitleService({
getConfig: () => DEFAULT_SUBTITLE_GENERATION_CONFIG,
getModelDirectory: () => '/models',
tempRoot: root,
getYtDlpCommand: () => 'yt-dlp',
requireSetup: async () => ({
modelPath: '/models/ggml-small.bin',
tools: { ffmpeg: 'ffmpeg', ffprobe: 'ffprobe', whisper: 'whisper-cli', vad: null },
}),
...overrides,
});
}
test('audio download picks a small original-language stream and ignores user embeds', () => {
const args = buildYoutubeWhisperAudioArgs(URL, '/tmp/audio.%(ext)s');
assert.deepEqual(args.slice(args.indexOf('-f'), args.indexOf('-f') + 4), [
'-f',
'ba[abr>=48]/ba',
'-S',
'lang,+abr',
]);
for (const flag of ['--no-playlist', '--no-sponsorblock', '--no-embed-subs']) {
assert.ok(args.includes(flag), flag);
}
assert.equal(args.at(-1), URL);
});
test('parseYtDlpDownloadPercent reads yt-dlp progress lines only', () => {
assert.equal(parseYtDlpDownloadPercent('[download] 42.5% of 3.89MiB'), 42.5);
assert.equal(parseYtDlpDownloadPercent('[download] Destination: audio.m4a'), null);
});
test('generate transcribes the downloaded audio and deletes it afterwards', async () => {
const root = makeTempRoot();
const calls: string[][] = [];
const progress: string[] = [];
let transcribedPath = '';
const service = createService(root, {
runProcess: fakeYtDlp(calls),
generate: async (input: { mediaPath: string; outputPath: string; workDirectory?: string }) => {
transcribedPath = input.mediaPath;
assert.equal(fs.readFileSync(input.mediaPath, 'utf8'), 'audio');
// The extracted WAV must land where cleanup already looks.
assert.equal(input.workDirectory, path.dirname(input.mediaPath));
return input.outputPath;
},
});
const result = await service.generate({
url: URL,
outputPath: '/subs/youtube-whisper.ja.srt',
onProgress: (update) => progress.push(`${update.stage}:${update.percent}`),
});
assert.equal(result, '/subs/youtube-whisper.ja.srt');
assert.equal(calls.length, 1);
assert.equal(path.basename(transcribedPath), 'audio.m4a');
assert.deepEqual(progress, ['download:0', 'download:50']);
assert.deepEqual(audioDirs(root), []);
});
test('generate deletes the audio when transcription fails', async () => {
const root = makeTempRoot();
const service = createService(root, {
runProcess: fakeYtDlp([]),
generate: async () => {
throw new Error('whisper crashed');
},
});
await assert.rejects(
service.generate({ url: URL, outputPath: '/subs/x.srt' }),
/whisper crashed/,
);
assert.deepEqual(audioDirs(root), []);
});
test('generate skips the download when Whisper is not set up', async () => {
const root = makeTempRoot();
const calls: string[][] = [];
const service = createService(root, {
runProcess: fakeYtDlp(calls),
requireSetup: async () => {
throw new Error('No Whisper model found.');
},
});
await assert.rejects(
service.generate({ url: URL, outputPath: '/subs/x.srt' }),
/No Whisper model/,
);
assert.equal(calls.length, 0);
assert.deepEqual(audioDirs(root), []);
});
test('leftover audio from a crashed run is removed before the next download', async () => {
const root = makeTempRoot();
fs.mkdirSync(path.join(root, `${YOUTUBE_WHISPER_AUDIO_DIR_PREFIX}stale`));
let dirsDuringDownload: string[] = [];
const service = createService(root, {
runProcess: async (input: { args: string[] }) => {
dirsDuringDownload = audioDirs(root);
return fakeYtDlp([])(input);
},
generate: async (input: { outputPath: string }) => input.outputPath,
});
await service.generate({ url: URL, outputPath: '/subs/x.srt' });
assert.equal(dirsDuringDownload.length, 1);
assert.notEqual(dirsDuringDownload[0], `${YOUTUBE_WHISPER_AUDIO_DIR_PREFIX}stale`);
});
test('removeActiveAudio deletes audio for a job that is still running', async () => {
const root = makeTempRoot();
let release: (() => void) | null = null;
const service = createService(root, {
runProcess: fakeYtDlp([]),
generate: async (input: { outputPath: string }) => {
await new Promise<void>((resolve) => {
release = resolve;
});
return input.outputPath;
},
});
const job = service.generate({ url: URL, outputPath: '/subs/x.srt' });
while (!release) await new Promise((resolve) => setImmediate(resolve));
assert.equal(audioDirs(root).length, 1);
service.removeActiveAudio();
assert.deepEqual(audioDirs(root), []);
(release as () => void)();
await job;
});
@@ -0,0 +1,148 @@
import { mkdtemp, readdir, rm } from 'node:fs/promises';
import { rmSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import type {
SubtitleGenerationConfig,
SubtitleGenerationProgress,
} from '../../../shared/subtitle-generation';
import { generateJapaneseSubtitles, requireSubtitleGenerationSetup } from '../subtitle-generation';
import { runSubtitleGenerationProcess } from '../subtitle-generation-process';
import { getYoutubeYtDlpCommand, YTDLP_SINGLE_VIDEO_ARG } from './ytdlp-command';
export const YOUTUBE_WHISPER_AUDIO_DIR_PREFIX = 'subminer-youtube-audio-';
/**
* Whisper resamples everything to 16 kHz mono, so bitrate beyond plain speech clarity only costs
* download time. Take the smallest audio-only stream of at least 48 kbps, falling back to the
* smallest one. Sorting on `lang` first keeps the original track ahead of auto-dubbed ones.
*/
const AUDIO_FORMAT = 'ba[abr>=48]/ba';
const AUDIO_SORT = 'lang,+abr';
export function buildYoutubeWhisperAudioArgs(url: string, outputTemplate: string): string[] {
return [
YTDLP_SINGLE_VIDEO_ARG,
'--no-warnings',
'--newline',
// Override user yt-dlp config that would change the audio or add requests: SponsorBlock
// removal shifts every subtitle after a cut, and subtitle embedding hits the timedtext API.
'--no-sponsorblock',
'--no-write-subs',
'--no-write-auto-subs',
'--no-embed-subs',
'--no-write-thumbnail',
'--no-embed-thumbnail',
'--no-embed-metadata',
'--no-embed-chapters',
'-f',
AUDIO_FORMAT,
'-S',
AUDIO_SORT,
'-o',
outputTemplate,
url,
];
}
export function parseYtDlpDownloadPercent(line: string): number | null {
const match = /^\[download\]\s+(\d+(?:\.\d+)?)%/.exec(line.trim());
return match ? Math.min(100, Number(match[1])) : null;
}
async function findDownloadedAudio(directory: string): Promise<string> {
const names = await readdir(directory);
const audio = names.find((name) => name.startsWith('audio.') && !/\.(part|ytdl)$/.test(name));
if (!audio) throw new Error('yt-dlp finished without saving the audio.');
return path.join(directory, audio);
}
export type YoutubeWhisperSubtitleServiceDeps = {
getConfig: () => SubtitleGenerationConfig;
getModelDirectory: () => string;
tempRoot?: string;
getYtDlpCommand?: () => string;
requireSetup?: typeof requireSubtitleGenerationSetup;
runProcess?: typeof runSubtitleGenerationProcess;
generate?: typeof generateJapaneseSubtitles;
};
/**
* Generates Japanese subtitles for a YouTube video by transcribing its audio with Whisper.
* The audio lives in a private temp directory that is removed as soon as generation ends,
* whether it succeeds, fails, or is aborted.
*/
export function createYoutubeWhisperSubtitleService(deps: YoutubeWhisperSubtitleServiceDeps) {
const tempRoot = deps.tempRoot ?? os.tmpdir();
const activeDirs = new Set<string>();
// Audio directories only outlive a job when the app crashes mid-generation.
const removeStaleAudioDirs = async (): Promise<void> => {
const names = await readdir(tempRoot).catch(() => []);
await Promise.all(
names
.filter((name) => name.startsWith(YOUTUBE_WHISPER_AUDIO_DIR_PREFIX))
.map((name) => path.join(tempRoot, name))
.filter((dir) => !activeDirs.has(dir))
.map((dir) => rm(dir, { recursive: true, force: true }).catch(() => undefined)),
);
};
async function generate(input: {
url: string;
outputPath: string;
/** Overrides the configured settings, e.g. the modal's per-session model choice. */
config?: SubtitleGenerationConfig;
signal?: AbortSignal;
onProgress?: (progress: SubtitleGenerationProgress) => void;
}): Promise<string> {
input.signal?.throwIfAborted();
const config = input.config ?? deps.getConfig();
// Fail before spending time on the download when Whisper cannot run.
await (deps.requireSetup ?? requireSubtitleGenerationSetup)(config, deps.getModelDirectory());
await removeStaleAudioDirs();
const directory = await mkdtemp(path.join(tempRoot, YOUTUBE_WHISPER_AUDIO_DIR_PREFIX));
activeDirs.add(directory);
try {
input.onProgress?.({ stage: 'download', percent: 0, message: 'Downloading audio...' });
await (deps.runProcess ?? runSubtitleGenerationProcess)({
command: (deps.getYtDlpCommand ?? getYoutubeYtDlpCommand)(),
args: buildYoutubeWhisperAudioArgs(input.url, path.join(directory, 'audio.%(ext)s')),
signal: input.signal,
missingMessage: 'yt-dlp was not found. Install it or set SUBMINER_YTDLP_BIN.',
onLine: (line) => {
const percent = parseYtDlpDownloadPercent(line);
if (percent !== null)
input.onProgress?.({ stage: 'download', percent, message: 'Downloading audio...' });
},
});
return await (deps.generate ?? generateJapaneseSubtitles)({
config,
modelDirectory: deps.getModelDirectory(),
mediaPath: await findDownloadedAudio(directory),
outputPath: input.outputPath,
// Keep the extracted WAV inside the managed directory so every cleanup path removes it.
workDirectory: directory,
onProgress: input.onProgress,
signal: input.signal,
});
} finally {
await rm(directory, { recursive: true, force: true }).catch(() => undefined);
activeDirs.delete(directory);
}
}
/** Synchronous so it still runs while the app is quitting. */
function removeActiveAudio(): void {
for (const directory of activeDirs) {
try {
rmSync(directory, { recursive: true, force: true });
} catch {
// Leftovers are swept before the next download.
}
}
activeDirs.clear();
}
return { generate, removeActiveAudio };
}