mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-30 17:40:53 -07:00
feat(youtube): add YouTube browser window and Whisper subtitle source (#273)
This commit is contained in:
@@ -51,6 +51,7 @@ export interface CliCommandRuntimeServiceContext {
|
||||
openYomitanSettings: () => void;
|
||||
openConfigSettingsWindow: () => void;
|
||||
openSyncUiWindow: () => void;
|
||||
openYoutubeBrowserWindow: () => void;
|
||||
cycleSecondarySubMode: () => void;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
printHelp: () => void;
|
||||
@@ -137,6 +138,7 @@ function createCliCommandDepsFromContext(
|
||||
openYomitanSettings: context.openYomitanSettings,
|
||||
openConfigSettingsWindow: context.openConfigSettingsWindow,
|
||||
openSyncUiWindow: context.openSyncUiWindow,
|
||||
openYoutubeBrowserWindow: context.openYoutubeBrowserWindow,
|
||||
cycleSecondarySubMode: context.cycleSecondarySubMode,
|
||||
openRuntimeOptionsPalette: context.openRuntimeOptionsPalette,
|
||||
printHelp: context.printHelp,
|
||||
|
||||
@@ -219,6 +219,7 @@ export interface CliCommandRuntimeServiceDepsParams {
|
||||
openYomitanSettings: CliCommandDepsRuntimeOptions['ui']['openYomitanSettings'];
|
||||
openConfigSettingsWindow: CliCommandDepsRuntimeOptions['ui']['openConfigSettingsWindow'];
|
||||
openSyncUiWindow: CliCommandDepsRuntimeOptions['ui']['openSyncUiWindow'];
|
||||
openYoutubeBrowserWindow: CliCommandDepsRuntimeOptions['ui']['openYoutubeBrowserWindow'];
|
||||
cycleSecondarySubMode: CliCommandDepsRuntimeOptions['ui']['cycleSecondarySubMode'];
|
||||
openRuntimeOptionsPalette: CliCommandDepsRuntimeOptions['ui']['openRuntimeOptionsPalette'];
|
||||
printHelp: CliCommandDepsRuntimeOptions['ui']['printHelp'];
|
||||
@@ -432,6 +433,7 @@ export function createCliCommandRuntimeServiceDeps(
|
||||
openYomitanSettings: params.ui.openYomitanSettings,
|
||||
openConfigSettingsWindow: params.ui.openConfigSettingsWindow,
|
||||
openSyncUiWindow: params.ui.openSyncUiWindow,
|
||||
openYoutubeBrowserWindow: params.ui.openYoutubeBrowserWindow,
|
||||
cycleSecondarySubMode: params.ui.cycleSecondarySubMode,
|
||||
openRuntimeOptionsPalette: params.ui.openRuntimeOptionsPalette,
|
||||
printHelp: params.ui.printHelp,
|
||||
|
||||
@@ -75,6 +75,7 @@ test('build cli command context deps maps handlers and values', () => {
|
||||
openYomitanSettings: () => calls.push('yomitan'),
|
||||
openConfigSettingsWindow: () => calls.push('config-settings'),
|
||||
openSyncUiWindow: () => {},
|
||||
openYoutubeBrowserWindow: () => {},
|
||||
cycleSecondarySubMode: () => calls.push('cycle-secondary'),
|
||||
openRuntimeOptionsPalette: () => calls.push('runtime-options'),
|
||||
printHelp: () => calls.push('help'),
|
||||
|
||||
@@ -49,6 +49,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
|
||||
openYomitanSettings: () => void;
|
||||
openConfigSettingsWindow: () => void;
|
||||
openSyncUiWindow: () => void;
|
||||
openYoutubeBrowserWindow: () => void;
|
||||
cycleSecondarySubMode: () => void;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
printHelp: () => void;
|
||||
@@ -109,6 +110,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
|
||||
openYomitanSettings: deps.openYomitanSettings,
|
||||
openConfigSettingsWindow: deps.openConfigSettingsWindow,
|
||||
openSyncUiWindow: deps.openSyncUiWindow,
|
||||
openYoutubeBrowserWindow: deps.openYoutubeBrowserWindow,
|
||||
cycleSecondarySubMode: deps.cycleSecondarySubMode,
|
||||
openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette,
|
||||
printHelp: deps.printHelp,
|
||||
|
||||
@@ -77,6 +77,7 @@ test('cli command context factory composes main deps and context handlers', () =
|
||||
openYomitanSettings: () => {},
|
||||
openConfigSettingsWindow: () => {},
|
||||
openSyncUiWindow: () => {},
|
||||
openYoutubeBrowserWindow: () => {},
|
||||
cycleSecondarySubMode: () => {},
|
||||
openRuntimeOptionsPalette: () => {},
|
||||
printHelp: () => {},
|
||||
|
||||
@@ -106,6 +106,7 @@ test('cli command context main deps builder maps state and callbacks', async ()
|
||||
openYomitanSettings: () => calls.push('open-yomitan'),
|
||||
openConfigSettingsWindow: () => calls.push('open-config-settings'),
|
||||
openSyncUiWindow: () => {},
|
||||
openYoutubeBrowserWindow: () => {},
|
||||
cycleSecondarySubMode: () => calls.push('cycle-secondary'),
|
||||
openRuntimeOptionsPalette: () => calls.push('open-runtime-options'),
|
||||
printHelp: () => calls.push('help'),
|
||||
|
||||
@@ -66,6 +66,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
|
||||
openYomitanSettings: () => void;
|
||||
openConfigSettingsWindow: () => void;
|
||||
openSyncUiWindow: () => void;
|
||||
openYoutubeBrowserWindow: () => void;
|
||||
cycleSecondarySubMode: () => void;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
printHelp: () => void;
|
||||
@@ -146,6 +147,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
|
||||
openYomitanSettings: () => deps.openYomitanSettings(),
|
||||
openConfigSettingsWindow: () => deps.openConfigSettingsWindow(),
|
||||
openSyncUiWindow: () => deps.openSyncUiWindow(),
|
||||
openYoutubeBrowserWindow: () => deps.openYoutubeBrowserWindow(),
|
||||
cycleSecondarySubMode: () => deps.cycleSecondarySubMode(),
|
||||
openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(),
|
||||
printHelp: () => deps.printHelp(),
|
||||
|
||||
@@ -59,6 +59,7 @@ function createDeps() {
|
||||
openYomitanSettings: () => {},
|
||||
openConfigSettingsWindow: () => {},
|
||||
openSyncUiWindow: () => {},
|
||||
openYoutubeBrowserWindow: () => {},
|
||||
cycleSecondarySubMode: () => {},
|
||||
openRuntimeOptionsPalette: () => {},
|
||||
printHelp: () => {},
|
||||
|
||||
@@ -54,6 +54,7 @@ export type CliCommandContextFactoryDeps = {
|
||||
openYomitanSettings: () => void;
|
||||
openConfigSettingsWindow: () => void;
|
||||
openSyncUiWindow: () => void;
|
||||
openYoutubeBrowserWindow: () => void;
|
||||
cycleSecondarySubMode: () => void;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
printHelp: () => void;
|
||||
@@ -136,6 +137,7 @@ export function createCliCommandContext(
|
||||
openYomitanSettings: deps.openYomitanSettings,
|
||||
openConfigSettingsWindow: deps.openConfigSettingsWindow,
|
||||
openSyncUiWindow: deps.openSyncUiWindow,
|
||||
openYoutubeBrowserWindow: deps.openYoutubeBrowserWindow,
|
||||
cycleSecondarySubMode: deps.cycleSecondarySubMode,
|
||||
openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette,
|
||||
printHelp: deps.printHelp,
|
||||
|
||||
@@ -53,6 +53,7 @@ test('composeCliStartupHandlers returns callable CLI startup handlers', () => {
|
||||
openYomitanSettings: () => {},
|
||||
openConfigSettingsWindow: () => {},
|
||||
openSyncUiWindow: () => {},
|
||||
openYoutubeBrowserWindow: () => {},
|
||||
cycleSecondarySubMode: () => {},
|
||||
openRuntimeOptionsPalette: () => {},
|
||||
printHelp: () => {},
|
||||
|
||||
@@ -146,6 +146,8 @@ export type JellyfinRuntimeComposerResult = ComposerOutputs<{
|
||||
typeof composeJellyfinRemoteHandlers
|
||||
>['handleJellyfinRemoteGeneralCommand'];
|
||||
playJellyfinItemInMpv: ReturnType<typeof createPlayJellyfinItemInMpvHandler>;
|
||||
/** Connects to mpv, launching an idle player when none is running. */
|
||||
ensureMpvConnectedForPlayback: () => Promise<boolean>;
|
||||
cleanupJellyfinSubtitleCache: () => void;
|
||||
startJellyfinRemoteSession: ReturnType<typeof createStartJellyfinRemoteSessionHandler>;
|
||||
stopJellyfinRemoteSession: ReturnType<typeof createStopJellyfinRemoteSessionHandler>;
|
||||
@@ -303,6 +305,7 @@ export function composeJellyfinRuntimeHandlers(
|
||||
handleJellyfinRemotePlaystate,
|
||||
handleJellyfinRemoteGeneralCommand,
|
||||
playJellyfinItemInMpv,
|
||||
ensureMpvConnectedForPlayback: () => ensureMpvConnectedForJellyfinPlayback(),
|
||||
cleanupJellyfinSubtitleCache: () => preloadJellyfinExternalSubtitles.cleanupCachedSubtitles(),
|
||||
startJellyfinRemoteSession,
|
||||
stopJellyfinRemoteSession,
|
||||
|
||||
@@ -32,6 +32,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
yomitan: false,
|
||||
settings: false,
|
||||
syncWindow: false,
|
||||
youtubeBrowser: false,
|
||||
setup: false,
|
||||
show: false,
|
||||
hide: false,
|
||||
|
||||
@@ -313,3 +313,133 @@ test('speech model downloads share the job lock and cancellation', async () => {
|
||||
runtime.cancel();
|
||||
assert.deepEqual(await download, { ok: false, message: 'Cancelled.' });
|
||||
});
|
||||
|
||||
test('a YouTube Whisper job shows in the modal status and returns its subtitle file', async () => {
|
||||
const { runtime } = fixture();
|
||||
let release = () => {};
|
||||
const job = runtime.generateYoutubeSubtitles({
|
||||
url: 'https://www.youtube.com/watch?v=abcdefghijk',
|
||||
signal: new AbortController().signal,
|
||||
generate: async (_signal, onProgress) => {
|
||||
onProgress({ stage: 'transcribe', percent: 40, message: 'Generating Japanese subtitles...' });
|
||||
await new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
return '/tmp/subs/youtube-whisper.ja.srt';
|
||||
},
|
||||
});
|
||||
|
||||
const active = await runtime.getStatus();
|
||||
assert.equal(active.running, true);
|
||||
assert.equal(active.mediaPath, 'https://www.youtube.com/watch?v=abcdefghijk');
|
||||
assert.equal(active.progress?.percent, 40);
|
||||
assert.equal((await runtime.start()).ok, false);
|
||||
|
||||
release();
|
||||
assert.equal(await job, '/tmp/subs/youtube-whisper.ja.srt');
|
||||
const done = await runtime.getStatus();
|
||||
assert.equal(done.running, false);
|
||||
assert.equal(done.mediaPath, '/video/episode.mkv');
|
||||
});
|
||||
|
||||
test('cancelling a YouTube Whisper job resolves to null, and failures throw', async () => {
|
||||
const { runtime } = fixture();
|
||||
const cancelled = runtime.generateYoutubeSubtitles({
|
||||
url: 'https://www.youtube.com/watch?v=abcdefghijk',
|
||||
signal: new AbortController().signal,
|
||||
generate: (signal) =>
|
||||
new Promise((_, reject) =>
|
||||
signal.addEventListener('abort', () => reject(new Error('Aborted')), { once: true }),
|
||||
),
|
||||
});
|
||||
runtime.cancel();
|
||||
assert.equal(await cancelled, null);
|
||||
assert.deepEqual((await runtime.getStatus()).lastResult, { ok: false, message: 'Cancelled.' });
|
||||
|
||||
await assert.rejects(
|
||||
runtime.generateYoutubeSubtitles({
|
||||
url: 'https://www.youtube.com/watch?v=abcdefghijk',
|
||||
signal: new AbortController().signal,
|
||||
generate: async () => {
|
||||
throw new Error('No Whisper model found.');
|
||||
},
|
||||
}),
|
||||
/No Whisper model found/,
|
||||
);
|
||||
});
|
||||
|
||||
function youtubeFixture(generateYoutube: SubtitleGenerationRuntimeDeps['generateYoutube']) {
|
||||
const player = {
|
||||
path: 'https://www.youtube.com/watch?v=abcdefghijk&pp=search',
|
||||
paused: false,
|
||||
};
|
||||
const commands: unknown[][] = [];
|
||||
const client = {
|
||||
connected: true,
|
||||
requestProperty: async (name: string): Promise<unknown> =>
|
||||
name === 'path' ? player.path : name === 'pause' ? player.paused : null,
|
||||
request: async (command: unknown[]) => {
|
||||
commands.push(command);
|
||||
if (command[0] === 'set_property' && command[1] === 'pause')
|
||||
player.paused = command[2] === true;
|
||||
return { error: 'success' };
|
||||
},
|
||||
};
|
||||
const { runtime } = fixture({ getMpvClient: () => client, generateYoutube });
|
||||
return { runtime, player, commands };
|
||||
}
|
||||
|
||||
test('the modal generates for a playing YouTube video, pausing it until the subtitles load', async () => {
|
||||
let pausedDuringGeneration = false;
|
||||
let usedModel = '';
|
||||
const { runtime, player, commands } = youtubeFixture(async (input) => {
|
||||
pausedDuringGeneration = player.paused;
|
||||
usedModel = input.config.managedModel;
|
||||
assert.equal(input.url, 'https://www.youtube.com/watch?v=abcdefghijk');
|
||||
return '/tmp/subs/youtube-whisper.ja.srt';
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
(await runtime.getStatus()).mediaPath,
|
||||
'https://www.youtube.com/watch?v=abcdefghijk',
|
||||
);
|
||||
await runtime.selectModel('medium');
|
||||
const result = await runtime.start();
|
||||
|
||||
assert.deepEqual(result, {
|
||||
ok: true,
|
||||
outputPath: '/tmp/subs/youtube-whisper.ja.srt',
|
||||
message: 'Japanese subtitles generated and loaded.',
|
||||
});
|
||||
assert.equal(pausedDuringGeneration, true);
|
||||
assert.equal(usedModel, 'medium');
|
||||
assert.deepEqual(
|
||||
commands.find((command) => command[0] === 'sub-add'),
|
||||
['sub-add', '/tmp/subs/youtube-whisper.ja.srt', 'select', 'Generated Japanese', 'ja'],
|
||||
);
|
||||
assert.equal(player.paused, false);
|
||||
});
|
||||
|
||||
test('switching videos cancels a YouTube job from the modal without loading its subtitles', async () => {
|
||||
let jobSignal: AbortSignal | null = null;
|
||||
const { runtime, player, commands } = youtubeFixture(
|
||||
(input) =>
|
||||
new Promise((_, reject) => {
|
||||
jobSignal = input.signal;
|
||||
input.signal.addEventListener('abort', () => reject(new Error('Aborted')), { once: true });
|
||||
}),
|
||||
);
|
||||
|
||||
const job = runtime.start();
|
||||
while (!jobSignal) await new Promise((resolve) => setImmediate(resolve));
|
||||
runtime.handleMediaPathChange('https://www.youtube.com/watch?v=abcdefghijk');
|
||||
assert.equal((jobSignal as AbortSignal).aborted, false);
|
||||
player.path = 'https://www.youtube.com/watch?v=zyxwvutsrqp';
|
||||
runtime.handleMediaPathChange(player.path);
|
||||
|
||||
assert.deepEqual(await job, { ok: false, message: 'Cancelled.' });
|
||||
assert.equal(
|
||||
commands.some((command) => command[0] === 'sub-add'),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -21,6 +21,7 @@ import type {
|
||||
SubtitleGenerationResult,
|
||||
SubtitleGenerationStatus,
|
||||
} from '../../shared/subtitle-generation-ipc';
|
||||
import { extractYoutubeVideoId, toYoutubeWatchUrl } from './youtube-playback';
|
||||
|
||||
interface GenerationMpvClient {
|
||||
connected: boolean;
|
||||
@@ -40,6 +41,16 @@ export interface SubtitleGenerationRuntimeDeps {
|
||||
detectAcceleration?: typeof detectSubtitleGenerationAcceleration;
|
||||
downloadVad?: typeof downloadSubtitleGenerationVadModel;
|
||||
resolveVadModel?: typeof resolveSubtitleGenerationVadModel;
|
||||
/**
|
||||
* Transcribes a YouTube video's audio into a temporary subtitle file and returns its path.
|
||||
* Without it the modal only generates for local media.
|
||||
*/
|
||||
generateYoutube?: (input: {
|
||||
url: string;
|
||||
config: SubtitleGenerationConfig;
|
||||
signal: AbortSignal;
|
||||
onProgress: (progress: SubtitleGenerationProgress) => void;
|
||||
}) => Promise<string>;
|
||||
}
|
||||
|
||||
async function currentLocalMedia(client: GenerationMpvClient | null): Promise<string | null> {
|
||||
@@ -51,6 +62,25 @@ async function currentLocalMedia(client: GenerationMpvClient | null): Promise<st
|
||||
return typeof directory === 'string' ? path.resolve(directory, media) : null;
|
||||
}
|
||||
|
||||
async function currentYoutubeMedia(client: GenerationMpvClient | null): Promise<string | null> {
|
||||
if (!client?.connected) return null;
|
||||
const media = await client.requestProperty('path');
|
||||
return typeof media === 'string' ? toYoutubeWatchUrl(media) : null;
|
||||
}
|
||||
|
||||
async function loadGeneratedSubtitles(client: GenerationMpvClient, outputPath: string) {
|
||||
const loaded = await client.request([
|
||||
'sub-add',
|
||||
outputPath,
|
||||
'select',
|
||||
'Generated Japanese',
|
||||
'ja',
|
||||
]);
|
||||
if (loaded.error && loaded.error !== 'success') throw new Error(loaded.error);
|
||||
const delay = await client.request(['set_property', 'sub-delay', 0]);
|
||||
if (delay.error && delay.error !== 'success') throw new Error(delay.error);
|
||||
}
|
||||
|
||||
function selectedAudioIndex(tracks: unknown): number {
|
||||
if (!Array.isArray(tracks)) throw new Error('Unable to inspect the selected audio track.');
|
||||
for (const track of tracks) {
|
||||
@@ -83,6 +113,8 @@ export function createSubtitleGenerationRuntime(deps: SubtitleGenerationRuntimeD
|
||||
let lastResult: SubtitleGenerationResult | null = null;
|
||||
let selectedModel: SubtitleGenerationModelId | null = null;
|
||||
let vadEnabled: boolean | null = null;
|
||||
// Shown as the modal's media while a YouTube job runs; YouTube is never local media.
|
||||
let youtubeJobUrl: string | null = null;
|
||||
let accelerationCheck:
|
||||
| {
|
||||
path: string;
|
||||
@@ -155,7 +187,11 @@ export function createSubtitleGenerationRuntime(deps: SubtitleGenerationRuntimeD
|
||||
config,
|
||||
deps.getModelDirectory(),
|
||||
);
|
||||
const mediaPath = await currentLocalMedia(deps.getMpvClient()).catch(() => null);
|
||||
const client = deps.getMpvClient();
|
||||
const mediaPath =
|
||||
youtubeJobUrl ??
|
||||
(await currentLocalMedia(client).catch(() => null)) ??
|
||||
(deps.generateYoutube ? await currentYoutubeMedia(client).catch(() => null) : null);
|
||||
return {
|
||||
model,
|
||||
vad: {
|
||||
@@ -177,8 +213,42 @@ export function createSubtitleGenerationRuntime(deps: SubtitleGenerationRuntimeD
|
||||
};
|
||||
}
|
||||
|
||||
// YouTube subtitles only exist in mpv, so the video pauses while they generate and resumes
|
||||
// afterwards. Closing the modal leaves the job running; unpausing is up to the user.
|
||||
async function startYoutube(
|
||||
client: GenerationMpvClient,
|
||||
url: string,
|
||||
config: SubtitleGenerationConfig,
|
||||
signal: AbortSignal,
|
||||
generate: NonNullable<SubtitleGenerationRuntimeDeps['generateYoutube']>,
|
||||
): Promise<SubtitleGenerationResult> {
|
||||
const resume = (await client.requestProperty('pause')) === false;
|
||||
if (resume) await client.request(['set_property', 'pause', true]);
|
||||
youtubeJobUrl = url;
|
||||
const stillPlaying = async () =>
|
||||
deps.getMpvClient() === client &&
|
||||
(await currentYoutubeMedia(client).catch(() => null)) === url;
|
||||
try {
|
||||
const outputPath = await generate({ url, config, signal, onProgress: report });
|
||||
if (signal.aborted || !(await stillPlaying()))
|
||||
return { ok: false, message: 'Playback changed, so the subtitles were not loaded.' };
|
||||
await loadGeneratedSubtitles(client, outputPath);
|
||||
return { ok: true, outputPath, message: 'Japanese subtitles generated and loaded.' };
|
||||
} finally {
|
||||
youtubeJobUrl = null;
|
||||
if (resume && (await stillPlaying()))
|
||||
await client.request(['set_property', 'pause', false]).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
getStatus,
|
||||
/** Stops a YouTube job once its video is no longer playing, which also deletes its audio. */
|
||||
handleMediaPathChange(mediaPath: string | null | undefined): void {
|
||||
if (!youtubeJobUrl) return;
|
||||
if (extractYoutubeVideoId(mediaPath) !== extractYoutubeVideoId(youtubeJobUrl))
|
||||
controller?.abort();
|
||||
},
|
||||
async setVadEnabled(enabled: boolean): Promise<SubtitleGenerationStatus> {
|
||||
if (controller)
|
||||
throw new Error('Wait for the current operation before changing speech detection.');
|
||||
@@ -210,6 +280,39 @@ export function createSubtitleGenerationRuntime(deps: SubtitleGenerationRuntimeD
|
||||
cancel(): void {
|
||||
controller?.abort();
|
||||
},
|
||||
/**
|
||||
* Runs YouTube Whisper generation as the active operation, so the modal shows its progress
|
||||
* and its Cancel button stops it. Aborting `signal` stops it too. Resolves to the written
|
||||
* subtitle file, or null when the job was cancelled.
|
||||
*/
|
||||
async generateYoutubeSubtitles(input: {
|
||||
url: string;
|
||||
signal: AbortSignal;
|
||||
generate: (
|
||||
signal: AbortSignal,
|
||||
onProgress: (progress: SubtitleGenerationProgress) => void,
|
||||
) => Promise<string>;
|
||||
}): Promise<string | null> {
|
||||
let cancelled = false;
|
||||
let outputPath: string | null = null;
|
||||
const result = await run(async (signal) => {
|
||||
const stop = () => controller?.abort();
|
||||
input.signal.addEventListener('abort', stop, { once: true });
|
||||
if (input.signal.aborted) stop();
|
||||
youtubeJobUrl = input.url;
|
||||
try {
|
||||
outputPath = await input.generate(signal, report);
|
||||
return { ok: true, outputPath, message: 'Japanese subtitles generated.' };
|
||||
} finally {
|
||||
cancelled = signal.aborted;
|
||||
input.signal.removeEventListener('abort', stop);
|
||||
youtubeJobUrl = null;
|
||||
}
|
||||
});
|
||||
if (result.ok) return outputPath;
|
||||
if (cancelled) return null;
|
||||
throw new Error(result.message);
|
||||
},
|
||||
download(): Promise<SubtitleGenerationResult> {
|
||||
return run(async (signal) => {
|
||||
await (deps.download ?? downloadSubtitleGenerationModel)({
|
||||
@@ -236,9 +339,12 @@ export function createSubtitleGenerationRuntime(deps: SubtitleGenerationRuntimeD
|
||||
if (vad.kind === 'invalid') throw new Error(vad.message);
|
||||
}
|
||||
const client = deps.getMpvClient();
|
||||
const youtubeUrl = deps.generateYoutube ? await currentYoutubeMedia(client) : null;
|
||||
if (client && youtubeUrl && deps.generateYoutube)
|
||||
return await startYoutube(client, youtubeUrl, config, signal, deps.generateYoutube);
|
||||
const mediaPath = await currentLocalMedia(client);
|
||||
if (!client || !mediaPath)
|
||||
throw new Error('Open a local video or audio file in mpv first.');
|
||||
throw new Error('Open a local video or audio file, or a YouTube video, in mpv first.');
|
||||
const tracks = await client.requestProperty('track-list');
|
||||
const audioStreamIndex = selectedAudioIndex(tracks);
|
||||
const references = await readSubtitleGenerationReferences(tracks, (name) =>
|
||||
@@ -260,16 +366,7 @@ export function createSubtitleGenerationRuntime(deps: SubtitleGenerationRuntimeD
|
||||
try {
|
||||
const playingMedia = await currentLocalMedia(client);
|
||||
if (!signal.aborted && deps.getMpvClient() === client && playingMedia === mediaPath) {
|
||||
const loaded = await client.request([
|
||||
'sub-add',
|
||||
outputPath,
|
||||
'select',
|
||||
'Generated Japanese',
|
||||
'ja',
|
||||
]);
|
||||
if (loaded.error && loaded.error !== 'success') throw new Error(loaded.error);
|
||||
const delay = await client.request(['set_property', 'sub-delay', 0]);
|
||||
if (delay.error && delay.error !== 'success') throw new Error(delay.error);
|
||||
await loadGeneratedSubtitles(client, outputPath);
|
||||
return {
|
||||
ok: true,
|
||||
outputPath,
|
||||
|
||||
@@ -51,6 +51,7 @@ test('build tray template handler wires actions and init guards', () => {
|
||||
handlers.openYomitanSettings();
|
||||
handlers.openConfigSettings();
|
||||
handlers.openSyncUi();
|
||||
handlers.openYoutubeBrowser();
|
||||
handlers.exportLogs();
|
||||
handlers.openJellyfinSetup();
|
||||
handlers.toggleJellyfinDiscovery(true);
|
||||
@@ -74,6 +75,7 @@ test('build tray template handler wires actions and init guards', () => {
|
||||
openYomitanSettings: () => calls.push('yomitan'),
|
||||
openConfigSettingsWindow: () => calls.push('configuration'),
|
||||
openSyncUiWindow: () => calls.push('sync-ui'),
|
||||
openYoutubeBrowserWindow: () => calls.push('youtube'),
|
||||
exportLogs: () => calls.push('export-logs'),
|
||||
openJellyfinSetupWindow: () => calls.push('jellyfin'),
|
||||
isJellyfinConfigured: () => true,
|
||||
@@ -100,6 +102,7 @@ test('build tray template handler wires actions and init guards', () => {
|
||||
'yomitan',
|
||||
'configuration',
|
||||
'sync-ui',
|
||||
'youtube',
|
||||
'export-logs',
|
||||
'jellyfin',
|
||||
'jellyfin-discovery:true',
|
||||
@@ -130,6 +133,7 @@ test('windows mpv launcher tray action force-opens completed setup', () => {
|
||||
openYomitanSettings: () => calls.push('yomitan'),
|
||||
openConfigSettingsWindow: () => calls.push('configuration'),
|
||||
openSyncUiWindow: () => calls.push('configuration'),
|
||||
openYoutubeBrowserWindow: () => calls.push('youtube'),
|
||||
exportLogs: () => calls.push('export-logs'),
|
||||
openJellyfinSetupWindow: () => calls.push('jellyfin'),
|
||||
isJellyfinConfigured: () => false,
|
||||
|
||||
@@ -49,6 +49,7 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
|
||||
openYomitanSettings: () => void;
|
||||
openConfigSettings: () => void;
|
||||
openSyncUi: () => void;
|
||||
openYoutubeBrowser: () => void;
|
||||
exportLogs: () => void;
|
||||
openJellyfinSetup: () => void;
|
||||
showJellyfinDiscovery: boolean;
|
||||
@@ -70,6 +71,7 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
|
||||
openYomitanSettings: () => void;
|
||||
openConfigSettingsWindow: () => void;
|
||||
openSyncUiWindow: () => void;
|
||||
openYoutubeBrowserWindow: () => void;
|
||||
exportLogs: () => void;
|
||||
openJellyfinSetupWindow: () => void;
|
||||
isJellyfinConfigured: () => boolean;
|
||||
@@ -116,6 +118,9 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
|
||||
openSyncUi: () => {
|
||||
deps.openSyncUiWindow();
|
||||
},
|
||||
openYoutubeBrowser: () => {
|
||||
deps.openYoutubeBrowserWindow();
|
||||
},
|
||||
exportLogs: () => {
|
||||
deps.exportLogs();
|
||||
},
|
||||
|
||||
@@ -34,6 +34,7 @@ test('tray main deps builders return mapped handlers', () => {
|
||||
openYomitanSettings: () => calls.push('yomitan'),
|
||||
openConfigSettingsWindow: () => calls.push('configuration'),
|
||||
openSyncUiWindow: () => calls.push('sync-ui'),
|
||||
openYoutubeBrowserWindow: () => calls.push('youtube'),
|
||||
exportLogs: () => calls.push('export-logs'),
|
||||
openJellyfinSetupWindow: () => calls.push('jellyfin'),
|
||||
isJellyfinConfigured: () => true,
|
||||
@@ -61,6 +62,7 @@ test('tray main deps builders return mapped handlers', () => {
|
||||
openYomitanSettings: () => calls.push('open-yomitan'),
|
||||
openConfigSettings: () => calls.push('open-configuration'),
|
||||
openSyncUi: () => {},
|
||||
openYoutubeBrowser: () => {},
|
||||
exportLogs: () => calls.push('open-export-logs'),
|
||||
openJellyfinSetup: () => calls.push('open-jellyfin'),
|
||||
showJellyfinDiscovery: true,
|
||||
|
||||
@@ -39,6 +39,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
|
||||
openYomitanSettings: () => void;
|
||||
openConfigSettings: () => void;
|
||||
openSyncUi: () => void;
|
||||
openYoutubeBrowser: () => void;
|
||||
exportLogs: () => void;
|
||||
openJellyfinSetup: () => void;
|
||||
showJellyfinDiscovery: boolean;
|
||||
@@ -60,6 +61,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
|
||||
openYomitanSettings: () => void;
|
||||
openConfigSettingsWindow: () => void;
|
||||
openSyncUiWindow: () => void;
|
||||
openYoutubeBrowserWindow: () => void;
|
||||
exportLogs: () => void;
|
||||
openJellyfinSetupWindow: () => void;
|
||||
isJellyfinConfigured: () => boolean;
|
||||
@@ -85,6 +87,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
|
||||
openYomitanSettings: deps.openYomitanSettings,
|
||||
openConfigSettingsWindow: deps.openConfigSettingsWindow,
|
||||
openSyncUiWindow: deps.openSyncUiWindow,
|
||||
openYoutubeBrowserWindow: deps.openYoutubeBrowserWindow,
|
||||
exportLogs: deps.exportLogs,
|
||||
openJellyfinSetupWindow: deps.openJellyfinSetupWindow,
|
||||
isJellyfinConfigured: deps.isJellyfinConfigured,
|
||||
|
||||
@@ -34,6 +34,7 @@ test('tray runtime handlers compose resolve/menu/ensure/destroy handlers', () =>
|
||||
openYomitanSettings: () => {},
|
||||
openConfigSettingsWindow: () => {},
|
||||
openSyncUiWindow: () => {},
|
||||
openYoutubeBrowserWindow: () => {},
|
||||
exportLogs: () => {},
|
||||
openJellyfinSetupWindow: () => {},
|
||||
isJellyfinConfigured: () => false,
|
||||
|
||||
@@ -40,6 +40,7 @@ test('tray menu template contains expected entries and handlers', () => {
|
||||
openYomitanSettings: () => calls.push('yomitan'),
|
||||
openConfigSettings: () => calls.push('configuration'),
|
||||
openSyncUi: () => calls.push('sync-ui'),
|
||||
openYoutubeBrowser: () => calls.push('youtube'),
|
||||
exportLogs: () => calls.push('export-logs'),
|
||||
openJellyfinSetup: () => calls.push('jellyfin'),
|
||||
showJellyfinDiscovery: true,
|
||||
@@ -69,6 +70,7 @@ test('tray menu template contains expected entries and handlers', () => {
|
||||
'Open Yomitan Settings',
|
||||
'Open SubMiner Settings',
|
||||
'Sync Stats && History',
|
||||
'Browse YouTube',
|
||||
'Export Logs',
|
||||
'Configure Jellyfin',
|
||||
'Jellyfin Discovery',
|
||||
@@ -88,6 +90,7 @@ test('tray menu template contains expected entries and handlers', () => {
|
||||
entryFor('View Changelog').click?.();
|
||||
entryFor('Open Texthooker').click?.();
|
||||
entryFor('Sync Stats && History').click?.();
|
||||
entryFor('Browse YouTube').click?.();
|
||||
entryFor('Export Logs').click?.();
|
||||
entryFor('Check for Updates').click?.();
|
||||
calls.push(template.some((entry) => entry.type === 'separator') ? 'separator' : 'bad');
|
||||
@@ -99,6 +102,7 @@ test('tray menu template contains expected entries and handlers', () => {
|
||||
'changelog',
|
||||
'texthooker',
|
||||
'sync-ui',
|
||||
'youtube',
|
||||
'export-logs',
|
||||
'updates',
|
||||
'separator',
|
||||
@@ -119,6 +123,7 @@ test('tray menu template omits first-run setup entry when setup is complete', ()
|
||||
openYomitanSettings: () => undefined,
|
||||
openConfigSettings: () => undefined,
|
||||
openSyncUi: () => undefined,
|
||||
openYoutubeBrowser: () => undefined,
|
||||
exportLogs: () => undefined,
|
||||
openJellyfinSetup: () => undefined,
|
||||
showJellyfinDiscovery: false,
|
||||
@@ -149,6 +154,7 @@ test('tray menu template omits texthooker entry when texthooker page is disabled
|
||||
openYomitanSettings: () => undefined,
|
||||
openConfigSettings: () => undefined,
|
||||
openSyncUi: () => undefined,
|
||||
openYoutubeBrowser: () => undefined,
|
||||
exportLogs: () => undefined,
|
||||
openJellyfinSetup: () => undefined,
|
||||
showJellyfinDiscovery: false,
|
||||
@@ -177,6 +183,7 @@ test('tray menu template renders active jellyfin discovery checkbox', () => {
|
||||
openYomitanSettings: () => undefined,
|
||||
openConfigSettings: () => undefined,
|
||||
openSyncUi: () => undefined,
|
||||
openYoutubeBrowser: () => undefined,
|
||||
exportLogs: () => undefined,
|
||||
openJellyfinSetup: () => undefined,
|
||||
showJellyfinDiscovery: true,
|
||||
@@ -206,6 +213,7 @@ test('tray menu template renders a visible linux discovery check mark when activ
|
||||
openYomitanSettings: () => undefined,
|
||||
openConfigSettings: () => undefined,
|
||||
openSyncUi: () => undefined,
|
||||
openYoutubeBrowser: () => undefined,
|
||||
exportLogs: () => undefined,
|
||||
openJellyfinSetup: () => undefined,
|
||||
showJellyfinDiscovery: true,
|
||||
|
||||
@@ -42,6 +42,7 @@ export type TrayMenuActionHandlers = {
|
||||
openYomitanSettings: () => void;
|
||||
openConfigSettings: () => void;
|
||||
openSyncUi: () => void;
|
||||
openYoutubeBrowser: () => void;
|
||||
exportLogs: () => void;
|
||||
openJellyfinSetup: () => void;
|
||||
showJellyfinDiscovery: boolean;
|
||||
@@ -113,6 +114,10 @@ export function buildTrayMenuTemplateRuntime(handlers: TrayMenuActionHandlers):
|
||||
label: 'Sync Stats && History',
|
||||
click: handlers.openSyncUi,
|
||||
},
|
||||
{
|
||||
label: 'Browse YouTube',
|
||||
click: handlers.openYoutubeBrowser,
|
||||
},
|
||||
{
|
||||
label: 'Export Logs',
|
||||
click: handlers.exportLogs,
|
||||
|
||||
@@ -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({
|
||||
url: 'https://example.com/watch?v=video123',
|
||||
mode: 'download',
|
||||
});
|
||||
|
||||
assert.equal(selectedPrimarySid, 9);
|
||||
@@ -1884,9 +1883,145 @@ test('youtube flow falls back to existing auto secondary track when auto seconda
|
||||
|
||||
await runtime.runYoutubePlaybackFlow({
|
||||
url: 'https://example.com/watch?v=video123',
|
||||
mode: 'download',
|
||||
});
|
||||
|
||||
assert.equal(selectedPrimarySid, 4);
|
||||
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,
|
||||
YoutubePickerResolveResult,
|
||||
} from '../../types';
|
||||
import type { YoutubeSubtitleSource } from '../../types/integrations';
|
||||
import type { SubtitleGenerationProgress } from '../../shared/subtitle-generation';
|
||||
import type {
|
||||
YoutubeTrackOption,
|
||||
YoutubeTrackProbeResult,
|
||||
@@ -18,9 +20,9 @@ import {
|
||||
acquireYoutubeSubtitleTracks,
|
||||
} from '../../core/services/youtube/generate';
|
||||
import { resolveSubtitleSourcePath } from './subtitle-prefetch-source';
|
||||
import { extractYoutubeVideoId } from './youtube-playback';
|
||||
|
||||
type YoutubeFlowOpenPicker = (payload: YoutubePickerOpenPayload) => Promise<boolean>;
|
||||
type YoutubeFlowMode = 'download' | 'generate';
|
||||
|
||||
type YoutubeFlowDeps = {
|
||||
probeYoutubeTracks: (url: string) => Promise<YoutubeTrackProbeResult>;
|
||||
@@ -48,6 +50,20 @@ type YoutubeFlowDeps = {
|
||||
getYoutubeOutputDir: () => string;
|
||||
createSubtitleTempDir?: () => Promise<string>;
|
||||
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 = {
|
||||
@@ -58,6 +74,13 @@ type YoutubeFlowSession = {
|
||||
|
||||
const YOUTUBE_PICKER_SETTLE_DELAY_MS = 150;
|
||||
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 {
|
||||
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 {
|
||||
deps.sendMpvCommand(['script-message', 'subminer-autoplay-ready']);
|
||||
deps.resumeMpv();
|
||||
@@ -429,6 +467,20 @@ async function injectDownloadedSubtitles(
|
||||
export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
|
||||
let activeSession: YoutubeFlowSession | null = null;
|
||||
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 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: {
|
||||
targetUrl: string;
|
||||
outputDir: string;
|
||||
@@ -604,7 +664,6 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
|
||||
|
||||
const loadTracksIntoMpv = async (input: {
|
||||
url: string;
|
||||
mode: YoutubeFlowMode;
|
||||
outputDir: string;
|
||||
primaryTrack: YoutubeTrackOption;
|
||||
secondaryTrack: YoutubeTrackOption | null;
|
||||
@@ -751,10 +810,7 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
|
||||
}
|
||||
};
|
||||
|
||||
const openManualPicker = async (input: {
|
||||
url: string;
|
||||
mode?: YoutubeFlowMode;
|
||||
}): Promise<void> => {
|
||||
const openManualPicker = async (input: { url: string }): Promise<void> => {
|
||||
deps.showMpvOsd('Opening YouTube subtitle picker...');
|
||||
|
||||
let probe: YoutubeTrackProbeResult;
|
||||
@@ -818,11 +874,12 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
|
||||
});
|
||||
const secondaryTrack = getTrackById(probe.tracks, selected.secondaryTrackId);
|
||||
|
||||
// A track picked by hand replaces whatever Whisper would have loaded.
|
||||
cancelWhisperGeneration();
|
||||
try {
|
||||
deps.showMpvOsd('Getting subtitles...');
|
||||
const loaded = await loadTracksIntoMpv({
|
||||
url: input.url,
|
||||
mode: input.mode ?? 'download',
|
||||
outputDir: normalizeOutputPath(deps.getYoutubeOutputDir()),
|
||||
primaryTrack,
|
||||
secondaryTrack,
|
||||
@@ -844,10 +901,86 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
|
||||
}
|
||||
};
|
||||
|
||||
async function runYoutubePlaybackFlow(input: {
|
||||
url: string;
|
||||
mode: YoutubeFlowMode;
|
||||
}): Promise<void> {
|
||||
const openWhisperProgressModal = async (jobSignal: AbortSignal): Promise<void> => {
|
||||
if (!deps.openSubtitleGenerationModal) return;
|
||||
// Like the picker, wait for mpv's window so the modal lands on top of it.
|
||||
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');
|
||||
const tokenizationWarmupPromise = deps.startTokenizationWarmups().catch((error) => {
|
||||
deps.warn(
|
||||
@@ -859,6 +992,15 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
|
||||
|
||||
deps.pauseMpv();
|
||||
suppressYoutubeSubtitleState(deps);
|
||||
cancelWhisperGeneration();
|
||||
if (deps.getSubtitleSource?.() === 'whisper' && deps.generateWhisperSubtitles) {
|
||||
await loadWhisperSubtitles(
|
||||
input.url,
|
||||
deps.generateWhisperSubtitles,
|
||||
tokenizationWarmupPromise,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const outputDir = normalizeOutputPath(deps.getYoutubeOutputDir());
|
||||
|
||||
let probe: YoutubeTrackProbeResult;
|
||||
@@ -890,14 +1032,10 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
|
||||
deps.showMpvOsd('Getting subtitles...');
|
||||
const loaded = await loadTracksIntoMpv({
|
||||
url: input.url,
|
||||
mode: input.mode,
|
||||
outputDir,
|
||||
primaryTrack,
|
||||
secondaryTrack,
|
||||
secondaryFailureLabel:
|
||||
input.mode === 'generate'
|
||||
? 'Failed to generate secondary YouTube subtitle track'
|
||||
: 'Failed to download secondary YouTube subtitle track',
|
||||
secondaryFailureLabel: 'Failed to download secondary YouTube subtitle track',
|
||||
tokenizationWarmupPromise,
|
||||
showDownloadProgress: false,
|
||||
});
|
||||
@@ -906,9 +1044,7 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
|
||||
}
|
||||
} catch (error) {
|
||||
deps.warn(
|
||||
`Failed to ${
|
||||
input.mode === 'generate' ? 'generate' : 'download'
|
||||
} primary YouTube subtitle track: ${
|
||||
`Failed to download primary YouTube subtitle track: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
@@ -926,5 +1062,8 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
|
||||
cancelActivePicker,
|
||||
hasActiveSession: () => Boolean(activeSession),
|
||||
cleanupSubtitleTempDirs,
|
||||
createSubtitleTempDir,
|
||||
cancelWhisperGeneration,
|
||||
handleMediaPathChange,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -38,8 +38,8 @@ test('youtube playback runtime resets flow ownership after a successful run', as
|
||||
calls.push(`prepare:${url}`);
|
||||
return true;
|
||||
},
|
||||
runYoutubePlaybackFlow: async ({ url, mode }) => {
|
||||
calls.push(`run-flow:${url}:${mode}`);
|
||||
runYoutubePlaybackFlow: async ({ url }) => {
|
||||
calls.push(`run-flow:${url}`);
|
||||
},
|
||||
logInfo: (message) => {
|
||||
calls.push(`info:${message}`);
|
||||
@@ -59,7 +59,6 @@ test('youtube playback runtime resets flow ownership after a successful run', as
|
||||
|
||||
await runtime.runYoutubePlaybackFlow({
|
||||
url: 'https://youtu.be/demo',
|
||||
mode: 'download',
|
||||
source: 'initial',
|
||||
});
|
||||
|
||||
@@ -117,8 +116,8 @@ test('youtube playback runtime resolves the socket path lazily for windows start
|
||||
calls.push(`prepare:${url}`);
|
||||
return true;
|
||||
},
|
||||
runYoutubePlaybackFlow: async ({ url, mode }) => {
|
||||
calls.push(`run-flow:${url}:${mode}`);
|
||||
runYoutubePlaybackFlow: async ({ url }) => {
|
||||
calls.push(`run-flow:${url}`);
|
||||
},
|
||||
logInfo: (message) => {
|
||||
calls.push(`info:${message}`);
|
||||
@@ -140,7 +139,6 @@ test('youtube playback runtime resolves the socket path lazily for windows start
|
||||
|
||||
await runtime.runYoutubePlaybackFlow({
|
||||
url: 'https://youtu.be/demo',
|
||||
mode: 'download',
|
||||
source: 'initial',
|
||||
});
|
||||
|
||||
@@ -185,8 +183,8 @@ test('youtube playback runtime starts media cache without blocking the subtitle
|
||||
await cachePromise;
|
||||
calls.push('cache-done');
|
||||
},
|
||||
runYoutubePlaybackFlow: async ({ url, mode }) => {
|
||||
calls.push(`run-flow:${url}:${mode}`);
|
||||
runYoutubePlaybackFlow: async ({ url }) => {
|
||||
calls.push(`run-flow:${url}`);
|
||||
},
|
||||
logInfo: (message) => {
|
||||
calls.push(`info:${message}`);
|
||||
@@ -200,13 +198,12 @@ test('youtube playback runtime starts media cache without blocking the subtitle
|
||||
|
||||
await runtime.runYoutubePlaybackFlow({
|
||||
url: 'https://youtu.be/demo',
|
||||
mode: 'download',
|
||||
source: 'second-instance',
|
||||
});
|
||||
|
||||
const prepareIndex = calls.indexOf('prepare: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(cacheIndex, -1);
|
||||
assert.notEqual(runFlowIndex, -1);
|
||||
@@ -251,8 +248,8 @@ test('youtube playback runtime logs synchronous media cache startup failures', a
|
||||
calls.push('cache');
|
||||
throw new Error('cache exploded');
|
||||
},
|
||||
runYoutubePlaybackFlow: async ({ url, mode }) => {
|
||||
calls.push(`run-flow:${url}:${mode}`);
|
||||
runYoutubePlaybackFlow: async ({ url }) => {
|
||||
calls.push(`run-flow:${url}`);
|
||||
},
|
||||
logInfo: (message) => {
|
||||
calls.push(`info:${message}`);
|
||||
@@ -266,12 +263,11 @@ test('youtube playback runtime logs synchronous media cache startup failures', a
|
||||
|
||||
await runtime.runYoutubePlaybackFlow({
|
||||
url: 'https://youtu.be/demo',
|
||||
mode: 'download',
|
||||
source: 'second-instance',
|
||||
});
|
||||
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(
|
||||
calls.some((entry) =>
|
||||
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 = {
|
||||
ok: boolean;
|
||||
@@ -21,10 +24,7 @@ export type YoutubePlaybackRuntimeDeps = {
|
||||
waitForYoutubeMpvConnected: (timeoutMs: number) => Promise<boolean>;
|
||||
prepareYoutubePlaybackInMpv: (request: { url: string }) => Promise<boolean>;
|
||||
startYoutubeMediaCache?: (url: string) => void | Promise<void>;
|
||||
runYoutubePlaybackFlow: (request: {
|
||||
url: string;
|
||||
mode: NonNullable<CliArgs['youtubeMode']>;
|
||||
}) => Promise<void>;
|
||||
runYoutubePlaybackFlow: (request: { url: string }) => Promise<void>;
|
||||
logInfo: (message: string) => void;
|
||||
logWarn: (message: string) => void;
|
||||
schedule: (callback: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
|
||||
@@ -45,8 +45,7 @@ export function createYoutubePlaybackRuntime(deps: YoutubePlaybackRuntimeDeps) {
|
||||
|
||||
const runYoutubePlaybackFlow = async (request: {
|
||||
url: string;
|
||||
mode: NonNullable<CliArgs['youtubeMode']>;
|
||||
source: CliCommandSource;
|
||||
source: YoutubePlaybackFlowSource;
|
||||
}): Promise<void> => {
|
||||
const flowGeneration = ++playbackFlowGeneration;
|
||||
deps.invalidatePendingAutoplayReadyFallbacks();
|
||||
@@ -139,10 +138,7 @@ export function createYoutubePlaybackRuntime(deps: YoutubePlaybackRuntimeDeps) {
|
||||
});
|
||||
}
|
||||
|
||||
await deps.runYoutubePlaybackFlow({
|
||||
url: request.url,
|
||||
mode: request.mode,
|
||||
});
|
||||
await deps.runYoutubePlaybackFlow({ url: request.url });
|
||||
flowCompleted = true;
|
||||
deps.logInfo(`YouTube playback flow completed from ${request.source}.`);
|
||||
} finally {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
toYoutubeWatchUrl,
|
||||
isSameYoutubeMediaPath,
|
||||
isYoutubeMediaPath,
|
||||
isYoutubePlaybackActive,
|
||||
@@ -76,3 +77,25 @@ test('shouldUseCachedYoutubeParsedCues requires cached cues for the same youtube
|
||||
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}`);
|
||||
}
|
||||
|
||||
function extractYoutubeVideoId(mediaPath: string | null | undefined): string | null {
|
||||
export function extractYoutubeVideoId(mediaPath: string | null | undefined): string | null {
|
||||
const normalized = trimToNull(mediaPath);
|
||||
if (!normalized) {
|
||||
return null;
|
||||
@@ -43,6 +43,19 @@ function extractYoutubeVideoId(mediaPath: string | null | undefined): string | n
|
||||
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 {
|
||||
const normalized = trimToNull(mediaPath);
|
||||
if (!normalized) {
|
||||
|
||||
Reference in New Issue
Block a user