mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-10-22 05:01:24 -07:00
feat(youtube): add YouTube browser window and Whisper subtitle source (#273)
This commit is contained in:
1 parent
c68b73c8c1
commit
07b32626a6
101 files changed
+2269
-726
No files matched your search
@@ -17,6 +17,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
yomitan: false,
|
||||
settings: false,
|
||||
syncWindow: false,
|
||||
youtubeBrowser: false,
|
||||
setup: false,
|
||||
show: false,
|
||||
hide: false,
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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}`,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user