mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-25 00:11:26 -07:00
fix: restore app-owned youtube subtitle flow
This commit is contained in:
@@ -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,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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...');
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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, [
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
54
src/main.ts
54
src/main.ts
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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));
|
||||||
|
});
|
||||||
|
|||||||
@@ -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: ${
|
||||||
|
|||||||
149
src/main/runtime/youtube-primary-subtitle-notification.test.ts
Normal file
149
src/main/runtime/youtube-primary-subtitle-notification.test.ts
Normal 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, []);
|
||||||
|
});
|
||||||
168
src/main/runtime/youtube-primary-subtitle-notification.ts
Normal file
168
src/main/runtime/youtube-primary-subtitle-notification.ts
Normal 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();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user