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:
@@ -1,6 +1,5 @@
|
||||
import { handleCliCommand, createCliCommandDepsRuntime } from '../core/services';
|
||||
import type { CliArgs, CliCommandSource } from '../cli/args';
|
||||
import type { YoutubeFlowMode } from '../types';
|
||||
import {
|
||||
createCliCommandRuntimeServiceDeps,
|
||||
CliCommandRuntimeServiceDepsParams,
|
||||
@@ -39,11 +38,7 @@ export interface CliCommandRuntimeServiceContext {
|
||||
openJellyfinSetup: CliCommandRuntimeServiceDepsParams['jellyfin']['openSetup'];
|
||||
runStatsCommand: CliCommandRuntimeServiceDepsParams['jellyfin']['runStatsCommand'];
|
||||
runJellyfinCommand: CliCommandRuntimeServiceDepsParams['jellyfin']['runCommand'];
|
||||
runYoutubePlaybackFlow: (request: {
|
||||
url: string;
|
||||
mode: YoutubeFlowMode;
|
||||
source: CliCommandSource;
|
||||
}) => Promise<void>;
|
||||
runYoutubePlaybackFlow: CliCommandRuntimeServiceDepsParams['app']['runYoutubePlaybackFlow'];
|
||||
openYomitanSettings: () => void;
|
||||
cycleSecondarySubMode: () => void;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
|
||||
@@ -61,7 +61,7 @@ test('build cli command context deps maps handlers and values', () => {
|
||||
calls.push('run-jellyfin');
|
||||
},
|
||||
runYoutubePlaybackFlow: async () => {
|
||||
calls.push('run-youtube');
|
||||
calls.push('run-youtube-playback');
|
||||
},
|
||||
openYomitanSettings: () => calls.push('yomitan'),
|
||||
cycleSecondarySubMode: () => calls.push('cycle-secondary'),
|
||||
|
||||
@@ -9,7 +9,6 @@ test('cli command context factory composes main deps and context handlers', () =
|
||||
mpvClient: null,
|
||||
texthookerPort: 5174,
|
||||
overlayRuntimeInitialized: false,
|
||||
youtubePlaybackFlowPending: false,
|
||||
};
|
||||
|
||||
const createContext = createCliCommandContextFactory({
|
||||
|
||||
@@ -9,7 +9,6 @@ test('cli command context main deps builder maps state and callbacks', async ()
|
||||
mpvClient: null,
|
||||
texthookerPort: 5174,
|
||||
overlayRuntimeInitialized: false,
|
||||
youtubePlaybackFlowPending: false,
|
||||
};
|
||||
|
||||
const build = createBuildCliCommandContextMainDepsHandler({
|
||||
@@ -86,9 +85,8 @@ test('cli command context main deps builder maps state and callbacks', async ()
|
||||
calls.push('run-jellyfin');
|
||||
},
|
||||
runYoutubePlaybackFlow: async () => {
|
||||
calls.push('run-youtube');
|
||||
calls.push('run-youtube-playback');
|
||||
},
|
||||
|
||||
openYomitanSettings: () => calls.push('open-yomitan'),
|
||||
cycleSecondarySubMode: () => calls.push('cycle-secondary'),
|
||||
openRuntimeOptionsPalette: () => calls.push('open-runtime-options'),
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { CliArgs } from '../../cli/args';
|
||||
import type { YoutubeFlowMode } from '../../types';
|
||||
import type { CliCommandContextFactoryDeps } from './cli-command-context';
|
||||
|
||||
type CliCommandContextMainState = {
|
||||
@@ -42,11 +41,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
|
||||
generateCharacterDictionary: CliCommandContextFactoryDeps['generateCharacterDictionary'];
|
||||
runStatsCommand: CliCommandContextFactoryDeps['runStatsCommand'];
|
||||
runJellyfinCommand: (args: CliArgs) => Promise<void>;
|
||||
runYoutubePlaybackFlow: (request: {
|
||||
url: string;
|
||||
mode: YoutubeFlowMode;
|
||||
source: 'initial' | 'second-instance';
|
||||
}) => Promise<void>;
|
||||
runYoutubePlaybackFlow: CliCommandContextFactoryDeps['runYoutubePlaybackFlow'];
|
||||
|
||||
openYomitanSettings: () => void;
|
||||
cycleSecondarySubMode: () => void;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { CliArgs } from '../../cli/args';
|
||||
import type { YoutubeFlowMode } from '../../types';
|
||||
import type {
|
||||
CliCommandRuntimeServiceContext,
|
||||
CliCommandRuntimeServiceContextHandlers,
|
||||
@@ -42,11 +41,7 @@ export type CliCommandContextFactoryDeps = {
|
||||
generateCharacterDictionary: CliCommandRuntimeServiceContext['generateCharacterDictionary'];
|
||||
runStatsCommand: CliCommandRuntimeServiceContext['runStatsCommand'];
|
||||
runJellyfinCommand: (args: CliArgs) => Promise<void>;
|
||||
runYoutubePlaybackFlow: (request: {
|
||||
url: string;
|
||||
mode: YoutubeFlowMode;
|
||||
source: 'initial' | 'second-instance';
|
||||
}) => Promise<void>;
|
||||
runYoutubePlaybackFlow: CliCommandRuntimeServiceContext['runYoutubePlaybackFlow'];
|
||||
openYomitanSettings: () => void;
|
||||
cycleSecondarySubMode: () => void;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
|
||||
@@ -19,507 +19,13 @@ const secondaryTrack: YoutubeTrackOption = {
|
||||
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 () => {
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
const focusOverlayCalls: string[] = [];
|
||||
const osdMessages: string[] = [];
|
||||
const openedPayloads: YoutubePickerOpenPayload[] = [];
|
||||
const waits: number[] = [];
|
||||
const refreshedSidebarSources: string[] = [];
|
||||
|
||||
const runtime = createYoutubeFlowRuntime({
|
||||
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` }),
|
||||
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) => {
|
||||
openedPayloads.push(payload);
|
||||
queueMicrotask(() => {
|
||||
@@ -563,15 +57,8 @@ test('youtube flow can open a manual picker session and load the selected subtit
|
||||
});
|
||||
return true;
|
||||
},
|
||||
reportSubtitleFailure: () => {
|
||||
throw new Error('manual picker success should not report failure');
|
||||
},
|
||||
pauseMpv: () => {
|
||||
throw new Error('manual picker should not pause playback');
|
||||
},
|
||||
resumeMpv: () => {
|
||||
throw new Error('manual picker should not resume playback');
|
||||
},
|
||||
pauseMpv: () => {},
|
||||
resumeMpv: () => {},
|
||||
sendMpvCommand: (command) => {
|
||||
commands.push(command);
|
||||
},
|
||||
@@ -599,12 +86,30 @@ test('youtube flow can open a manual picker session and load the selected subtit
|
||||
];
|
||||
},
|
||||
refreshCurrentSubtitle: () => {},
|
||||
refreshSubtitleSidebarSource: async (sourcePath: string) => {
|
||||
refreshedSidebarSources.push(sourcePath);
|
||||
},
|
||||
startTokenizationWarmups: async () => {},
|
||||
waitForTokenizationReady: async () => {},
|
||||
waitForAnkiReady: async () => {},
|
||||
wait: async (ms) => {
|
||||
waits.push(ms);
|
||||
},
|
||||
waitForPlaybackWindowReady: async () => {
|
||||
waits.push(1);
|
||||
},
|
||||
waitForOverlayGeometryReady: async () => {
|
||||
waits.push(2);
|
||||
},
|
||||
focusOverlayWindow: () => {
|
||||
focusOverlayCalls.push('focus-overlay');
|
||||
},
|
||||
showMpvOsd: (text) => {
|
||||
osdMessages.push(text);
|
||||
},
|
||||
reportSubtitleFailure: () => {
|
||||
throw new Error('manual picker success should not report failure');
|
||||
},
|
||||
warn: (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',
|
||||
});
|
||||
|
||||
await runtime.openManualPicker({ url: 'https://example.com', mode: 'download' });
|
||||
await runtime.openManualPicker({ url: 'https://example.com' });
|
||||
|
||||
assert.equal(openedPayloads.length, 1);
|
||||
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',
|
||||
),
|
||||
);
|
||||
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']);
|
||||
});
|
||||
|
||||
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 path from 'node:path';
|
||||
import type {
|
||||
YoutubeFlowMode,
|
||||
YoutubePickerOpenPayload,
|
||||
YoutubePickerResolveRequest,
|
||||
YoutubePickerResolveResult,
|
||||
@@ -21,6 +20,7 @@ import {
|
||||
import { resolveSubtitleSourcePath } from './subtitle-prefetch-source';
|
||||
|
||||
type YoutubeFlowOpenPicker = (payload: YoutubePickerOpenPayload) => Promise<boolean>;
|
||||
type YoutubeFlowMode = 'download' | 'generate';
|
||||
|
||||
type YoutubeFlowDeps = {
|
||||
probeYoutubeTracks: (url: string) => Promise<YoutubeTrackProbeResult>;
|
||||
@@ -134,6 +134,22 @@ function parseTrackId(value: unknown): number | 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>): {
|
||||
id: number | null;
|
||||
lang: string;
|
||||
@@ -252,7 +268,12 @@ async function injectDownloadedSubtitles(
|
||||
}
|
||||
|
||||
if (primaryTrackId !== null) {
|
||||
deps.sendMpvCommand(['set_property', 'sid', primaryTrackId]);
|
||||
await ensureSubtitleTrackSelection({
|
||||
deps,
|
||||
property: 'sid',
|
||||
targetId: primaryTrackId,
|
||||
});
|
||||
deps.sendMpvCommand(['set_property', 'sub-visibility', 'yes']);
|
||||
} else {
|
||||
deps.warn(
|
||||
`Unable to bind downloaded primary subtitle track in mpv: ${path.basename(primaryPath)}`,
|
||||
@@ -260,7 +281,12 @@ async function injectDownloadedSubtitles(
|
||||
}
|
||||
if (secondaryPath && secondaryTrack) {
|
||||
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 {
|
||||
deps.warn(
|
||||
`Unable to bind downloaded secondary subtitle track in mpv: ${path.basename(secondaryPath)}`,
|
||||
@@ -280,7 +306,7 @@ async function injectDownloadedSubtitles(
|
||||
deps.showMpvOsd(
|
||||
secondaryPath && secondaryTrack ? 'Primary and secondary subtitles loaded.' : 'Subtitles loaded.',
|
||||
);
|
||||
return typeof currentSubText === 'string' && currentSubText.trim().length > 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
|
||||
@@ -291,7 +317,6 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
|
||||
outputDir: string;
|
||||
primaryTrack: YoutubeTrackOption;
|
||||
secondaryTrack: YoutubeTrackOption | null;
|
||||
mode: YoutubeFlowMode;
|
||||
secondaryFailureLabel: string;
|
||||
}): Promise<{ primaryPath: string; secondaryPath: string | null }> => {
|
||||
if (!input.secondaryTrack) {
|
||||
@@ -300,7 +325,6 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
|
||||
targetUrl: input.targetUrl,
|
||||
outputDir: input.outputDir,
|
||||
track: input.primaryTrack,
|
||||
mode: input.mode,
|
||||
})
|
||||
).path;
|
||||
return { primaryPath, secondaryPath: null };
|
||||
@@ -311,7 +335,6 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
|
||||
targetUrl: input.targetUrl,
|
||||
outputDir: input.outputDir,
|
||||
tracks: [input.primaryTrack, input.secondaryTrack],
|
||||
mode: input.mode,
|
||||
});
|
||||
const primaryPath = batchResult.get(input.primaryTrack.id) ?? null;
|
||||
const secondaryPath = batchResult.get(input.secondaryTrack.id) ?? null;
|
||||
@@ -332,7 +355,6 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
|
||||
targetUrl: input.targetUrl,
|
||||
outputDir: input.outputDir,
|
||||
track: input.secondaryTrack,
|
||||
mode: input.mode,
|
||||
})
|
||||
).path;
|
||||
return { primaryPath, secondaryPath: retriedSecondaryPath };
|
||||
@@ -355,7 +377,6 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
|
||||
targetUrl: input.targetUrl,
|
||||
outputDir: input.outputDir,
|
||||
track: input.primaryTrack,
|
||||
mode: input.mode,
|
||||
})
|
||||
).path;
|
||||
return { primaryPath, secondaryPath: null };
|
||||
@@ -403,7 +424,6 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
|
||||
const buildOpenPayload = (
|
||||
input: {
|
||||
url: string;
|
||||
mode: YoutubeFlowMode;
|
||||
},
|
||||
probe: YoutubeTrackProbeResult,
|
||||
): YoutubePickerOpenPayload => {
|
||||
@@ -411,7 +431,6 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
|
||||
return {
|
||||
sessionId: createSessionId(),
|
||||
url: input.url,
|
||||
mode: input.mode,
|
||||
tracks: probe.tracks,
|
||||
defaultPrimaryTrackId: defaults.primaryTrackId,
|
||||
defaultSecondaryTrackId: defaults.secondaryTrackId,
|
||||
@@ -441,7 +460,6 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
|
||||
outputDir: input.outputDir,
|
||||
primaryTrack: input.primaryTrack,
|
||||
secondaryTrack: input.secondaryTrack,
|
||||
mode: input.mode,
|
||||
secondaryFailureLabel: input.secondaryFailureLabel,
|
||||
});
|
||||
const resolvedPrimaryPath = await deps.retimeYoutubePrimaryTrack({
|
||||
@@ -484,7 +502,7 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
|
||||
|
||||
const openManualPicker = async (input: {
|
||||
url: string;
|
||||
mode: YoutubeFlowMode;
|
||||
mode?: YoutubeFlowMode;
|
||||
}): Promise<void> => {
|
||||
let probe: YoutubeTrackProbeResult;
|
||||
try {
|
||||
@@ -549,15 +567,18 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
|
||||
|
||||
try {
|
||||
deps.showMpvOsd('Getting subtitles...');
|
||||
await loadTracksIntoMpv({
|
||||
const loaded = await loadTracksIntoMpv({
|
||||
url: input.url,
|
||||
mode: input.mode,
|
||||
mode: input.mode ?? 'download',
|
||||
outputDir: normalizeOutputPath(deps.getYoutubeOutputDir()),
|
||||
primaryTrack,
|
||||
secondaryTrack,
|
||||
secondaryFailureLabel: 'Failed to download secondary YouTube subtitle track',
|
||||
showDownloadProgress: true,
|
||||
});
|
||||
if (!loaded) {
|
||||
reportPrimarySubtitleFailure();
|
||||
}
|
||||
} catch (error) {
|
||||
deps.warn(
|
||||
`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