fix: align youtube playback with shared overlay startup

This commit is contained in:
2026-03-22 18:34:25 -07:00
parent 7666a094f4
commit e7242d006f
31 changed files with 3545 additions and 60 deletions

View File

@@ -0,0 +1,451 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createYoutubeFlowRuntime } from './youtube-flow';
import type { YoutubePickerOpenPayload, YoutubeTrackOption } from '../../types';
const primaryTrack: YoutubeTrackOption = {
id: 'auto:ja-orig',
language: 'ja',
sourceLanguage: 'ja-orig',
kind: 'auto',
label: 'Japanese (auto)',
};
const secondaryTrack: YoutubeTrackOption = {
id: 'manual:en',
language: 'en',
sourceLanguage: 'en',
kind: 'manual',
label: 'English (manual)',
};
test('youtube flow clears internal tracks and binds external primary+secondary subtitles', async () => {
const commands: Array<Array<string | number>> = [];
const osdMessages: string[] = [];
const order: string[] = [];
const refreshedSubtitles: string[] = [];
const waits: number[] = [];
const focusOverlayCalls: string[] = [];
let pickerPayload: YoutubePickerOpenPayload | null = null;
let trackListRequests = 0;
const runtime = createYoutubeFlowRuntime({
probeYoutubeTracks: async () => ({
videoId: 'video123',
title: 'Video 123',
tracks: [primaryTrack, secondaryTrack],
}),
acquireYoutubeSubtitleTracks: async ({ tracks }) => {
assert.deepEqual(
tracks.map((track) => track.id),
[primaryTrack.id, secondaryTrack.id],
);
return new Map<string, string>([
[primaryTrack.id, '/tmp/auto-ja-orig.vtt'],
[secondaryTrack.id, '/tmp/manual-en.vtt'],
]);
},
acquireYoutubeSubtitleTrack: async ({ track }) => {
if (track.id === primaryTrack.id) {
return { path: '/tmp/auto-ja-orig.vtt' };
}
return { path: '/tmp/manual-en.vtt' };
},
retimeYoutubePrimaryTrack: async ({ primaryPath, secondaryPath }) => {
assert.equal(primaryPath, '/tmp/auto-ja-orig.vtt');
assert.equal(secondaryPath, '/tmp/manual-en.vtt');
return '/tmp/auto-ja-orig_retimed.vtt';
},
startTokenizationWarmups: async () => {
order.push('start-tokenization-warmups');
},
waitForTokenizationReady: async () => {
order.push('wait-tokenization-ready');
},
waitForAnkiReady: async () => {
order.push('wait-anki-ready');
},
waitForPlaybackWindowReady: async () => {
order.push('wait-window-ready');
},
waitForOverlayGeometryReady: async () => {
order.push('wait-overlay-geometry');
},
focusOverlayWindow: () => {
focusOverlayCalls.push('focus-overlay');
},
openPicker: async (payload) => {
assert.deepEqual(waits, [150]);
order.push('open-picker');
pickerPayload = payload;
queueMicrotask(() => {
void runtime.resolveActivePicker({
sessionId: payload.sessionId,
action: 'use-selected',
primaryTrackId: primaryTrack.id,
secondaryTrackId: secondaryTrack.id,
});
});
return true;
},
pauseMpv: () => {
commands.push(['set_property', 'pause', 'yes']);
},
resumeMpv: () => {
commands.push(['set_property', 'pause', 'no']);
},
sendMpvCommand: (command) => {
commands.push(command);
},
requestMpvProperty: async (name) => {
if (name === 'sub-text') {
return '字幕です';
}
assert.equal(name, 'track-list');
trackListRequests += 1;
if (trackListRequests === 1) {
return [{ type: 'sub', id: 1, lang: 'ja', external: false, title: 'internal' }];
}
return [
{
type: 'sub',
id: 5,
lang: 'ja-orig',
title: 'primary',
external: true,
'external-filename': '/tmp/auto-ja-orig_retimed.vtt',
},
{
type: 'sub',
id: 6,
lang: 'en',
title: 'secondary',
external: true,
'external-filename': '/tmp/manual-en.vtt',
},
];
},
refreshCurrentSubtitle: (text) => {
refreshedSubtitles.push(text);
},
wait: async (ms) => {
waits.push(ms);
},
showMpvOsd: (text) => {
osdMessages.push(text);
},
warn: (message) => {
throw new Error(message);
},
log: () => {},
getYoutubeOutputDir: () => '/tmp',
});
await runtime.runYoutubePlaybackFlow({ url: 'https://example.com', mode: 'download' });
assert.ok(pickerPayload);
assert.deepEqual(order, [
'start-tokenization-warmups',
'wait-window-ready',
'wait-overlay-geometry',
'open-picker',
'wait-tokenization-ready',
'wait-anki-ready',
]);
assert.deepEqual(osdMessages, [
'Opening YouTube video',
'Getting subtitles...',
'Downloading subtitles...',
'Loading subtitles...',
'Primary and secondary subtitles loaded.',
]);
assert.deepEqual(commands, [
['set_property', 'pause', 'yes'],
['set_property', 'sub-delay', 0],
['set_property', 'sid', 'no'],
['set_property', 'secondary-sid', 'no'],
['sub-add', '/tmp/auto-ja-orig_retimed.vtt', 'select', 'auto-ja-orig_retimed.vtt', 'ja-orig'],
['sub-add', '/tmp/manual-en.vtt', 'cached', 'manual-en.vtt', 'en'],
['set_property', 'sid', 5],
['set_property', 'secondary-sid', 6],
['script-message', 'subminer-autoplay-ready'],
['set_property', 'pause', 'no'],
]);
assert.deepEqual(focusOverlayCalls, ['focus-overlay']);
assert.deepEqual(refreshedSubtitles, ['字幕です']);
});
test('youtube flow can cancel active picker session', async () => {
const focusOverlayCalls: string[] = [];
const runtime = createYoutubeFlowRuntime({
probeYoutubeTracks: async () => ({
videoId: 'video123',
title: 'Video 123',
tracks: [primaryTrack],
}),
acquireYoutubeSubtitleTracks: async () => {
throw new Error('should not batch download after cancel');
},
acquireYoutubeSubtitleTrack: async () => {
throw new Error('should not download after cancel');
},
retimeYoutubePrimaryTrack: async ({ primaryPath }) => primaryPath,
startTokenizationWarmups: async () => {},
waitForTokenizationReady: async () => {},
waitForAnkiReady: async () => {},
waitForPlaybackWindowReady: async () => {},
waitForOverlayGeometryReady: async () => {},
focusOverlayWindow: () => {
focusOverlayCalls.push('focus-overlay');
},
openPicker: async (payload) => {
queueMicrotask(() => {
assert.equal(runtime.cancelActivePicker(), true);
void runtime.resolveActivePicker({
sessionId: payload.sessionId,
action: 'use-selected',
primaryTrackId: primaryTrack.id,
secondaryTrackId: null,
});
});
return true;
},
pauseMpv: () => {},
resumeMpv: () => {},
sendMpvCommand: () => {},
requestMpvProperty: async () => null,
refreshCurrentSubtitle: () => {},
wait: async () => {},
showMpvOsd: () => {},
warn: () => {},
log: () => {},
getYoutubeOutputDir: () => '/tmp',
});
await runtime.runYoutubePlaybackFlow({ url: 'https://example.com', mode: 'download' });
assert.equal(runtime.hasActiveSession(), false);
assert.equal(runtime.cancelActivePicker(), false);
assert.deepEqual(focusOverlayCalls, ['focus-overlay']);
});
test('youtube flow retries secondary after partial batch subtitle failure', async () => {
const acquireSingleCalls: string[] = [];
const commands: Array<Array<string | number>> = [];
const focusOverlayCalls: string[] = [];
const refreshedSubtitles: string[] = [];
const warns: string[] = [];
const waits: number[] = [];
let trackListRequests = 0;
const runtime = createYoutubeFlowRuntime({
probeYoutubeTracks: async () => ({
videoId: 'video123',
title: 'Video 123',
tracks: [primaryTrack, secondaryTrack],
}),
acquireYoutubeSubtitleTracks: async () => {
return new Map<string, string>([[primaryTrack.id, '/tmp/auto-ja-orig.vtt']]);
},
acquireYoutubeSubtitleTrack: async ({ track }) => {
acquireSingleCalls.push(track.id);
return { path: `/tmp/${track.id}.vtt` };
},
retimeYoutubePrimaryTrack: async ({ primaryPath, secondaryPath }) => {
assert.equal(primaryPath, '/tmp/auto-ja-orig.vtt');
assert.equal(secondaryPath, '/tmp/manual:en.vtt');
return primaryPath;
},
startTokenizationWarmups: async () => {},
waitForTokenizationReady: async () => {},
waitForAnkiReady: async () => {},
waitForPlaybackWindowReady: async () => {},
waitForOverlayGeometryReady: async () => {},
focusOverlayWindow: () => {
focusOverlayCalls.push('focus-overlay');
},
openPicker: async (payload) => {
queueMicrotask(() => {
void runtime.resolveActivePicker({
sessionId: payload.sessionId,
action: 'use-selected',
primaryTrackId: primaryTrack.id,
secondaryTrackId: secondaryTrack.id,
});
});
return true;
},
pauseMpv: () => {},
resumeMpv: () => {},
sendMpvCommand: (command) => {
commands.push(command);
},
requestMpvProperty: async (name) => {
if (name === 'sub-text') {
return '字幕です';
}
assert.equal(name, 'track-list');
trackListRequests += 1;
if (trackListRequests === 1) {
return [
{
type: 'sub',
id: 5,
lang: 'ja-orig',
title: 'primary',
external: true,
'external-filename': '/tmp/auto-ja-orig.vtt',
},
];
}
return [
{
type: 'sub',
id: 5,
lang: 'ja-orig',
title: 'primary',
external: true,
'external-filename': '/tmp/auto-ja-orig.vtt',
},
{
type: 'sub',
id: 6,
lang: 'en',
title: 'secondary',
external: true,
'external-filename': '/tmp/manual:en.vtt',
},
];
},
refreshCurrentSubtitle: (text) => {
refreshedSubtitles.push(text);
},
wait: async (ms) => {
waits.push(ms);
},
showMpvOsd: () => {},
warn: (message) => {
warns.push(message);
},
log: () => {},
getYoutubeOutputDir: () => '/tmp',
});
await runtime.runYoutubePlaybackFlow({ url: 'https://example.com', mode: 'download' });
assert.deepEqual(acquireSingleCalls, [secondaryTrack.id]);
assert.ok(waits.includes(150));
assert.deepEqual(focusOverlayCalls, ['focus-overlay']);
assert.deepEqual(refreshedSubtitles, ['字幕です']);
assert.ok(
commands.some(
(command) =>
command[0] === 'sub-add' &&
command[1] === '/tmp/manual:en.vtt' &&
command[2] === 'cached',
),
);
assert.equal(warns.length, 0);
});
test('youtube flow waits for tokenization readiness before releasing playback', async () => {
const commands: Array<Array<string | number>> = [];
const releaseOrder: string[] = [];
let tokenizationReadyRegistered = false;
let resolveTokenizationReady: () => void = () => {
throw new Error('expected tokenization readiness waiter');
};
const runtime = createYoutubeFlowRuntime({
probeYoutubeTracks: async () => ({
videoId: 'video123',
title: 'Video 123',
tracks: [primaryTrack],
}),
acquireYoutubeSubtitleTracks: async () => new Map<string, string>(),
acquireYoutubeSubtitleTrack: async () => ({ path: '/tmp/auto-ja-orig.vtt' }),
retimeYoutubePrimaryTrack: async ({ primaryPath }) => primaryPath,
startTokenizationWarmups: async () => {
releaseOrder.push('start-warmups');
},
waitForTokenizationReady: async () => {
releaseOrder.push('wait-tokenization-ready:start');
await new Promise<void>((resolve) => {
tokenizationReadyRegistered = true;
resolveTokenizationReady = resolve;
});
releaseOrder.push('wait-tokenization-ready:end');
},
waitForAnkiReady: async () => {
releaseOrder.push('wait-anki-ready');
},
waitForPlaybackWindowReady: async () => {},
waitForOverlayGeometryReady: async () => {},
focusOverlayWindow: () => {
releaseOrder.push('focus-overlay');
},
openPicker: async (payload) => {
queueMicrotask(() => {
void runtime.resolveActivePicker({
sessionId: payload.sessionId,
action: 'use-selected',
primaryTrackId: primaryTrack.id,
secondaryTrackId: null,
});
});
return true;
},
pauseMpv: () => {},
resumeMpv: () => {
commands.push(['set_property', 'pause', 'no']);
releaseOrder.push('resume');
},
sendMpvCommand: (command) => {
commands.push(command);
if (command[0] === 'script-message' && command[1] === 'subminer-autoplay-ready') {
releaseOrder.push('autoplay-ready');
}
},
requestMpvProperty: async (name) => {
if (name === 'sub-text') {
return '字幕です';
}
return [
{
type: 'sub',
id: 5,
lang: 'ja-orig',
title: 'primary',
external: true,
'external-filename': '/tmp/auto-ja-orig.vtt',
},
];
},
refreshCurrentSubtitle: () => {},
wait: async () => {},
showMpvOsd: () => {},
warn: (message) => {
throw new Error(message);
},
log: () => {},
getYoutubeOutputDir: () => '/tmp',
});
const flowPromise = runtime.runYoutubePlaybackFlow({ url: 'https://example.com', mode: 'download' });
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(tokenizationReadyRegistered, true);
assert.deepEqual(releaseOrder, ['start-warmups', 'wait-tokenization-ready:start']);
assert.equal(commands.some((command) => command[1] === 'subminer-autoplay-ready'), false);
resolveTokenizationReady();
await flowPromise;
assert.deepEqual(releaseOrder, [
'start-warmups',
'wait-tokenization-ready:start',
'wait-tokenization-ready:end',
'wait-anki-ready',
'autoplay-ready',
'resume',
'focus-overlay',
]);
});

View File

@@ -0,0 +1,549 @@
import os from 'node:os';
import path from 'node:path';
import type {
YoutubeFlowMode,
YoutubePickerOpenPayload,
YoutubePickerResolveRequest,
YoutubePickerResolveResult,
} from '../../types';
import type {
YoutubeTrackOption,
YoutubeTrackProbeResult,
} from '../../core/services/youtube/track-probe';
import {
chooseDefaultYoutubeTrackIds,
normalizeYoutubeTrackSelection,
} from '../../core/services/youtube/track-selection';
import {
acquireYoutubeSubtitleTrack,
acquireYoutubeSubtitleTracks,
} from '../../core/services/youtube/generate';
import { resolveSubtitleSourcePath } from './subtitle-prefetch-source';
type YoutubeFlowOpenPicker = (payload: YoutubePickerOpenPayload) => Promise<boolean>;
type YoutubeFlowDeps = {
probeYoutubeTracks: (url: string) => Promise<YoutubeTrackProbeResult>;
acquireYoutubeSubtitleTrack: typeof acquireYoutubeSubtitleTrack;
acquireYoutubeSubtitleTracks: typeof acquireYoutubeSubtitleTracks;
retimeYoutubePrimaryTrack: (input: {
targetUrl: string;
primaryTrack: YoutubeTrackOption;
primaryPath: string;
secondaryTrack: YoutubeTrackOption | null;
secondaryPath: string | null;
}) => Promise<string>;
openPicker: YoutubeFlowOpenPicker;
pauseMpv: () => void;
resumeMpv: () => void;
sendMpvCommand: (command: Array<string | number>) => void;
requestMpvProperty: (name: string) => Promise<unknown>;
refreshCurrentSubtitle: (text: string) => void;
startTokenizationWarmups: () => Promise<void>;
waitForTokenizationReady: () => Promise<void>;
waitForAnkiReady: () => Promise<void>;
wait: (ms: number) => Promise<void>;
waitForPlaybackWindowReady: () => Promise<void>;
waitForOverlayGeometryReady: () => Promise<void>;
focusOverlayWindow: () => void;
showMpvOsd: (text: string) => void;
warn: (message: string) => void;
log: (message: string) => void;
getYoutubeOutputDir: () => string;
};
type YoutubeFlowSession = {
sessionId: string;
resolve: (request: YoutubePickerResolveRequest) => void;
reject: (error: Error) => void;
};
const YOUTUBE_PICKER_SETTLE_DELAY_MS = 150;
const YOUTUBE_SECONDARY_RETRY_DELAY_MS = 350;
function createSessionId(): string {
return `yt-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
}
function getTrackById(tracks: YoutubeTrackOption[], id: string | null): YoutubeTrackOption | null {
if (!id) return null;
return tracks.find((track) => track.id === id) ?? null;
}
function normalizeOutputPath(value: string): string {
const trimmed = value.trim();
return trimmed || path.join(os.tmpdir(), 'subminer-youtube-subs');
}
function createYoutubeFlowOsdProgress(showMpvOsd: (text: string) => void) {
const frames = ['|', '/', '-', '\\'];
let timer: ReturnType<typeof setInterval> | null = null;
let frame = 0;
const stop = (): void => {
if (!timer) {
return;
}
clearInterval(timer);
timer = null;
};
const setMessage = (message: string): void => {
stop();
frame = 0;
showMpvOsd(message);
timer = setInterval(() => {
showMpvOsd(`${message} ${frames[frame % frames.length]}`);
frame += 1;
}, 180);
};
return {
setMessage,
stop,
};
}
function releasePlaybackGate(deps: YoutubeFlowDeps): void {
deps.sendMpvCommand(['script-message', 'subminer-autoplay-ready']);
deps.resumeMpv();
}
function restoreOverlayInputFocus(deps: YoutubeFlowDeps): void {
deps.focusOverlayWindow();
}
function parseTrackId(value: unknown): number | null {
if (typeof value === 'number' && Number.isInteger(value)) {
return value;
}
if (typeof value === 'string') {
const parsed = Number(value.trim());
return Number.isInteger(parsed) ? parsed : null;
}
return null;
}
function normalizeTrackListEntry(track: Record<string, unknown>): {
id: number | null;
lang: string;
title: string;
external: boolean;
externalFilename: string | null;
} {
const externalFilenameRaw =
typeof track['external-filename'] === 'string'
? track['external-filename']
: typeof track.external_filename === 'string'
? track.external_filename
: '';
const externalFilename = externalFilenameRaw.trim()
? resolveSubtitleSourcePath(externalFilenameRaw.trim())
: null;
return {
id: parseTrackId(track.id),
lang: String(track.lang || '').trim(),
title: String(track.title || '').trim(),
external: track.external === true,
externalFilename,
};
}
function matchExternalTrackId(
trackListRaw: unknown,
filePath: string,
trackOption: YoutubeTrackOption,
excludeId: number | null = null,
): number | null {
if (!Array.isArray(trackListRaw)) {
return null;
}
const normalizedFilePath = resolveSubtitleSourcePath(filePath);
const basename = path.basename(normalizedFilePath);
const externalTracks = trackListRaw
.filter(
(track): track is Record<string, unknown> => Boolean(track) && typeof track === 'object',
)
.filter((track) => track.type === 'sub')
.map(normalizeTrackListEntry)
.filter((track) => track.external && track.id !== null && track.id !== excludeId);
const exactPathMatch = externalTracks.find(
(track) => track.externalFilename === normalizedFilePath,
);
if (exactPathMatch?.id !== null && exactPathMatch?.id !== undefined) {
return exactPathMatch.id;
}
const basenameMatch = externalTracks.find(
(track) => track.externalFilename && path.basename(track.externalFilename) === basename,
);
if (basenameMatch?.id !== null && basenameMatch?.id !== undefined) {
return basenameMatch.id;
}
const languageMatch = externalTracks.find((track) => track.lang === trackOption.sourceLanguage);
if (languageMatch?.id !== null && languageMatch?.id !== undefined) {
return languageMatch.id;
}
const normalizedLanguageMatch = externalTracks.find(
(track) => track.lang === trackOption.language,
);
if (normalizedLanguageMatch?.id !== null && normalizedLanguageMatch?.id !== undefined) {
return normalizedLanguageMatch.id;
}
return null;
}
async function injectDownloadedSubtitles(
deps: YoutubeFlowDeps,
primaryTrack: YoutubeTrackOption,
primaryPath: string,
secondaryTrack: YoutubeTrackOption | null,
secondaryPath: string | null,
): Promise<boolean> {
deps.sendMpvCommand(['set_property', 'sub-delay', 0]);
deps.sendMpvCommand(['set_property', 'sid', 'no']);
deps.sendMpvCommand(['set_property', 'secondary-sid', 'no']);
deps.sendMpvCommand([
'sub-add',
primaryPath,
'select',
path.basename(primaryPath),
primaryTrack.sourceLanguage,
]);
if (secondaryPath && secondaryTrack) {
deps.sendMpvCommand([
'sub-add',
secondaryPath,
'cached',
path.basename(secondaryPath),
secondaryTrack.sourceLanguage,
]);
}
let trackListRaw: unknown = null;
let primaryTrackId: number | null = null;
let secondaryTrackId: number | null = null;
for (let attempt = 0; attempt < 12; attempt += 1) {
await deps.wait(attempt === 0 ? 150 : 100);
trackListRaw = await deps.requestMpvProperty('track-list');
primaryTrackId = matchExternalTrackId(trackListRaw, primaryPath, primaryTrack);
secondaryTrackId =
secondaryPath && secondaryTrack
? matchExternalTrackId(trackListRaw, secondaryPath, secondaryTrack, primaryTrackId)
: null;
if (primaryTrackId !== null && (!secondaryPath || secondaryTrackId !== null)) {
break;
}
}
if (primaryTrackId !== null) {
deps.sendMpvCommand(['set_property', 'sid', primaryTrackId]);
} else {
deps.warn(
`Unable to bind downloaded primary subtitle track in mpv: ${path.basename(primaryPath)}`,
);
}
if (secondaryPath && secondaryTrack) {
if (secondaryTrackId !== null) {
deps.sendMpvCommand(['set_property', 'secondary-sid', secondaryTrackId]);
} else {
deps.warn(
`Unable to bind downloaded secondary subtitle track in mpv: ${path.basename(secondaryPath)}`,
);
}
}
const currentSubText = await deps.requestMpvProperty('sub-text');
if (typeof currentSubText === 'string' && currentSubText.trim().length > 0) {
deps.refreshCurrentSubtitle(currentSubText);
}
deps.showMpvOsd(
secondaryPath && secondaryTrack ? 'Primary and secondary subtitles loaded.' : 'Subtitles loaded.',
);
return typeof currentSubText === 'string' && currentSubText.trim().length > 0;
}
export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
let activeSession: YoutubeFlowSession | null = null;
const acquireSelectedTracks = async (input: {
targetUrl: string;
outputDir: string;
primaryTrack: YoutubeTrackOption;
secondaryTrack: YoutubeTrackOption | null;
mode: YoutubeFlowMode;
secondaryFailureLabel: string;
}): Promise<{ primaryPath: string; secondaryPath: string | null }> => {
if (!input.secondaryTrack) {
const primaryPath = (
await deps.acquireYoutubeSubtitleTrack({
targetUrl: input.targetUrl,
outputDir: input.outputDir,
track: input.primaryTrack,
mode: input.mode,
})
).path;
return { primaryPath, secondaryPath: null };
}
try {
const batchResult = await deps.acquireYoutubeSubtitleTracks({
targetUrl: input.targetUrl,
outputDir: input.outputDir,
tracks: [input.primaryTrack, input.secondaryTrack],
mode: input.mode,
});
const primaryPath = batchResult.get(input.primaryTrack.id) ?? null;
const secondaryPath = batchResult.get(input.secondaryTrack.id) ?? null;
if (primaryPath) {
if (secondaryPath) {
return { primaryPath, secondaryPath };
}
deps.log(
`${
input.secondaryFailureLabel
}: No subtitle file was downloaded for ${input.secondaryTrack.sourceLanguage}; retrying secondary separately after delay.`,
);
await deps.wait(YOUTUBE_SECONDARY_RETRY_DELAY_MS);
try {
const retriedSecondaryPath = (
await deps.acquireYoutubeSubtitleTrack({
targetUrl: input.targetUrl,
outputDir: input.outputDir,
track: input.secondaryTrack,
mode: input.mode,
})
).path;
return { primaryPath, secondaryPath: retriedSecondaryPath };
} catch (error) {
deps.warn(
`${input.secondaryFailureLabel}: ${
error instanceof Error ? error.message : String(error)
}`,
);
return { primaryPath, secondaryPath: null };
}
}
} catch {
// fall through to primary-only recovery
}
try {
const primaryPath = (
await deps.acquireYoutubeSubtitleTrack({
targetUrl: input.targetUrl,
outputDir: input.outputDir,
track: input.primaryTrack,
mode: input.mode,
})
).path;
return { primaryPath, secondaryPath: null };
} catch (error) {
throw error;
}
};
const resolveActivePicker = async (
request: YoutubePickerResolveRequest,
): Promise<YoutubePickerResolveResult> => {
if (!activeSession || activeSession.sessionId !== request.sessionId) {
return { ok: false, message: 'No active YouTube subtitle picker session.' };
}
activeSession.resolve(request);
return { ok: true, message: 'Picker selection accepted.' };
};
const cancelActivePicker = (): boolean => {
if (!activeSession) {
return false;
}
activeSession.resolve({
sessionId: activeSession.sessionId,
action: 'continue-without-subtitles',
primaryTrackId: null,
secondaryTrackId: null,
});
return true;
};
const createPickerSelectionPromise = (sessionId: string): Promise<YoutubePickerResolveRequest> =>
new Promise<YoutubePickerResolveRequest>((resolve, reject) => {
activeSession = { sessionId, resolve, reject };
}).finally(() => {
activeSession = null;
});
async function runYoutubePlaybackFlow(input: {
url: string;
mode: YoutubeFlowMode;
}): Promise<void> {
deps.showMpvOsd('Opening YouTube video');
const tokenizationWarmupPromise = deps.startTokenizationWarmups().catch((error) => {
deps.warn(
`Failed to warm subtitle tokenization prerequisites: ${
error instanceof Error ? error.message : String(error)
}`,
);
});
const probe = await deps.probeYoutubeTracks(input.url);
const defaults = chooseDefaultYoutubeTrackIds(probe.tracks);
const sessionId = createSessionId();
const outputDir = normalizeOutputPath(deps.getYoutubeOutputDir());
deps.pauseMpv();
const openPayload: YoutubePickerOpenPayload = {
sessionId,
url: input.url,
mode: input.mode,
tracks: probe.tracks,
defaultPrimaryTrackId: defaults.primaryTrackId,
defaultSecondaryTrackId: defaults.secondaryTrackId,
hasTracks: probe.tracks.length > 0,
};
if (input.mode === 'download') {
await deps.waitForPlaybackWindowReady();
await deps.waitForOverlayGeometryReady();
await deps.wait(YOUTUBE_PICKER_SETTLE_DELAY_MS);
deps.showMpvOsd('Getting subtitles...');
const pickerSelection = createPickerSelectionPromise(sessionId);
void pickerSelection.catch(() => undefined);
const opened = await deps.openPicker(openPayload);
if (!opened) {
activeSession?.reject(new Error('Unable to open YouTube subtitle picker.'));
activeSession = null;
deps.warn('Unable to open YouTube subtitle picker; continuing without subtitles.');
releasePlaybackGate(deps);
restoreOverlayInputFocus(deps);
return;
}
const request = await pickerSelection;
if (request.action === 'continue-without-subtitles') {
releasePlaybackGate(deps);
restoreOverlayInputFocus(deps);
return;
}
const osdProgress = createYoutubeFlowOsdProgress(deps.showMpvOsd);
osdProgress.setMessage('Downloading subtitles...');
try {
const primaryTrack = getTrackById(probe.tracks, request.primaryTrackId);
if (!primaryTrack) {
deps.warn('No primary YouTube subtitle track selected; continuing without subtitles.');
return;
}
const selected = normalizeYoutubeTrackSelection({
primaryTrackId: primaryTrack.id,
secondaryTrackId: request.secondaryTrackId,
});
const secondaryTrack = getTrackById(probe.tracks, selected.secondaryTrackId);
const acquired = await acquireSelectedTracks({
targetUrl: input.url,
outputDir,
primaryTrack,
secondaryTrack,
mode: input.mode,
secondaryFailureLabel: 'Failed to download secondary YouTube subtitle track',
});
const resolvedPrimaryPath = await deps.retimeYoutubePrimaryTrack({
targetUrl: input.url,
primaryTrack,
primaryPath: acquired.primaryPath,
secondaryTrack,
secondaryPath: acquired.secondaryPath,
});
osdProgress.setMessage('Loading subtitles...');
const refreshedActiveSubtitle = await injectDownloadedSubtitles(
deps,
primaryTrack,
resolvedPrimaryPath,
secondaryTrack,
acquired.secondaryPath,
);
await tokenizationWarmupPromise;
if (refreshedActiveSubtitle) {
await deps.waitForTokenizationReady();
}
await deps.waitForAnkiReady();
} catch (error) {
deps.warn(
`Failed to download primary YouTube subtitle track: ${
error instanceof Error ? error.message : String(error)
}`,
);
} finally {
osdProgress.stop();
releasePlaybackGate(deps);
restoreOverlayInputFocus(deps);
}
return;
}
const primaryTrack = getTrackById(probe.tracks, defaults.primaryTrackId);
const secondaryTrack = getTrackById(probe.tracks, defaults.secondaryTrackId);
if (!primaryTrack) {
deps.showMpvOsd('No usable YouTube subtitles found.');
releasePlaybackGate(deps);
restoreOverlayInputFocus(deps);
return;
}
try {
deps.showMpvOsd('Getting subtitles...');
const acquired = await acquireSelectedTracks({
targetUrl: input.url,
outputDir,
primaryTrack,
secondaryTrack,
mode: input.mode,
secondaryFailureLabel: 'Failed to generate secondary YouTube subtitle track',
});
const resolvedPrimaryPath = await deps.retimeYoutubePrimaryTrack({
targetUrl: input.url,
primaryTrack,
primaryPath: acquired.primaryPath,
secondaryTrack,
secondaryPath: acquired.secondaryPath,
});
deps.showMpvOsd('Loading subtitles...');
const refreshedActiveSubtitle = await injectDownloadedSubtitles(
deps,
primaryTrack,
resolvedPrimaryPath,
secondaryTrack,
acquired.secondaryPath,
);
await tokenizationWarmupPromise;
if (refreshedActiveSubtitle) {
await deps.waitForTokenizationReady();
}
await deps.waitForAnkiReady();
} catch (error) {
deps.warn(
`Failed to generate primary YouTube subtitle track: ${
error instanceof Error ? error.message : String(error)
}`,
);
} finally {
releasePlaybackGate(deps);
restoreOverlayInputFocus(deps);
}
}
return {
runYoutubePlaybackFlow,
resolveActivePicker,
cancelActivePicker,
hasActiveSession: () => Boolean(activeSession),
};
}