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

This commit is contained in:
2026-09-28 20:35:47 -07:00
committed by GitHub
parent c68b73c8c1
commit 07b32626a6
101 changed files with 2269 additions and 726 deletions
+2
View File
@@ -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,
+2
View File
@@ -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: () => {},
+2
View File
@@ -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,
);
});
+109 -12
View File
@@ -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,
+5
View File
@@ -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();
},
+2
View File
@@ -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,
+3
View File
@@ -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,
+8
View File
@@ -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,
+5
View File
@@ -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',
);
});
+214
View File
@@ -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 };
}
+137 -2
View File
@@ -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');
});
+158 -19
View File
@@ -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'),
+7 -11
View File
@@ -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 {
+23
View File
@@ -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);
});
+14 -1
View File
@@ -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) {