fix: restore app-owned youtube subtitle flow

This commit is contained in:
2026-03-23 19:12:16 -07:00
parent 0c21e36e30
commit 5a0d8bc57f
22 changed files with 929 additions and 674 deletions

View File

@@ -82,16 +82,26 @@ function createContext(): LauncherCommandContext {
}; };
} }
test('youtube playback launches overlay with youtube-play args in the primary app start', async () => { test('youtube playback launches overlay with app-owned youtube flow args', async () => {
const calls: string[] = []; const calls: string[] = [];
const context = createContext(); const context = createContext();
let receivedStartMpvOptions: Record<string, unknown> | null = null;
await runPlaybackCommandWithDeps(context, { await runPlaybackCommandWithDeps(context, {
ensurePlaybackSetupReady: async () => {}, ensurePlaybackSetupReady: async () => {},
chooseTarget: async (_args, _scriptPath) => ({ target: context.args.target, kind: 'url' }), chooseTarget: async (_args, _scriptPath) => ({ target: context.args.target, kind: 'url' }),
checkDependencies: () => {}, checkDependencies: () => {},
registerCleanup: () => {}, registerCleanup: () => {},
startMpv: async () => { startMpv: async (
_target,
_targetKind,
_args,
_socketPath,
_appPath,
_preloadedSubtitles,
options,
) => {
receivedStartMpvOptions = options ?? null;
calls.push('startMpv'); calls.push('startMpv');
}, },
waitForUnixSocketReady: async () => true, waitForUnixSocketReady: async () => true,
@@ -110,4 +120,8 @@ test('youtube playback launches overlay with youtube-play args in the primary ap
'startMpv', 'startMpv',
'startOverlay:--youtube-play https://www.youtube.com/watch?v=65Ovd7t8sNw --youtube-mode download', 'startOverlay:--youtube-play https://www.youtube.com/watch?v=65Ovd7t8sNw --youtube-mode download',
]); ]);
assert.deepEqual(receivedStartMpvOptions, {
startPaused: true,
disableYoutubeSubtitleAutoLoad: true,
});
}); });

View File

@@ -560,28 +560,25 @@ export async function startMpv(
if (targetKind === 'url' && isYoutubeTarget(target)) { if (targetKind === 'url' && isYoutubeTarget(target)) {
log('info', args.logLevel, 'Applying URL playback options'); log('info', args.logLevel, 'Applying URL playback options');
mpvArgs.push('--ytdl=yes'); mpvArgs.push('--ytdl=yes');
const subtitleLangs = uniqueNormalizedLangCodes([
if (isYoutubeTarget(target)) { ...args.youtubePrimarySubLangs,
const subtitleLangs = uniqueNormalizedLangCodes([ ...args.youtubeSecondarySubLangs,
...args.youtubePrimarySubLangs, ]).join(',');
...args.youtubeSecondarySubLangs, const audioLangs = uniqueNormalizedLangCodes(args.youtubeAudioLangs).join(',');
]).join(','); log('info', args.logLevel, 'Applying YouTube playback options');
const audioLangs = uniqueNormalizedLangCodes(args.youtubeAudioLangs).join(','); log('debug', args.logLevel, `YouTube subtitle langs: ${subtitleLangs}`);
log('info', args.logLevel, 'Applying YouTube playback options'); log('debug', args.logLevel, `YouTube audio langs: ${audioLangs}`);
log('debug', args.logLevel, `YouTube subtitle langs: ${subtitleLangs}`); mpvArgs.push(`--ytdl-format=${DEFAULT_YOUTUBE_YTDL_FORMAT}`, `--alang=${audioLangs}`);
log('debug', args.logLevel, `YouTube audio langs: ${audioLangs}`); if (options?.disableYoutubeSubtitleAutoLoad !== true) {
mpvArgs.push(`--ytdl-format=${DEFAULT_YOUTUBE_YTDL_FORMAT}`, `--alang=${audioLangs}`); mpvArgs.push(
if (options?.disableYoutubeSubtitleAutoLoad !== true) { '--sub-auto=fuzzy',
mpvArgs.push( `--slang=${subtitleLangs}`,
'--sub-auto=fuzzy', '--ytdl-raw-options-append=write-subs=',
`--slang=${subtitleLangs}`, '--ytdl-raw-options-append=sub-format=vtt/best',
'--ytdl-raw-options-append=write-subs=', `--ytdl-raw-options-append=sub-langs=${subtitleLangs}`,
'--ytdl-raw-options-append=sub-format=vtt/best', );
`--ytdl-raw-options-append=sub-langs=${subtitleLangs}`, } else {
); mpvArgs.push('--sub-auto=no');
} else {
mpvArgs.push('--sub-auto=no');
}
} }
} }
if (args.mpvArgs) { if (args.mpvArgs) {

View File

@@ -56,8 +56,13 @@ test('parseArgs captures launch-mpv targets and keeps it out of app startup', ()
assert.equal(shouldStartApp(args), false); assert.equal(shouldStartApp(args), false);
}); });
test('parseArgs captures youtube playback commands and mode', () => { test('parseArgs captures youtube startup forwarding flags', () => {
const args = parseArgs(['--youtube-play', 'https://youtube.com/watch?v=abc', '--youtube-mode', 'generate']); const args = parseArgs([
'--youtube-play',
'https://youtube.com/watch?v=abc',
'--youtube-mode',
'generate',
]);
assert.equal(args.youtubePlay, 'https://youtube.com/watch?v=abc'); assert.equal(args.youtubePlay, 'https://youtube.com/watch?v=abc');
assert.equal(args.youtubeMode, 'generate'); assert.equal(args.youtubeMode, 'generate');

View File

@@ -499,7 +499,7 @@ export function commandNeedsOverlayRuntime(args: CliArgs): boolean {
args.triggerFieldGrouping || args.triggerFieldGrouping ||
args.triggerSubsync || args.triggerSubsync ||
args.markAudioCard || args.markAudioCard ||
args.openRuntimeOptions args.openRuntimeOptions ||
|| Boolean(args.youtubePlay) Boolean(args.youtubePlay)
); );
} }

View File

@@ -186,8 +186,8 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
runJellyfinCommand: async () => { runJellyfinCommand: async () => {
calls.push('runJellyfinCommand'); calls.push('runJellyfinCommand');
}, },
runYoutubePlaybackFlow: async ({ url, mode }) => { runYoutubePlaybackFlow: async (request) => {
calls.push(`runYoutubePlaybackFlow:${url}:${mode}`); calls.push(`runYoutubePlaybackFlow:${request.url}:${request.mode}:${request.source}`);
}, },
printHelp: () => { printHelp: () => {
calls.push('printHelp'); calls.push('printHelp');
@@ -212,25 +212,6 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
return { deps, calls, osd }; return { deps, calls, osd };
} }
test('handleCliCommand reconnects MPV for second-instance --start when overlay runtime is already initialized', () => {
const { deps, calls } = createDeps({
isOverlayRuntimeInitialized: () => true,
});
const args = makeArgs({ start: true });
handleCliCommand(args, 'second-instance', deps);
assert.ok(calls.includes('setMpvClientSocketPath:/tmp/subminer.sock'));
assert.equal(
calls.some((value) => value.includes('connectMpvClient')),
true,
);
assert.equal(
calls.some((value) => value.includes('initializeOverlayRuntime')),
false,
);
});
test('handleCliCommand starts youtube playback flow on initial launch', () => { test('handleCliCommand starts youtube playback flow on initial launch', () => {
const { deps, calls } = createDeps({ const { deps, calls } = createDeps({
runYoutubePlaybackFlow: async (request) => { runYoutubePlaybackFlow: async (request) => {
@@ -265,6 +246,25 @@ test('handleCliCommand defaults youtube mode to download when omitted', () => {
]); ]);
}); });
test('handleCliCommand reconnects MPV for second-instance --start when overlay runtime is already initialized', () => {
const { deps, calls } = createDeps({
isOverlayRuntimeInitialized: () => true,
});
const args = makeArgs({ start: true });
handleCliCommand(args, 'second-instance', deps);
assert.ok(calls.includes('setMpvClientSocketPath:/tmp/subminer.sock'));
assert.equal(
calls.some((value) => value.includes('connectMpvClient')),
true,
);
assert.equal(
calls.some((value) => value.includes('initializeOverlayRuntime')),
false,
);
});
test('handleCliCommand processes --start for second-instance when overlay runtime is not initialized', () => { test('handleCliCommand processes --start for second-instance when overlay runtime is not initialized', () => {
const { deps, calls } = createDeps(); const { deps, calls } = createDeps();
const args = makeArgs({ start: true }); const args = makeArgs({ start: true });

View File

@@ -404,18 +404,11 @@ export function handleCliCommand(
deps.openJellyfinSetup(); deps.openJellyfinSetup();
deps.log('Opened Jellyfin setup flow.'); deps.log('Opened Jellyfin setup flow.');
} else if (args.youtubePlay) { } else if (args.youtubePlay) {
const youtubeUrl = args.youtubePlay; void deps.runYoutubePlaybackFlow({
runAsyncWithOsd( url: args.youtubePlay,
() => mode: args.youtubeMode ?? 'download',
deps.runYoutubePlaybackFlow({ source,
url: youtubeUrl, });
mode: args.youtubeMode ?? 'download',
source,
}),
deps,
'runYoutubePlaybackFlow',
'YouTube playback failed',
);
} else if (args.dictionary) { } else if (args.dictionary) {
const shouldStopAfterRun = source === 'initial' && !deps.hasMainWindow(); const shouldStopAfterRun = source === 'initial' && !deps.hasMainWindow();
deps.log('Generating character dictionary for current anime...'); deps.log('Generating character dictionary for current anime...');

View File

@@ -1,16 +1,10 @@
import type { YoutubeFlowMode } from '../../../types';
import type { YoutubeTrackOption } from './track-probe'; import type { YoutubeTrackOption } from './track-probe';
import { downloadYoutubeSubtitleTrack, downloadYoutubeSubtitleTracks } from './track-download'; import { downloadYoutubeSubtitleTrack, downloadYoutubeSubtitleTracks } from './track-download';
export function isYoutubeGenerationMode(mode: YoutubeFlowMode): boolean {
return mode === 'generate';
}
export async function acquireYoutubeSubtitleTrack(input: { export async function acquireYoutubeSubtitleTrack(input: {
targetUrl: string; targetUrl: string;
outputDir: string; outputDir: string;
track: YoutubeTrackOption; track: YoutubeTrackOption;
mode: YoutubeFlowMode;
}): Promise<{ path: string }> { }): Promise<{ path: string }> {
return await downloadYoutubeSubtitleTrack(input); return await downloadYoutubeSubtitleTrack(input);
} }
@@ -19,7 +13,6 @@ export async function acquireYoutubeSubtitleTracks(input: {
targetUrl: string; targetUrl: string;
outputDir: string; outputDir: string;
tracks: YoutubeTrackOption[]; tracks: YoutubeTrackOption[];
mode: YoutubeFlowMode;
}): Promise<Map<string, string>> { }): Promise<Map<string, string>> {
return await downloadYoutubeSubtitleTracks(input); return await downloadYoutubeSubtitleTracks(input);
} }

View File

@@ -1,6 +1,6 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { convertYoutubeTimedTextToVtt } from './timedtext'; import { convertYoutubeTimedTextToVtt, normalizeYoutubeAutoVtt } from './timedtext';
test('convertYoutubeTimedTextToVtt leaves malformed numeric entities literal', () => { test('convertYoutubeTimedTextToVtt leaves malformed numeric entities literal', () => {
const result = convertYoutubeTimedTextToVtt( const result = convertYoutubeTimedTextToVtt(
@@ -38,3 +38,38 @@ test('convertYoutubeTimedTextToVtt does not swallow text after zero-length overl
].join('\n'), ].join('\n'),
); );
}); });
test('normalizeYoutubeAutoVtt strips cumulative rolling-caption prefixes', () => {
const result = normalizeYoutubeAutoVtt(
[
'WEBVTT',
'',
'00:00:01.000 --> 00:00:02.000',
'今日は',
'',
'00:00:02.000 --> 00:00:03.000',
'今日はいい天気ですね',
'',
'00:00:03.000 --> 00:00:04.000',
'今日はいい天気ですね本当に',
'',
].join('\n'),
);
assert.equal(
result,
[
'WEBVTT',
'',
'00:00:01.000 --> 00:00:02.000',
'今日は',
'',
'00:00:02.000 --> 00:00:03.000',
'いい天気ですね',
'',
'00:00:03.000 --> 00:00:04.000',
'本当に',
'',
].join('\n'),
);
});

View File

@@ -115,3 +115,52 @@ export function convertYoutubeTimedTextToVtt(xml: string): string {
return `WEBVTT\n\n${blocks.join('\n\n')}\n`; return `WEBVTT\n\n${blocks.join('\n\n')}\n`;
} }
function normalizeRollingCaptionText(text: string, previousText: string): string {
if (!previousText || !text.startsWith(previousText)) {
return text;
}
return text.slice(previousText.length).trimStart();
}
export function normalizeYoutubeAutoVtt(content: string): string {
const normalizedContent = content.replace(/\r\n?/g, '\n');
const blocks = normalizedContent.split(/\n{2,}/);
if (blocks.length === 0) {
return content;
}
let previousText = '';
let changed = false;
const normalizedBlocks = blocks.map((block) => {
if (!block.includes('-->')) {
return block;
}
const lines = block.split('\n');
const timingLineIndex = lines.findIndex((line) => line.includes('-->'));
if (timingLineIndex < 0 || timingLineIndex === lines.length - 1) {
return block;
}
const textLines = lines.slice(timingLineIndex + 1);
const originalText = textLines.join('\n').trim();
if (!originalText) {
return block;
}
const normalizedText = normalizeRollingCaptionText(originalText, previousText);
previousText = originalText;
if (!normalizedText || normalizedText === originalText) {
return block;
}
changed = true;
return [...lines.slice(0, timingLineIndex + 1), normalizedText].join('\n');
});
if (!changed) {
return content;
}
return `${normalizedBlocks.join('\n\n')}\n`;
}

View File

@@ -174,7 +174,6 @@ test('downloadYoutubeSubtitleTrack prefers subtitle files over later webp artifa
kind: 'auto', kind: 'auto',
label: 'Japanese (auto)', label: 'Japanese (auto)',
}, },
mode: 'download',
}); });
assert.equal(path.extname(result.path), '.vtt'); assert.equal(path.extname(result.path), '.vtt');
@@ -204,7 +203,6 @@ test('downloadYoutubeSubtitleTrack ignores stale subtitle files from prior runs'
kind: 'auto', kind: 'auto',
label: 'Japanese (auto)', label: 'Japanese (auto)',
}, },
mode: 'download',
}), }),
/No subtitle file was downloaded/, /No subtitle file was downloaded/,
); );
@@ -233,7 +231,6 @@ test('downloadYoutubeSubtitleTrack uses auto subtitle flags and raw source langu
kind: 'auto', kind: 'auto',
label: 'Japanese (auto)', label: 'Japanese (auto)',
}, },
mode: 'download',
}); });
assert.equal(path.extname(result.path), '.vtt'); assert.equal(path.extname(result.path), '.vtt');
@@ -264,7 +261,6 @@ test('downloadYoutubeSubtitleTrack keeps manual subtitle flag for manual tracks'
kind: 'manual', kind: 'manual',
label: 'Japanese (manual)', label: 'Japanese (manual)',
}, },
mode: 'download',
}); });
assert.equal(path.extname(result.path), '.vtt'); assert.equal(path.extname(result.path), '.vtt');
@@ -273,6 +269,43 @@ test('downloadYoutubeSubtitleTrack keeps manual subtitle flag for manual tracks'
}); });
}); });
test('downloadYoutubeSubtitleTrack normalizes rolling auto-caption vtt output from yt-dlp', async () => {
if (process.platform === 'win32') {
return;
}
await withFakeYtDlp('rolling-auto', async (root) => {
const result = await downloadYoutubeSubtitleTrack({
targetUrl: 'https://www.youtube.com/watch?v=abc123',
outputDir: path.join(root, 'out'),
track: {
id: 'auto:ja-orig',
language: 'ja',
sourceLanguage: 'ja-orig',
kind: 'auto',
label: 'Japanese (auto)',
},
});
assert.equal(
fs.readFileSync(result.path, 'utf8'),
[
'WEBVTT',
'',
'00:00:01.000 --> 00:00:02.000',
'今日は',
'',
'00:00:02.000 --> 00:00:03.000',
'いい天気ですね',
'',
'00:00:03.000 --> 00:00:04.000',
'本当に',
'',
].join('\n'),
);
});
});
test('downloadYoutubeSubtitleTrack prefers direct download URL when available', async () => { test('downloadYoutubeSubtitleTrack prefers direct download URL when available', async () => {
await withTempDir(async (root) => { await withTempDir(async (root) => {
await withStubFetch( await withStubFetch(
@@ -293,7 +326,6 @@ test('downloadYoutubeSubtitleTrack prefers direct download URL when available',
downloadUrl: 'https://example.com/subs/ja.vtt', downloadUrl: 'https://example.com/subs/ja.vtt',
fileExtension: 'vtt', fileExtension: 'vtt',
}, },
mode: 'download',
}); });
assert.equal(path.basename(result.path), 'auto-ja-orig.ja-orig.vtt'); assert.equal(path.basename(result.path), 'auto-ja-orig.ja-orig.vtt');
@@ -320,7 +352,6 @@ test('downloadYoutubeSubtitleTrack sanitizes metadata source language in filenam
downloadUrl: 'https://example.com/subs/ja.vtt', downloadUrl: 'https://example.com/subs/ja.vtt',
fileExtension: 'vtt', fileExtension: 'vtt',
}, },
mode: 'download',
}); });
assert.equal(path.dirname(result.path), path.join(root, 'out')); assert.equal(path.dirname(result.path), path.join(root, 'out'));
@@ -359,7 +390,6 @@ test('downloadYoutubeSubtitleTrack converts srv3 auto subtitles into regular vtt
downloadUrl: 'https://example.com/subs/ja.srv3', downloadUrl: 'https://example.com/subs/ja.srv3',
fileExtension: 'srv3', fileExtension: 'srv3',
}, },
mode: 'download',
}); });
assert.equal(path.basename(result.path), 'auto-ja-orig.ja-orig.vtt'); assert.equal(path.basename(result.path), 'auto-ja-orig.ja-orig.vtt');
@@ -410,7 +440,6 @@ test('downloadYoutubeSubtitleTracks downloads primary and secondary in one invoc
label: 'English (auto)', label: 'English (auto)',
}, },
], ],
mode: 'download',
}); });
assert.match(path.basename(result.get('auto:ja-orig') ?? ''), /\.ja-orig\.vtt$/); assert.match(path.basename(result.get('auto:ja-orig') ?? ''), /\.ja-orig\.vtt$/);
@@ -444,7 +473,6 @@ test('downloadYoutubeSubtitleTracks preserves successfully downloaded primary fi
label: 'English (auto)', label: 'English (auto)',
}, },
], ],
mode: 'download',
}); });
assert.match(path.basename(result.get('auto:ja-orig') ?? ''), /\.ja-orig\.vtt$/); assert.match(path.basename(result.get('auto:ja-orig') ?? ''), /\.ja-orig\.vtt$/);
@@ -484,7 +512,6 @@ test('downloadYoutubeSubtitleTracks prefers direct download URLs when available'
fileExtension: 'vtt', fileExtension: 'vtt',
}, },
], ],
mode: 'download',
}); });
assert.deepEqual(seen, [ assert.deepEqual(seen, [
@@ -530,7 +557,6 @@ test('downloadYoutubeSubtitleTracks keeps duplicate source-language direct downl
fileExtension: 'vtt', fileExtension: 'vtt',
}, },
], ],
mode: 'download',
}); });
assert.deepEqual(seen, [ assert.deepEqual(seen, [

View File

@@ -1,9 +1,12 @@
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { spawn } from 'node:child_process'; import { spawn } from 'node:child_process';
import type { YoutubeFlowMode } from '../../../types';
import type { YoutubeTrackOption } from './track-probe'; import type { YoutubeTrackOption } from './track-probe';
import { convertYoutubeTimedTextToVtt, isYoutubeTimedTextExtension } from './timedtext'; import {
convertYoutubeTimedTextToVtt,
isYoutubeTimedTextExtension,
normalizeYoutubeAutoVtt,
} from './timedtext';
const YOUTUBE_SUBTITLE_EXTENSIONS = new Set(['.srt', '.vtt', '.ass']); const YOUTUBE_SUBTITLE_EXTENSIONS = new Set(['.srt', '.vtt', '.ass']);
const YOUTUBE_BATCH_PREFIX = 'youtube-batch'; const YOUTUBE_BATCH_PREFIX = 'youtube-batch';
@@ -171,7 +174,11 @@ async function downloadSubtitleFromUrl(input: {
throw new Error(`HTTP ${response.status} while downloading ${input.track.sourceLanguage}`); throw new Error(`HTTP ${response.status} while downloading ${input.track.sourceLanguage}`);
} }
const body = await response.text(); const body = await response.text();
const normalizedBody = isYoutubeTimedTextExtension(ext) ? convertYoutubeTimedTextToVtt(body) : body; const normalizedBody = isYoutubeTimedTextExtension(ext)
? convertYoutubeTimedTextToVtt(body)
: input.track.kind === 'auto' && safeExt === 'vtt'
? normalizeYoutubeAutoVtt(body)
: body;
fs.writeFileSync(targetPath, normalizedBody, 'utf8'); fs.writeFileSync(targetPath, normalizedBody, 'utf8');
return { path: targetPath }; return { path: targetPath };
} }
@@ -185,11 +192,21 @@ function canDownloadSubtitleFromUrl(track: YoutubeTrackOption): boolean {
return isYoutubeTimedTextExtension(ext) || YOUTUBE_SUBTITLE_EXTENSIONS.has(`.${ext}`); return isYoutubeTimedTextExtension(ext) || YOUTUBE_SUBTITLE_EXTENSIONS.has(`.${ext}`);
} }
function normalizeDownloadedAutoSubtitle(pathname: string, track: YoutubeTrackOption): void {
if (track.kind !== 'auto' || path.extname(pathname).toLowerCase() !== '.vtt') {
return;
}
const content = fs.readFileSync(pathname, 'utf8');
const normalized = normalizeYoutubeAutoVtt(content);
if (normalized !== content) {
fs.writeFileSync(pathname, normalized, 'utf8');
}
}
export async function downloadYoutubeSubtitleTrack(input: { export async function downloadYoutubeSubtitleTrack(input: {
targetUrl: string; targetUrl: string;
outputDir: string; outputDir: string;
track: YoutubeTrackOption; track: YoutubeTrackOption;
mode: YoutubeFlowMode;
}): Promise<{ path: string }> { }): Promise<{ path: string }> {
fs.mkdirSync(input.outputDir, { recursive: true }); fs.mkdirSync(input.outputDir, { recursive: true });
const prefix = input.track.id.replace(/[^a-z0-9_-]+/gi, '-'); const prefix = input.track.id.replace(/[^a-z0-9_-]+/gi, '-');
@@ -215,7 +232,7 @@ export async function downloadYoutubeSubtitleTrack(input: {
targetUrl: input.targetUrl, targetUrl: input.targetUrl,
outputTemplate, outputTemplate,
sourceLanguages: [input.track.sourceLanguage], sourceLanguages: [input.track.sourceLanguage],
includeAutoSubs: input.mode === 'generate' || input.track.kind === 'auto', includeAutoSubs: input.track.kind === 'auto',
includeManualSubs: input.track.kind === 'manual', includeManualSubs: input.track.kind === 'manual',
}), }),
]; ];
@@ -225,6 +242,7 @@ export async function downloadYoutubeSubtitleTrack(input: {
if (!subtitlePath) { if (!subtitlePath) {
throw new Error(`No subtitle file was downloaded for ${input.track.sourceLanguage}`); throw new Error(`No subtitle file was downloaded for ${input.track.sourceLanguage}`);
} }
normalizeDownloadedAutoSubtitle(subtitlePath, input.track);
return { path: subtitlePath }; return { path: subtitlePath };
} }
@@ -232,7 +250,6 @@ export async function downloadYoutubeSubtitleTracks(input: {
targetUrl: string; targetUrl: string;
outputDir: string; outputDir: string;
tracks: YoutubeTrackOption[]; tracks: YoutubeTrackOption[];
mode: YoutubeFlowMode;
}): Promise<Map<string, string>> { }): Promise<Map<string, string>> {
fs.mkdirSync(input.outputDir, { recursive: true }); fs.mkdirSync(input.outputDir, { recursive: true });
const hasDuplicateSourceLanguages = const hasDuplicateSourceLanguages =
@@ -260,8 +277,7 @@ export async function downloadYoutubeSubtitleTracks(input: {
} }
const outputTemplate = path.join(input.outputDir, `${YOUTUBE_BATCH_PREFIX}.%(ext)s`); const outputTemplate = path.join(input.outputDir, `${YOUTUBE_BATCH_PREFIX}.%(ext)s`);
const includeAutoSubs = const includeAutoSubs = input.tracks.some((track) => track.kind === 'auto');
input.mode === 'generate' || input.tracks.some((track) => track.kind === 'auto');
const includeManualSubs = input.tracks.some((track) => track.kind === 'manual'); const includeManualSubs = input.tracks.some((track) => track.kind === 'manual');
const result = await runCaptureDetailed( const result = await runCaptureDetailed(
@@ -283,6 +299,7 @@ export async function downloadYoutubeSubtitleTracks(input: {
track.sourceLanguage, track.sourceLanguage,
); );
if (subtitlePath) { if (subtitlePath) {
normalizeDownloadedAutoSubtitle(subtitlePath, track);
results.set(track.id, subtitlePath); results.set(track.id, subtitlePath);
} }
} }

View File

@@ -324,6 +324,10 @@ import {
shouldAutoOpenFirstRunSetup, shouldAutoOpenFirstRunSetup,
} from './main/runtime/first-run-setup-service'; } from './main/runtime/first-run-setup-service';
import { createYoutubeFlowRuntime } from './main/runtime/youtube-flow'; import { createYoutubeFlowRuntime } from './main/runtime/youtube-flow';
import {
clearYoutubePrimarySubtitleNotificationTimer,
createYoutubePrimarySubtitleNotificationRuntime,
} from './main/runtime/youtube-primary-subtitle-notification';
import { resolveAutoplayReadyMaxReleaseAttempts } from './main/runtime/startup-autoplay-release-policy'; import { resolveAutoplayReadyMaxReleaseAttempts } from './main/runtime/startup-autoplay-release-policy';
import { import {
buildFirstRunSetupHtml, buildFirstRunSetupHtml,
@@ -802,9 +806,6 @@ const appState = createAppState({
texthookerPort: DEFAULT_TEXTHOOKER_PORT, texthookerPort: DEFAULT_TEXTHOOKER_PORT,
}); });
const startBackgroundWarmupsIfAllowed = (): void => { const startBackgroundWarmupsIfAllowed = (): void => {
if (appState.youtubePlaybackFlowPending) {
return;
}
startBackgroundWarmups(); startBackgroundWarmups();
}; };
const youtubeFlowRuntime = createYoutubeFlowRuntime({ const youtubeFlowRuntime = createYoutubeFlowRuntime({
@@ -942,11 +943,9 @@ const youtubeFlowRuntime = createYoutubeFlowRuntime({
async function runYoutubePlaybackFlowMain(request: { async function runYoutubePlaybackFlowMain(request: {
url: string; url: string;
mode: 'download' | 'generate'; mode: NonNullable<CliArgs['youtubeMode']>;
source: CliCommandSource; source: CliCommandSource;
}): Promise<void> { }): Promise<void> {
const wasYoutubePlaybackFlowPending = appState.youtubePlaybackFlowPending;
appState.youtubePlaybackFlowPending = true;
if (process.platform === 'win32' && !appState.mpvClient?.connected) { if (process.platform === 'win32' && !appState.mpvClient?.connected) {
const launchResult = launchWindowsMpv( const launchResult = launchWindowsMpv(
[request.url], [request.url],
@@ -969,18 +968,12 @@ async function runYoutubePlaybackFlowMain(request: {
if (!appState.mpvClient?.connected) { if (!appState.mpvClient?.connected) {
appState.mpvClient?.connect(); appState.mpvClient?.connect();
} }
try {
await youtubeFlowRuntime.runYoutubePlaybackFlow({ await youtubeFlowRuntime.runYoutubePlaybackFlow({
url: request.url, url: request.url,
mode: request.mode, mode: request.mode,
}); });
logger.info(`YouTube playback flow completed from ${request.source}.`); logger.info(`YouTube playback flow completed from ${request.source}.`);
} finally {
appState.youtubePlaybackFlowPending = wasYoutubePlaybackFlowPending;
if (!wasYoutubePlaybackFlowPending) {
startBackgroundWarmupsIfAllowed();
}
}
} }
let firstRunSetupMessage: string | null = null; let firstRunSetupMessage: string | null = null;
@@ -1236,6 +1229,12 @@ const currentMediaTokenizationGate = createCurrentMediaTokenizationGate();
const startupOsdSequencer = createStartupOsdSequencer({ const startupOsdSequencer = createStartupOsdSequencer({
showOsd: (message) => showMpvOsd(message), showOsd: (message) => showMpvOsd(message),
}); });
const youtubePrimarySubtitleNotificationRuntime = createYoutubePrimarySubtitleNotificationRuntime({
getPrimarySubtitleLanguages: () => getResolvedConfig().youtubeSubgen.primarySubLanguages,
notifyFailure: (message) => reportYoutubeSubtitleFailure(message),
schedule: (fn, delayMs) => setTimeout(fn, delayMs),
clearSchedule: clearYoutubePrimarySubtitleNotificationTimer,
});
function isYoutubePlaybackActiveNow(): boolean { function isYoutubePlaybackActiveNow(): boolean {
return isYoutubePlaybackActive( return isYoutubePlaybackActive(
@@ -1267,7 +1266,6 @@ async function openYoutubeTrackPickerFromPlayback(): Promise<void> {
} }
await youtubeFlowRuntime.openManualPicker({ await youtubeFlowRuntime.openManualPicker({
url: currentMediaPath, url: currentMediaPath,
mode: 'download',
}); });
} }
@@ -1275,9 +1273,6 @@ function maybeSignalPluginAutoplayReady(
payload: SubtitleData, payload: SubtitleData,
options?: { forceWhilePaused?: boolean }, options?: { forceWhilePaused?: boolean },
): void { ): void {
if (appState.youtubePlaybackFlowPending) {
return;
}
if (!payload.text.trim()) { if (!payload.text.trim()) {
return; return;
} }
@@ -3544,6 +3539,7 @@ const {
startupOsdSequencer.reset(); startupOsdSequencer.reset();
clearScheduledSubtitlePrefetchRefresh(); clearScheduledSubtitlePrefetchRefresh();
subtitlePrefetchInitController.cancelPendingInit(); subtitlePrefetchInitController.cancelPendingInit();
youtubePrimarySubtitleNotificationRuntime.handleMediaPathChange(path);
if (path) { if (path) {
ensureImmersionTrackerStarted(); ensureImmersionTrackerStarted();
// Delay slightly to allow MPV's track-list to be populated. // Delay slightly to allow MPV's track-list to be populated.
@@ -3571,9 +3567,6 @@ const {
immersionMediaRuntime.syncFromCurrentMediaState(); immersionMediaRuntime.syncFromCurrentMediaState();
}, },
signalAutoplayReadyIfWarm: () => { signalAutoplayReadyIfWarm: () => {
if (appState.youtubePlaybackFlowPending) {
return;
}
if (!isTokenizationWarmupReady()) { if (!isTokenizationWarmupReady()) {
return; return;
} }
@@ -3604,11 +3597,13 @@ const {
} }
lastObservedTimePos = time; lastObservedTimePos = time;
}, },
onSubtitleTrackChange: () => { onSubtitleTrackChange: (sid) => {
scheduleSubtitlePrefetchRefresh(); scheduleSubtitlePrefetchRefresh();
youtubePrimarySubtitleNotificationRuntime.handleSubtitleTrackChange(sid);
}, },
onSubtitleTrackListChange: () => { onSubtitleTrackListChange: (trackList) => {
scheduleSubtitlePrefetchRefresh(); scheduleSubtitlePrefetchRefresh();
youtubePrimarySubtitleNotificationRuntime.handleSubtitleTrackListChange(trackList);
}, },
updateSubtitleRenderMetrics: (patch) => { updateSubtitleRenderMetrics: (patch) => {
updateMpvSubtitleRenderMetrics(patch as Partial<MpvSubtitleRenderMetrics>); updateMpvSubtitleRenderMetrics(patch as Partial<MpvSubtitleRenderMetrics>);
@@ -4867,10 +4862,7 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
appState.overlayRuntimeInitialized = initialized; appState.overlayRuntimeInitialized = initialized;
}, },
startBackgroundWarmups: () => { startBackgroundWarmups: () => {
if ( if (appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)) {
(appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)) ||
appState.youtubePlaybackFlowPending
) {
return; return;
} }
startBackgroundWarmups(); startBackgroundWarmups();

View File

@@ -1,6 +1,5 @@
import { handleCliCommand, createCliCommandDepsRuntime } from '../core/services'; import { handleCliCommand, createCliCommandDepsRuntime } from '../core/services';
import type { CliArgs, CliCommandSource } from '../cli/args'; import type { CliArgs, CliCommandSource } from '../cli/args';
import type { YoutubeFlowMode } from '../types';
import { import {
createCliCommandRuntimeServiceDeps, createCliCommandRuntimeServiceDeps,
CliCommandRuntimeServiceDepsParams, CliCommandRuntimeServiceDepsParams,
@@ -39,11 +38,7 @@ export interface CliCommandRuntimeServiceContext {
openJellyfinSetup: CliCommandRuntimeServiceDepsParams['jellyfin']['openSetup']; openJellyfinSetup: CliCommandRuntimeServiceDepsParams['jellyfin']['openSetup'];
runStatsCommand: CliCommandRuntimeServiceDepsParams['jellyfin']['runStatsCommand']; runStatsCommand: CliCommandRuntimeServiceDepsParams['jellyfin']['runStatsCommand'];
runJellyfinCommand: CliCommandRuntimeServiceDepsParams['jellyfin']['runCommand']; runJellyfinCommand: CliCommandRuntimeServiceDepsParams['jellyfin']['runCommand'];
runYoutubePlaybackFlow: (request: { runYoutubePlaybackFlow: CliCommandRuntimeServiceDepsParams['app']['runYoutubePlaybackFlow'];
url: string;
mode: YoutubeFlowMode;
source: CliCommandSource;
}) => Promise<void>;
openYomitanSettings: () => void; openYomitanSettings: () => void;
cycleSecondarySubMode: () => void; cycleSecondarySubMode: () => void;
openRuntimeOptionsPalette: () => void; openRuntimeOptionsPalette: () => void;

View File

@@ -61,7 +61,7 @@ test('build cli command context deps maps handlers and values', () => {
calls.push('run-jellyfin'); calls.push('run-jellyfin');
}, },
runYoutubePlaybackFlow: async () => { runYoutubePlaybackFlow: async () => {
calls.push('run-youtube'); calls.push('run-youtube-playback');
}, },
openYomitanSettings: () => calls.push('yomitan'), openYomitanSettings: () => calls.push('yomitan'),
cycleSecondarySubMode: () => calls.push('cycle-secondary'), cycleSecondarySubMode: () => calls.push('cycle-secondary'),

View File

@@ -9,7 +9,6 @@ test('cli command context factory composes main deps and context handlers', () =
mpvClient: null, mpvClient: null,
texthookerPort: 5174, texthookerPort: 5174,
overlayRuntimeInitialized: false, overlayRuntimeInitialized: false,
youtubePlaybackFlowPending: false,
}; };
const createContext = createCliCommandContextFactory({ const createContext = createCliCommandContextFactory({

View File

@@ -9,7 +9,6 @@ test('cli command context main deps builder maps state and callbacks', async ()
mpvClient: null, mpvClient: null,
texthookerPort: 5174, texthookerPort: 5174,
overlayRuntimeInitialized: false, overlayRuntimeInitialized: false,
youtubePlaybackFlowPending: false,
}; };
const build = createBuildCliCommandContextMainDepsHandler({ const build = createBuildCliCommandContextMainDepsHandler({
@@ -86,9 +85,8 @@ test('cli command context main deps builder maps state and callbacks', async ()
calls.push('run-jellyfin'); calls.push('run-jellyfin');
}, },
runYoutubePlaybackFlow: async () => { runYoutubePlaybackFlow: async () => {
calls.push('run-youtube'); calls.push('run-youtube-playback');
}, },
openYomitanSettings: () => calls.push('open-yomitan'), openYomitanSettings: () => calls.push('open-yomitan'),
cycleSecondarySubMode: () => calls.push('cycle-secondary'), cycleSecondarySubMode: () => calls.push('cycle-secondary'),
openRuntimeOptionsPalette: () => calls.push('open-runtime-options'), openRuntimeOptionsPalette: () => calls.push('open-runtime-options'),

View File

@@ -1,5 +1,4 @@
import type { CliArgs } from '../../cli/args'; import type { CliArgs } from '../../cli/args';
import type { YoutubeFlowMode } from '../../types';
import type { CliCommandContextFactoryDeps } from './cli-command-context'; import type { CliCommandContextFactoryDeps } from './cli-command-context';
type CliCommandContextMainState = { type CliCommandContextMainState = {
@@ -42,11 +41,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
generateCharacterDictionary: CliCommandContextFactoryDeps['generateCharacterDictionary']; generateCharacterDictionary: CliCommandContextFactoryDeps['generateCharacterDictionary'];
runStatsCommand: CliCommandContextFactoryDeps['runStatsCommand']; runStatsCommand: CliCommandContextFactoryDeps['runStatsCommand'];
runJellyfinCommand: (args: CliArgs) => Promise<void>; runJellyfinCommand: (args: CliArgs) => Promise<void>;
runYoutubePlaybackFlow: (request: { runYoutubePlaybackFlow: CliCommandContextFactoryDeps['runYoutubePlaybackFlow'];
url: string;
mode: YoutubeFlowMode;
source: 'initial' | 'second-instance';
}) => Promise<void>;
openYomitanSettings: () => void; openYomitanSettings: () => void;
cycleSecondarySubMode: () => void; cycleSecondarySubMode: () => void;

View File

@@ -1,5 +1,4 @@
import type { CliArgs } from '../../cli/args'; import type { CliArgs } from '../../cli/args';
import type { YoutubeFlowMode } from '../../types';
import type { import type {
CliCommandRuntimeServiceContext, CliCommandRuntimeServiceContext,
CliCommandRuntimeServiceContextHandlers, CliCommandRuntimeServiceContextHandlers,
@@ -42,11 +41,7 @@ export type CliCommandContextFactoryDeps = {
generateCharacterDictionary: CliCommandRuntimeServiceContext['generateCharacterDictionary']; generateCharacterDictionary: CliCommandRuntimeServiceContext['generateCharacterDictionary'];
runStatsCommand: CliCommandRuntimeServiceContext['runStatsCommand']; runStatsCommand: CliCommandRuntimeServiceContext['runStatsCommand'];
runJellyfinCommand: (args: CliArgs) => Promise<void>; runJellyfinCommand: (args: CliArgs) => Promise<void>;
runYoutubePlaybackFlow: (request: { runYoutubePlaybackFlow: CliCommandRuntimeServiceContext['runYoutubePlaybackFlow'];
url: string;
mode: YoutubeFlowMode;
source: 'initial' | 'second-instance';
}) => Promise<void>;
openYomitanSettings: () => void; openYomitanSettings: () => void;
cycleSecondarySubMode: () => void; cycleSecondarySubMode: () => void;
openRuntimeOptionsPalette: () => void; openRuntimeOptionsPalette: () => void;

View File

@@ -19,507 +19,13 @@ const secondaryTrack: YoutubeTrackOption = {
label: 'English (manual)', label: 'English (manual)',
}; };
test('youtube flow auto-loads default primary+secondary subtitles without opening the picker', async () => {
const commands: Array<Array<string | number>> = [];
const osdMessages: string[] = [];
const order: string[] = [];
const refreshedSubtitles: string[] = [];
const focusOverlayCalls: string[] = [];
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 () => {
throw new Error('startup auto-load should not wait for modal window readiness');
},
waitForOverlayGeometryReady: async () => {
throw new Error('startup auto-load should not wait for modal overlay geometry');
},
focusOverlayWindow: () => {
focusOverlayCalls.push('focus-overlay');
},
openPicker: async () => {
throw new Error('startup auto-load should not open the picker');
},
reportSubtitleFailure: () => {
throw new Error('startup auto-load should not report failure on success');
},
pauseMpv: () => {
commands.push(['set_property', 'pause', 'yes']);
},
resumeMpv: () => {
commands.push(['set_property', 'pause', 'no']);
},
sendMpvCommand: (command) => {
commands.push(command);
},
requestMpvProperty: async (name: string) => {
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 () => {},
showMpvOsd: (text) => {
osdMessages.push(text);
},
warn: (message: string) => {
throw new Error(message);
},
log: () => {},
getYoutubeOutputDir: () => '/tmp',
});
await runtime.runYoutubePlaybackFlow({ url: 'https://example.com', mode: 'download' });
assert.deepEqual(order, [
'start-tokenization-warmups',
'wait-tokenization-ready',
'wait-anki-ready',
]);
assert.deepEqual(osdMessages, [
'Opening YouTube video',
'Getting subtitles...',
'Loading subtitles...',
'Primary and secondary subtitles loaded.',
]);
assert.deepEqual(commands, [
['set_property', 'pause', 'yes'],
['set_property', 'sub-auto', 'no'],
['set_property', 'sid', 'no'],
['set_property', 'secondary-sid', 'no'],
['set_property', 'sub-visibility', 'no'],
['set_property', 'secondary-sub-visibility', 'no'],
['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 refreshes parsed subtitle cues from the resolved primary subtitle path after auto-load', async () => {
const refreshedSidebarSources: string[] = [];
let trackListRequests = 0;
const runtime = createYoutubeFlowRuntime({
probeYoutubeTracks: async () => ({
videoId: 'video123',
title: 'Video 123',
tracks: [primaryTrack],
}),
acquireYoutubeSubtitleTracks: async () => {
throw new Error('single-track auto-load should not batch acquire');
},
acquireYoutubeSubtitleTrack: async () => ({ path: '/tmp/auto-ja-orig.vtt' }),
retimeYoutubePrimaryTrack: async () => '/tmp/auto-ja-orig_retimed.vtt',
startTokenizationWarmups: async () => {},
waitForTokenizationReady: async () => {},
waitForAnkiReady: async () => {},
waitForPlaybackWindowReady: async () => {},
waitForOverlayGeometryReady: async () => {},
focusOverlayWindow: () => {},
openPicker: async () => false,
reportSubtitleFailure: () => {
throw new Error('primary subtitle should load successfully');
},
pauseMpv: () => {},
resumeMpv: () => {},
sendMpvCommand: () => {},
requestMpvProperty: async (name: string) => {
if (name === 'sub-text') {
return '字幕です';
}
assert.equal(name, 'track-list');
trackListRequests += 1;
return [
{
type: 'sub',
id: 5,
lang: 'ja-orig',
title: 'primary',
external: true,
'external-filename': '/tmp/auto-ja-orig_retimed.vtt',
},
];
},
refreshCurrentSubtitle: () => {},
refreshSubtitleSidebarSource: async (sourcePath: string) => {
refreshedSidebarSources.push(sourcePath);
},
wait: async () => {},
showMpvOsd: () => {},
warn: (message: string) => {
throw new Error(message);
},
log: () => {},
getYoutubeOutputDir: () => '/tmp',
} as never);
await runtime.runYoutubePlaybackFlow({ url: 'https://example.com', mode: 'download' });
assert.equal(trackListRequests > 0, true);
assert.deepEqual(refreshedSidebarSources, ['/tmp/auto-ja-orig_retimed.vtt']);
});
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 () => {
throw new Error('startup auto-load should not wait for modal window readiness');
},
waitForOverlayGeometryReady: async () => {
throw new Error('startup auto-load should not wait for modal overlay geometry');
},
focusOverlayWindow: () => {
focusOverlayCalls.push('focus-overlay');
},
openPicker: async () => {
throw new Error('startup auto-load should not open the picker');
},
reportSubtitleFailure: () => {
throw new Error('secondary retry should not report primary failure');
},
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(350));
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 () => {
throw new Error('startup auto-load should not wait for modal window readiness');
},
waitForOverlayGeometryReady: async () => {
throw new Error('startup auto-load should not wait for modal overlay geometry');
},
focusOverlayWindow: () => {
releaseOrder.push('focus-overlay');
},
openPicker: async () => {
throw new Error('startup auto-load should not open the picker');
},
reportSubtitleFailure: () => {
throw new Error('successful auto-load should not report failure');
},
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',
]);
});
test('youtube flow reports primary auto-load failure through the configured reporter when the primary subtitle never binds', async () => {
const commands: Array<Array<string | number>> = [];
const warns: string[] = [];
const reportedFailures: string[] = [];
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 () => {},
waitForTokenizationReady: async () => {
throw new Error('bind failure should not wait for tokenization readiness');
},
waitForAnkiReady: async () => {},
waitForPlaybackWindowReady: async () => {
throw new Error('startup auto-load should not wait for modal window readiness');
},
waitForOverlayGeometryReady: async () => {
throw new Error('startup auto-load should not wait for modal overlay geometry');
},
focusOverlayWindow: () => {},
openPicker: async () => {
throw new Error('startup auto-load should not open the picker');
},
reportSubtitleFailure: (message) => {
reportedFailures.push(message);
},
pauseMpv: () => {},
resumeMpv: () => {},
sendMpvCommand: (command) => {
commands.push(command);
},
requestMpvProperty: async (name) => {
if (name === 'track-list') {
return [];
}
throw new Error(`unexpected property request: ${name}`);
},
refreshCurrentSubtitle: () => {
throw new Error('should not refresh subtitle text on bind failure');
},
wait: async () => {},
showMpvOsd: () => {},
warn: (message) => {
warns.push(message);
},
log: () => {},
getYoutubeOutputDir: () => '/tmp',
});
await runtime.runYoutubePlaybackFlow({ url: 'https://example.com', mode: 'download' });
assert.equal(
commands.some((command) => command[0] === 'set_property' && command[1] === 'sid' && command[2] !== 'no'),
false,
);
assert.deepEqual(reportedFailures, [
'Primary subtitles failed to load. Use the YouTube subtitle picker to try manually.',
]);
assert.equal(warns.some((message) => message.includes('Unable to bind downloaded primary subtitle track')), true);
});
test('youtube flow can open a manual picker session and load the selected subtitles', async () => { test('youtube flow can open a manual picker session and load the selected subtitles', async () => {
const commands: Array<Array<string | number>> = []; const commands: Array<Array<string | number>> = [];
const focusOverlayCalls: string[] = []; const focusOverlayCalls: string[] = [];
const osdMessages: string[] = []; const osdMessages: string[] = [];
const openedPayloads: YoutubePickerOpenPayload[] = []; const openedPayloads: YoutubePickerOpenPayload[] = [];
const waits: number[] = []; const waits: number[] = [];
const refreshedSidebarSources: string[] = [];
const runtime = createYoutubeFlowRuntime({ const runtime = createYoutubeFlowRuntime({
probeYoutubeTracks: async () => ({ probeYoutubeTracks: async () => ({
@@ -539,18 +45,6 @@ test('youtube flow can open a manual picker session and load the selected subtit
}, },
acquireYoutubeSubtitleTrack: async ({ track }) => ({ path: `/tmp/${track.id}.vtt` }), acquireYoutubeSubtitleTrack: async ({ track }) => ({ path: `/tmp/${track.id}.vtt` }),
retimeYoutubePrimaryTrack: async ({ primaryPath }) => `${primaryPath}.retimed`, retimeYoutubePrimaryTrack: async ({ primaryPath }) => `${primaryPath}.retimed`,
startTokenizationWarmups: async () => {},
waitForTokenizationReady: async () => {},
waitForAnkiReady: async () => {},
waitForPlaybackWindowReady: async () => {
waits.push(1);
},
waitForOverlayGeometryReady: async () => {
waits.push(2);
},
focusOverlayWindow: () => {
focusOverlayCalls.push('focus-overlay');
},
openPicker: async (payload) => { openPicker: async (payload) => {
openedPayloads.push(payload); openedPayloads.push(payload);
queueMicrotask(() => { queueMicrotask(() => {
@@ -563,15 +57,8 @@ test('youtube flow can open a manual picker session and load the selected subtit
}); });
return true; return true;
}, },
reportSubtitleFailure: () => { pauseMpv: () => {},
throw new Error('manual picker success should not report failure'); resumeMpv: () => {},
},
pauseMpv: () => {
throw new Error('manual picker should not pause playback');
},
resumeMpv: () => {
throw new Error('manual picker should not resume playback');
},
sendMpvCommand: (command) => { sendMpvCommand: (command) => {
commands.push(command); commands.push(command);
}, },
@@ -599,12 +86,30 @@ test('youtube flow can open a manual picker session and load the selected subtit
]; ];
}, },
refreshCurrentSubtitle: () => {}, refreshCurrentSubtitle: () => {},
refreshSubtitleSidebarSource: async (sourcePath: string) => {
refreshedSidebarSources.push(sourcePath);
},
startTokenizationWarmups: async () => {},
waitForTokenizationReady: async () => {},
waitForAnkiReady: async () => {},
wait: async (ms) => { wait: async (ms) => {
waits.push(ms); waits.push(ms);
}, },
waitForPlaybackWindowReady: async () => {
waits.push(1);
},
waitForOverlayGeometryReady: async () => {
waits.push(2);
},
focusOverlayWindow: () => {
focusOverlayCalls.push('focus-overlay');
},
showMpvOsd: (text) => { showMpvOsd: (text) => {
osdMessages.push(text); osdMessages.push(text);
}, },
reportSubtitleFailure: () => {
throw new Error('manual picker success should not report failure');
},
warn: (message) => { warn: (message) => {
throw new Error(message); throw new Error(message);
}, },
@@ -612,7 +117,7 @@ test('youtube flow can open a manual picker session and load the selected subtit
getYoutubeOutputDir: () => '/tmp', getYoutubeOutputDir: () => '/tmp',
}); });
await runtime.openManualPicker({ url: 'https://example.com', mode: 'download' }); await runtime.openManualPicker({ url: 'https://example.com' });
assert.equal(openedPayloads.length, 1); assert.equal(openedPayloads.length, 1);
assert.equal(openedPayloads[0]?.defaultPrimaryTrackId, primaryTrack.id); assert.equal(openedPayloads[0]?.defaultPrimaryTrackId, primaryTrack.id);
@@ -632,5 +137,314 @@ test('youtube flow can open a manual picker session and load the selected subtit
command[2] === 'select', command[2] === 'select',
), ),
); );
assert.ok(
commands.some(
(command) =>
command[0] === 'set_property' &&
command[1] === 'sub-visibility' &&
command[2] === 'yes',
),
);
assert.ok(
commands.some(
(command) =>
command[0] === 'set_property' &&
command[1] === 'secondary-sub-visibility' &&
command[2] === 'yes',
),
);
assert.deepEqual(refreshedSidebarSources, ['/tmp/auto-ja-orig.vtt.retimed']);
assert.deepEqual(focusOverlayCalls, ['focus-overlay']); 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 waits: number[] = [];
const runtime = createYoutubeFlowRuntime({
probeYoutubeTracks: async () => ({
videoId: 'video123',
title: 'Video 123',
tracks: [primaryTrack, secondaryTrack],
}),
acquireYoutubeSubtitleTracks: async () =>
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 }) => primaryPath,
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 '字幕です';
}
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: () => {},
startTokenizationWarmups: async () => {},
waitForTokenizationReady: async () => {},
waitForAnkiReady: async () => {},
wait: async (ms) => {
waits.push(ms);
},
waitForPlaybackWindowReady: async () => {},
waitForOverlayGeometryReady: async () => {},
focusOverlayWindow: () => {},
showMpvOsd: () => {},
reportSubtitleFailure: () => {
throw new Error('secondary retry should not report primary failure');
},
warn: (message) => {
throw new Error(message);
},
log: () => {},
getYoutubeOutputDir: () => '/tmp',
});
await runtime.openManualPicker({ url: 'https://example.com' });
assert.deepEqual(acquireSingleCalls, [secondaryTrack.id]);
assert.ok(waits.includes(350));
assert.ok(
commands.some(
(command) =>
command[0] === 'sub-add' &&
command[1] === '/tmp/manual:en.vtt' &&
command[2] === 'cached',
),
);
});
test('youtube flow reports probe failure through the configured reporter in manual mode', async () => {
const failures: string[] = [];
const runtime = createYoutubeFlowRuntime({
probeYoutubeTracks: async () => {
throw new Error('probe failed');
},
acquireYoutubeSubtitleTracks: async () => new Map(),
acquireYoutubeSubtitleTrack: async () => ({ path: '/tmp/unused.vtt' }),
retimeYoutubePrimaryTrack: async ({ primaryPath }) => primaryPath,
openPicker: async () => true,
pauseMpv: () => {},
resumeMpv: () => {},
sendMpvCommand: () => {},
requestMpvProperty: async () => 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',
});
await runtime.openManualPicker({ url: 'https://example.com' });
assert.deepEqual(failures, [
'Primary subtitles failed to load. Use the YouTube subtitle picker to try manually.',
]);
});
test('youtube flow does not report failure when subtitle track binds before cue text appears', async () => {
const failures: string[] = [];
const runtime = createYoutubeFlowRuntime({
probeYoutubeTracks: async () => ({
videoId: 'video123',
title: 'Video 123',
tracks: [primaryTrack],
}),
acquireYoutubeSubtitleTracks: async () => new Map(),
acquireYoutubeSubtitleTrack: async () => ({ path: '/tmp/auto-ja-orig.vtt' }),
retimeYoutubePrimaryTrack: async ({ primaryPath }) => primaryPath,
openPicker: async (payload) => {
queueMicrotask(() => {
void runtime.resolveActivePicker({
sessionId: payload.sessionId,
action: 'use-selected',
primaryTrackId: primaryTrack.id,
secondaryTrackId: null,
});
});
return true;
},
pauseMpv: () => {},
resumeMpv: () => {},
sendMpvCommand: () => {},
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: () => {
throw new Error('should not refresh empty subtitle text');
},
startTokenizationWarmups: async () => {},
waitForTokenizationReady: async () => {},
waitForAnkiReady: async () => {},
wait: async () => {},
waitForPlaybackWindowReady: async () => {},
waitForOverlayGeometryReady: async () => {},
focusOverlayWindow: () => {},
showMpvOsd: () => {},
reportSubtitleFailure: (message) => {
failures.push(message);
},
warn: (message) => {
throw new Error(message);
},
log: () => {},
getYoutubeOutputDir: () => '/tmp',
});
await runtime.openManualPicker({ url: 'https://example.com' });
assert.deepEqual(failures, []);
});
test('youtube flow retries secondary subtitle selection until mpv reports the expected secondary sid', async () => {
const commands: Array<Array<string | number>> = [];
const waits: number[] = [];
let secondarySidReads = 0;
const runtime = createYoutubeFlowRuntime({
probeYoutubeTracks: async () => ({
videoId: 'video123',
title: 'Video 123',
tracks: [primaryTrack, secondaryTrack],
}),
acquireYoutubeSubtitleTracks: async () =>
new Map<string, string>([
[primaryTrack.id, '/tmp/auto-ja-orig.vtt'],
[secondaryTrack.id, '/tmp/manual-en.vtt'],
]),
acquireYoutubeSubtitleTrack: async ({ track }) => ({ path: `/tmp/${track.id}.vtt` }),
retimeYoutubePrimaryTrack: async ({ primaryPath }) => primaryPath,
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 '字幕です';
}
if (name === 'secondary-sid') {
secondarySidReads += 1;
return secondarySidReads >= 2 ? 6 : null;
}
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: 'English',
external: true,
'external-filename': null,
},
];
},
refreshCurrentSubtitle: () => {},
startTokenizationWarmups: async () => {},
waitForTokenizationReady: async () => {},
waitForAnkiReady: async () => {},
wait: async (ms) => {
waits.push(ms);
},
waitForPlaybackWindowReady: async () => {},
waitForOverlayGeometryReady: async () => {},
focusOverlayWindow: () => {},
showMpvOsd: () => {},
reportSubtitleFailure: () => {
throw new Error('secondary selection retry should not report failure');
},
warn: (message) => {
throw new Error(message);
},
log: () => {},
getYoutubeOutputDir: () => '/tmp',
});
await runtime.openManualPicker({ url: 'https://example.com' });
assert.equal(
commands.filter(
(command) =>
command[0] === 'set_property' && command[1] === 'secondary-sid' && command[2] === 6,
).length,
2,
);
assert.ok(waits.includes(100));
});

View File

@@ -1,7 +1,6 @@
import os from 'node:os'; import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import type { import type {
YoutubeFlowMode,
YoutubePickerOpenPayload, YoutubePickerOpenPayload,
YoutubePickerResolveRequest, YoutubePickerResolveRequest,
YoutubePickerResolveResult, YoutubePickerResolveResult,
@@ -21,6 +20,7 @@ import {
import { resolveSubtitleSourcePath } from './subtitle-prefetch-source'; import { resolveSubtitleSourcePath } from './subtitle-prefetch-source';
type YoutubeFlowOpenPicker = (payload: YoutubePickerOpenPayload) => Promise<boolean>; type YoutubeFlowOpenPicker = (payload: YoutubePickerOpenPayload) => Promise<boolean>;
type YoutubeFlowMode = 'download' | 'generate';
type YoutubeFlowDeps = { type YoutubeFlowDeps = {
probeYoutubeTracks: (url: string) => Promise<YoutubeTrackProbeResult>; probeYoutubeTracks: (url: string) => Promise<YoutubeTrackProbeResult>;
@@ -134,6 +134,22 @@ function parseTrackId(value: unknown): number | null {
return null; return null;
} }
async function ensureSubtitleTrackSelection(input: {
deps: YoutubeFlowDeps;
property: 'sid' | 'secondary-sid';
targetId: number;
}): Promise<void> {
input.deps.sendMpvCommand(['set_property', input.property, input.targetId]);
for (let attempt = 0; attempt < 4; attempt += 1) {
const currentId = parseTrackId(await input.deps.requestMpvProperty(input.property));
if (currentId === input.targetId) {
return;
}
await input.deps.wait(100);
input.deps.sendMpvCommand(['set_property', input.property, input.targetId]);
}
}
function normalizeTrackListEntry(track: Record<string, unknown>): { function normalizeTrackListEntry(track: Record<string, unknown>): {
id: number | null; id: number | null;
lang: string; lang: string;
@@ -252,7 +268,12 @@ async function injectDownloadedSubtitles(
} }
if (primaryTrackId !== null) { if (primaryTrackId !== null) {
deps.sendMpvCommand(['set_property', 'sid', primaryTrackId]); await ensureSubtitleTrackSelection({
deps,
property: 'sid',
targetId: primaryTrackId,
});
deps.sendMpvCommand(['set_property', 'sub-visibility', 'yes']);
} else { } else {
deps.warn( deps.warn(
`Unable to bind downloaded primary subtitle track in mpv: ${path.basename(primaryPath)}`, `Unable to bind downloaded primary subtitle track in mpv: ${path.basename(primaryPath)}`,
@@ -260,7 +281,12 @@ async function injectDownloadedSubtitles(
} }
if (secondaryPath && secondaryTrack) { if (secondaryPath && secondaryTrack) {
if (secondaryTrackId !== null) { if (secondaryTrackId !== null) {
deps.sendMpvCommand(['set_property', 'secondary-sid', secondaryTrackId]); await ensureSubtitleTrackSelection({
deps,
property: 'secondary-sid',
targetId: secondaryTrackId,
});
deps.sendMpvCommand(['set_property', 'secondary-sub-visibility', 'yes']);
} else { } else {
deps.warn( deps.warn(
`Unable to bind downloaded secondary subtitle track in mpv: ${path.basename(secondaryPath)}`, `Unable to bind downloaded secondary subtitle track in mpv: ${path.basename(secondaryPath)}`,
@@ -280,7 +306,7 @@ async function injectDownloadedSubtitles(
deps.showMpvOsd( deps.showMpvOsd(
secondaryPath && secondaryTrack ? 'Primary and secondary subtitles loaded.' : 'Subtitles loaded.', secondaryPath && secondaryTrack ? 'Primary and secondary subtitles loaded.' : 'Subtitles loaded.',
); );
return typeof currentSubText === 'string' && currentSubText.trim().length > 0; return true;
} }
export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) { export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
@@ -291,7 +317,6 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
outputDir: string; outputDir: string;
primaryTrack: YoutubeTrackOption; primaryTrack: YoutubeTrackOption;
secondaryTrack: YoutubeTrackOption | null; secondaryTrack: YoutubeTrackOption | null;
mode: YoutubeFlowMode;
secondaryFailureLabel: string; secondaryFailureLabel: string;
}): Promise<{ primaryPath: string; secondaryPath: string | null }> => { }): Promise<{ primaryPath: string; secondaryPath: string | null }> => {
if (!input.secondaryTrack) { if (!input.secondaryTrack) {
@@ -300,7 +325,6 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
targetUrl: input.targetUrl, targetUrl: input.targetUrl,
outputDir: input.outputDir, outputDir: input.outputDir,
track: input.primaryTrack, track: input.primaryTrack,
mode: input.mode,
}) })
).path; ).path;
return { primaryPath, secondaryPath: null }; return { primaryPath, secondaryPath: null };
@@ -311,7 +335,6 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
targetUrl: input.targetUrl, targetUrl: input.targetUrl,
outputDir: input.outputDir, outputDir: input.outputDir,
tracks: [input.primaryTrack, input.secondaryTrack], tracks: [input.primaryTrack, input.secondaryTrack],
mode: input.mode,
}); });
const primaryPath = batchResult.get(input.primaryTrack.id) ?? null; const primaryPath = batchResult.get(input.primaryTrack.id) ?? null;
const secondaryPath = batchResult.get(input.secondaryTrack.id) ?? null; const secondaryPath = batchResult.get(input.secondaryTrack.id) ?? null;
@@ -332,7 +355,6 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
targetUrl: input.targetUrl, targetUrl: input.targetUrl,
outputDir: input.outputDir, outputDir: input.outputDir,
track: input.secondaryTrack, track: input.secondaryTrack,
mode: input.mode,
}) })
).path; ).path;
return { primaryPath, secondaryPath: retriedSecondaryPath }; return { primaryPath, secondaryPath: retriedSecondaryPath };
@@ -355,7 +377,6 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
targetUrl: input.targetUrl, targetUrl: input.targetUrl,
outputDir: input.outputDir, outputDir: input.outputDir,
track: input.primaryTrack, track: input.primaryTrack,
mode: input.mode,
}) })
).path; ).path;
return { primaryPath, secondaryPath: null }; return { primaryPath, secondaryPath: null };
@@ -403,7 +424,6 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
const buildOpenPayload = ( const buildOpenPayload = (
input: { input: {
url: string; url: string;
mode: YoutubeFlowMode;
}, },
probe: YoutubeTrackProbeResult, probe: YoutubeTrackProbeResult,
): YoutubePickerOpenPayload => { ): YoutubePickerOpenPayload => {
@@ -411,7 +431,6 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
return { return {
sessionId: createSessionId(), sessionId: createSessionId(),
url: input.url, url: input.url,
mode: input.mode,
tracks: probe.tracks, tracks: probe.tracks,
defaultPrimaryTrackId: defaults.primaryTrackId, defaultPrimaryTrackId: defaults.primaryTrackId,
defaultSecondaryTrackId: defaults.secondaryTrackId, defaultSecondaryTrackId: defaults.secondaryTrackId,
@@ -441,7 +460,6 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
outputDir: input.outputDir, outputDir: input.outputDir,
primaryTrack: input.primaryTrack, primaryTrack: input.primaryTrack,
secondaryTrack: input.secondaryTrack, secondaryTrack: input.secondaryTrack,
mode: input.mode,
secondaryFailureLabel: input.secondaryFailureLabel, secondaryFailureLabel: input.secondaryFailureLabel,
}); });
const resolvedPrimaryPath = await deps.retimeYoutubePrimaryTrack({ const resolvedPrimaryPath = await deps.retimeYoutubePrimaryTrack({
@@ -484,7 +502,7 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
const openManualPicker = async (input: { const openManualPicker = async (input: {
url: string; url: string;
mode: YoutubeFlowMode; mode?: YoutubeFlowMode;
}): Promise<void> => { }): Promise<void> => {
let probe: YoutubeTrackProbeResult; let probe: YoutubeTrackProbeResult;
try { try {
@@ -549,15 +567,18 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
try { try {
deps.showMpvOsd('Getting subtitles...'); deps.showMpvOsd('Getting subtitles...');
await loadTracksIntoMpv({ const loaded = await loadTracksIntoMpv({
url: input.url, url: input.url,
mode: input.mode, mode: input.mode ?? 'download',
outputDir: normalizeOutputPath(deps.getYoutubeOutputDir()), outputDir: normalizeOutputPath(deps.getYoutubeOutputDir()),
primaryTrack, primaryTrack,
secondaryTrack, secondaryTrack,
secondaryFailureLabel: 'Failed to download secondary YouTube subtitle track', secondaryFailureLabel: 'Failed to download secondary YouTube subtitle track',
showDownloadProgress: true, showDownloadProgress: true,
}); });
if (!loaded) {
reportPrimarySubtitleFailure();
}
} catch (error) { } catch (error) {
deps.warn( deps.warn(
`Failed to download primary YouTube subtitle track: ${ `Failed to download primary YouTube subtitle track: ${

View File

@@ -0,0 +1,149 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createYoutubePrimarySubtitleNotificationRuntime,
type YoutubePrimarySubtitleNotificationTimer,
} from './youtube-primary-subtitle-notification';
function createTimerHarness() {
let nextId = 1;
const timers = new Map<number, () => void>();
return {
schedule: (fn: () => void): YoutubePrimarySubtitleNotificationTimer => {
const id = nextId++;
timers.set(id, fn);
return { id };
},
clear: (timer: YoutubePrimarySubtitleNotificationTimer | null) => {
if (!timer) {
return;
}
if (typeof timer === 'object' && 'id' in timer) {
timers.delete(timer.id);
}
},
runAll: () => {
const pending = [...timers.values()];
timers.clear();
for (const fn of pending) {
fn();
}
},
size: () => timers.size,
};
}
test('notifier reports missing preferred primary subtitle once for youtube media', () => {
const notifications: string[] = [];
const timers = createTimerHarness();
const runtime = createYoutubePrimarySubtitleNotificationRuntime({
getPrimarySubtitleLanguages: () => ['ja', 'jpn'],
notifyFailure: (message) => {
notifications.push(message);
},
schedule: (fn) => timers.schedule(fn),
clearSchedule: (timer) => timers.clear(timer),
});
runtime.handleMediaPathChange('https://www.youtube.com/watch?v=abc');
runtime.handleSubtitleTrackChange(null);
runtime.handleSubtitleTrackListChange([
{ type: 'sub', id: 2, lang: 'en', title: 'English', external: true },
]);
assert.equal(timers.size(), 1);
timers.runAll();
timers.runAll();
assert.deepEqual(notifications, [
'Primary subtitle failed to download or load. Try again from the subtitle modal.',
]);
});
test('notifier suppresses failure when preferred primary subtitle is selected', () => {
const notifications: string[] = [];
const timers = createTimerHarness();
const runtime = createYoutubePrimarySubtitleNotificationRuntime({
getPrimarySubtitleLanguages: () => ['ja', 'jpn'],
notifyFailure: (message) => {
notifications.push(message);
},
schedule: (fn) => timers.schedule(fn),
clearSchedule: (timer) => timers.clear(timer),
});
runtime.handleMediaPathChange('https://www.youtube.com/watch?v=abc');
runtime.handleSubtitleTrackListChange([
{ type: 'sub', id: 5, lang: 'ja', title: 'Japanese', external: true },
]);
runtime.handleSubtitleTrackChange(5);
timers.runAll();
assert.deepEqual(notifications, []);
});
test('notifier suppresses failure when any external subtitle track is selected', () => {
const notifications: string[] = [];
const timers = createTimerHarness();
const runtime = createYoutubePrimarySubtitleNotificationRuntime({
getPrimarySubtitleLanguages: () => ['ja', 'jpn'],
notifyFailure: (message) => {
notifications.push(message);
},
schedule: (fn) => timers.schedule(fn),
clearSchedule: (timer) => timers.clear(timer),
});
runtime.handleMediaPathChange('https://www.youtube.com/watch?v=abc');
runtime.handleSubtitleTrackListChange([
{ type: 'sub', id: 5, lang: '', title: 'auto-ja-orig.ja-orig.vtt', external: true },
]);
runtime.handleSubtitleTrackChange(5);
timers.runAll();
assert.deepEqual(notifications, []);
});
test('notifier resets when media changes away from youtube', () => {
const notifications: string[] = [];
const timers = createTimerHarness();
const runtime = createYoutubePrimarySubtitleNotificationRuntime({
getPrimarySubtitleLanguages: () => ['ja'],
notifyFailure: (message) => {
notifications.push(message);
},
schedule: (fn) => timers.schedule(fn),
clearSchedule: (timer) => timers.clear(timer),
});
runtime.handleMediaPathChange('https://www.youtube.com/watch?v=abc');
runtime.handleMediaPathChange('/tmp/video.mkv');
timers.runAll();
assert.deepEqual(notifications, []);
});
test('notifier ignores empty and null media paths and waits for track list before reporting', () => {
const notifications: string[] = [];
const timers = createTimerHarness();
const runtime = createYoutubePrimarySubtitleNotificationRuntime({
getPrimarySubtitleLanguages: () => ['ja'],
notifyFailure: (message) => {
notifications.push(message);
},
schedule: (fn) => timers.schedule(fn),
clearSchedule: (timer) => timers.clear(timer),
});
runtime.handleMediaPathChange(null);
runtime.handleMediaPathChange('');
assert.equal(timers.size(), 0);
runtime.handleMediaPathChange('https://www.youtube.com/watch?v=abc');
runtime.handleSubtitleTrackChange(7);
runtime.handleSubtitleTrackListChange([
{ type: 'sub', id: 7, lang: 'ja', title: 'Japanese', external: true },
]);
timers.runAll();
assert.deepEqual(notifications, []);
});

View File

@@ -0,0 +1,168 @@
import { isYoutubeMediaPath } from './youtube-playback';
import { normalizeYoutubeLangCode } from '../../core/services/youtube/labels';
export type YoutubePrimarySubtitleNotificationTimer = ReturnType<typeof setTimeout> | { id: number };
type SubtitleTrackEntry = {
id: number | null;
type: string;
lang: string;
external: boolean;
};
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 normalizeTrack(entry: unknown): SubtitleTrackEntry | null {
if (!entry || typeof entry !== 'object') {
return null;
}
const track = entry as Record<string, unknown>;
return {
id: parseTrackId(track.id),
type: String(track.type || '').trim(),
lang: String(track.lang || '').trim(),
external: track.external === true,
};
}
export function clearYoutubePrimarySubtitleNotificationTimer(
timer: YoutubePrimarySubtitleNotificationTimer | null,
): void {
if (!timer) {
return;
}
if (typeof timer === 'object' && timer !== null && 'id' in timer) {
clearTimeout((timer as { id: number }).id);
return;
}
clearTimeout(timer);
}
function buildPreferredLanguageSet(values: string[]): Set<string> {
const normalized = values
.map((value) => normalizeYoutubeLangCode(value))
.filter((value) => value.length > 0);
return new Set(normalized);
}
function matchesPreferredLanguage(language: string, preferred: Set<string>): boolean {
if (preferred.size === 0) {
return false;
}
const normalized = normalizeYoutubeLangCode(language);
if (!normalized) {
return false;
}
if (preferred.has(normalized)) {
return true;
}
const base = normalized.split('-')[0] || normalized;
return preferred.has(base);
}
function hasSelectedPrimarySubtitle(
sid: number | null,
trackList: unknown[] | null,
preferredLanguages: Set<string>,
): boolean {
if (sid === null || !Array.isArray(trackList)) {
return false;
}
const activeTrack =
trackList.map(normalizeTrack).find((track) => track?.type === 'sub' && track.id === sid) ?? null;
if (!activeTrack) {
return false;
}
if (activeTrack.external) {
return true;
}
return matchesPreferredLanguage(activeTrack.lang, preferredLanguages);
}
export function createYoutubePrimarySubtitleNotificationRuntime(deps: {
getPrimarySubtitleLanguages: () => string[];
notifyFailure: (message: string) => void;
schedule: (fn: () => void, delayMs: number) => YoutubePrimarySubtitleNotificationTimer;
clearSchedule: (timer: YoutubePrimarySubtitleNotificationTimer | null) => void;
delayMs?: number;
}) {
const delayMs = deps.delayMs ?? 5000;
let currentMediaPath: string | null = null;
let currentSid: number | null = null;
let currentTrackList: unknown[] | null = null;
let pendingTimer: YoutubePrimarySubtitleNotificationTimer | null = null;
let lastReportedMediaPath: string | null = null;
const clearPendingTimer = (): void => {
deps.clearSchedule(pendingTimer);
pendingTimer = null;
};
const maybeReportFailure = (): void => {
const mediaPath = currentMediaPath?.trim() || '';
if (!mediaPath || !isYoutubeMediaPath(mediaPath)) {
return;
}
if (lastReportedMediaPath === mediaPath) {
return;
}
const preferredLanguages = buildPreferredLanguageSet(deps.getPrimarySubtitleLanguages());
if (preferredLanguages.size === 0) {
return;
}
if (hasSelectedPrimarySubtitle(currentSid, currentTrackList, preferredLanguages)) {
return;
}
lastReportedMediaPath = mediaPath;
deps.notifyFailure('Primary subtitle failed to download or load. Try again from the subtitle modal.');
};
const schedulePendingCheck = (): void => {
clearPendingTimer();
const mediaPath = currentMediaPath?.trim() || '';
if (!mediaPath || !isYoutubeMediaPath(mediaPath)) {
return;
}
pendingTimer = deps.schedule(() => {
pendingTimer = null;
maybeReportFailure();
}, delayMs);
};
return {
handleMediaPathChange: (path: string | null): void => {
const normalizedPath = typeof path === 'string' && path.trim().length > 0 ? path.trim() : null;
if (currentMediaPath !== normalizedPath) {
lastReportedMediaPath = null;
}
currentMediaPath = normalizedPath;
currentSid = null;
currentTrackList = null;
schedulePendingCheck();
},
handleSubtitleTrackChange: (sid: number | null): void => {
currentSid = sid;
const preferredLanguages = buildPreferredLanguageSet(deps.getPrimarySubtitleLanguages());
if (hasSelectedPrimarySubtitle(currentSid, currentTrackList, preferredLanguages)) {
clearPendingTimer();
}
},
handleSubtitleTrackListChange: (trackList: unknown[] | null): void => {
currentTrackList = trackList;
const preferredLanguages = buildPreferredLanguageSet(deps.getPrimarySubtitleLanguages());
if (hasSelectedPrimarySubtitle(currentSid, currentTrackList, preferredLanguages)) {
clearPendingTimer();
}
},
};
}