fix: refine youtube subtitle startup binding

This commit is contained in:
2026-03-23 20:22:41 -07:00
parent e487998c40
commit cbab8717e1
2 changed files with 998 additions and 83 deletions

View File

@@ -43,7 +43,9 @@ test('youtube flow can open a manual picker session and load the selected subtit
[secondaryTrack.id, '/tmp/manual-en.vtt'], [secondaryTrack.id, '/tmp/manual-en.vtt'],
]); ]);
}, },
acquireYoutubeSubtitleTrack: async ({ track }) => ({ path: `/tmp/${track.id}.vtt` }), acquireYoutubeSubtitleTrack: async ({ track }) => ({
path: `/tmp/${track.id.replace(/[^a-z0-9_-]+/gi, '-')}.vtt`,
}),
retimeYoutubePrimaryTrack: async ({ primaryPath }) => `${primaryPath}.retimed`, retimeYoutubePrimaryTrack: async ({ primaryPath }) => `${primaryPath}.retimed`,
openPicker: async (payload) => { openPicker: async (payload) => {
openedPayloads.push(payload); openedPayloads.push(payload);
@@ -146,11 +148,13 @@ test('youtube flow can open a manual picker session and load the selected subtit
), ),
); );
assert.ok( assert.ok(
commands.some( commands.every(
(command) => (command) =>
command[0] === 'set_property' && !(
command[1] === 'secondary-sub-visibility' && command[0] === 'set_property' &&
command[2] === 'yes', command[1] === 'secondary-sub-visibility' &&
command[2] === 'yes'
),
), ),
); );
assert.deepEqual(refreshedSidebarSources, ['/tmp/auto-ja-orig.vtt.retimed']); assert.deepEqual(refreshedSidebarSources, ['/tmp/auto-ja-orig.vtt.retimed']);
@@ -161,6 +165,7 @@ test('youtube flow retries secondary after partial batch subtitle failure', asyn
const acquireSingleCalls: string[] = []; const acquireSingleCalls: string[] = [];
const commands: Array<Array<string | number>> = []; const commands: Array<Array<string | number>> = [];
const waits: number[] = []; const waits: number[] = [];
let secondaryTrackAdded = false;
const runtime = createYoutubeFlowRuntime({ const runtime = createYoutubeFlowRuntime({
probeYoutubeTracks: async () => ({ probeYoutubeTracks: async () => ({
@@ -190,29 +195,47 @@ test('youtube flow retries secondary after partial batch subtitle failure', asyn
resumeMpv: () => {}, resumeMpv: () => {},
sendMpvCommand: (command) => { sendMpvCommand: (command) => {
commands.push(command); commands.push(command);
if (
command[0] === 'sub-add' &&
command[1] === '/tmp/manual:en.vtt' &&
command[2] === 'cached'
) {
secondaryTrackAdded = true;
}
}, },
requestMpvProperty: async (name) => { requestMpvProperty: async (name) => {
if (name === 'sub-text') { if (name === 'sub-text') {
return '字幕です'; return '字幕です';
} }
return [ return secondaryTrackAdded
{ ? [
type: 'sub', {
id: 5, type: 'sub',
lang: 'ja-orig', id: 5,
title: 'primary', lang: 'ja-orig',
external: true, title: 'primary',
'external-filename': '/tmp/auto-ja-orig.vtt', external: true,
}, 'external-filename': '/tmp/auto-ja-orig.vtt',
{ },
type: 'sub', {
id: 6, type: 'sub',
lang: 'en', id: 6,
title: 'secondary', lang: 'en',
external: true, title: 'secondary',
'external-filename': '/tmp/manual:en.vtt', external: true,
}, 'external-filename': '/tmp/manual:en.vtt',
]; },
]
: [
{
type: 'sub',
id: 5,
lang: 'ja-orig',
title: 'primary',
external: true,
'external-filename': '/tmp/auto-ja-orig.vtt',
},
];
}, },
refreshCurrentSubtitle: () => {}, refreshCurrentSubtitle: () => {},
startTokenizationWarmups: async () => {}, startTokenizationWarmups: async () => {},
@@ -371,7 +394,9 @@ test('youtube flow retries secondary subtitle selection until mpv reports the ex
[primaryTrack.id, '/tmp/auto-ja-orig.vtt'], [primaryTrack.id, '/tmp/auto-ja-orig.vtt'],
[secondaryTrack.id, '/tmp/manual-en.vtt'], [secondaryTrack.id, '/tmp/manual-en.vtt'],
]), ]),
acquireYoutubeSubtitleTrack: async ({ track }) => ({ path: `/tmp/${track.id}.vtt` }), acquireYoutubeSubtitleTrack: async ({ track }) => ({
path: `/tmp/${track.id.replace(/[^a-z0-9_-]+/gi, '-')}.vtt`,
}),
retimeYoutubePrimaryTrack: async ({ primaryPath }) => primaryPath, retimeYoutubePrimaryTrack: async ({ primaryPath }) => primaryPath,
openPicker: async (payload) => { openPicker: async (payload) => {
queueMicrotask(() => { queueMicrotask(() => {
@@ -410,7 +435,7 @@ test('youtube flow retries secondary subtitle selection until mpv reports the ex
type: 'sub', type: 'sub',
id: 6, id: 6,
lang: 'en', lang: 'en',
title: 'English', title: 'manual-en.vtt',
external: true, external: true,
'external-filename': null, 'external-filename': null,
}, },
@@ -448,3 +473,673 @@ test('youtube flow retries secondary subtitle selection until mpv reports the ex
); );
assert.ok(waits.includes(100)); assert.ok(waits.includes(100));
}); });
test('youtube flow reuses the matching existing manual secondary track instead of a loose language match', async () => {
const commands: Array<Array<string | number>> = [];
let selectedSecondarySid: number | null = null;
const runtime = createYoutubeFlowRuntime({
probeYoutubeTracks: async () => ({
videoId: 'video123',
title: 'Video 123',
tracks: [
primaryTrack,
{
...secondaryTrack,
id: 'manual:en',
sourceLanguage: 'en',
kind: 'manual',
title: 'manual-en.vtt',
},
],
}),
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.replace(/[^a-z0-9_-]+/gi, '-')}.vtt`,
}),
retimeYoutubePrimaryTrack: async ({ primaryPath }) => primaryPath,
openPicker: async (payload) => {
queueMicrotask(() => {
void runtime.resolveActivePicker({
sessionId: payload.sessionId,
action: 'use-selected',
primaryTrackId: primaryTrack.id,
secondaryTrackId: 'manual:en',
});
});
return true;
},
pauseMpv: () => {},
resumeMpv: () => {},
sendMpvCommand: (command) => {
commands.push(command);
if (
command[0] === 'set_property' &&
command[1] === 'secondary-sid' &&
typeof command[2] === 'number'
) {
selectedSecondarySid = command[2];
}
},
requestMpvProperty: async (name) => {
if (name === 'sub-text') {
return '字幕です';
}
if (name === 'sid') {
return 5;
}
if (name === 'secondary-sid') {
return selectedSecondarySid;
}
return [
{
type: 'sub',
id: 5,
lang: 'ja-orig',
title: 'auto-ja-orig.vtt',
external: true,
'external-filename': '/tmp/auto-ja-orig.vtt',
},
{
type: 'sub',
id: 6,
lang: 'en',
title: 'English',
external: true,
'external-filename': null,
},
{
type: 'sub',
id: 8,
lang: 'en',
title: 'manual-en.vtt',
external: true,
'external-filename': null,
},
];
},
refreshCurrentSubtitle: () => {},
startTokenizationWarmups: async () => {},
waitForTokenizationReady: async () => {},
waitForAnkiReady: async () => {},
wait: async () => {},
waitForPlaybackWindowReady: async () => {},
waitForOverlayGeometryReady: async () => {},
focusOverlayWindow: () => {},
showMpvOsd: () => {},
reportSubtitleFailure: () => {
throw new Error('authoritative secondary bind should not report failure');
},
warn: (message) => {
throw new Error(message);
},
log: () => {},
getYoutubeOutputDir: () => '/tmp',
});
await runtime.openManualPicker({ url: 'https://example.com' });
assert.equal(selectedSecondarySid, 8);
assert.ok(
commands.some(
(command) =>
command[0] === 'set_property' && command[1] === 'secondary-sid' && command[2] === 8,
),
);
});
test('youtube flow leaves non-authoritative youtube subtitle tracks untouched after authoritative tracks bind', async () => {
const commands: Array<Array<string | number>> = [];
let selectedPrimarySid: number | null = null;
let selectedSecondarySid: number | null = null;
const runtime = createYoutubeFlowRuntime({
probeYoutubeTracks: async () => ({
videoId: 'video123',
title: 'Video 123',
tracks: [primaryTrack, secondaryTrack],
}),
acquireYoutubeSubtitleTracks: async () =>
new Map<string, string>([
[primaryTrack.id, '/tmp/manual-ja.ja.srt'],
[secondaryTrack.id, '/tmp/manual-en.en.srt'],
]),
acquireYoutubeSubtitleTrack: async ({ track }) => ({
path: `/tmp/${track.id.replace(/[^a-z0-9_-]+/gi, '-')}.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);
if (command[0] === 'set_property' && command[1] === 'sid' && typeof command[2] === 'number') {
selectedPrimarySid = command[2];
}
if (
command[0] === 'set_property' &&
command[1] === 'secondary-sid' &&
typeof command[2] === 'number'
) {
selectedSecondarySid = command[2];
}
},
requestMpvProperty: async (name) => {
if (name === 'sub-text') {
return '字幕です';
}
if (name === 'sid') {
return selectedPrimarySid;
}
if (name === 'secondary-sid') {
return selectedSecondarySid;
}
return [
{ type: 'sub', id: 1, lang: 'en', title: 'English', external: true, 'external-filename': null },
{ type: 'sub', id: 2, lang: 'ja', title: 'Japanese', external: true, 'external-filename': null },
{ type: 'sub', id: 3, lang: 'ja-en', title: 'Japanese from English', external: true, 'external-filename': null },
{ type: 'sub', id: 4, lang: 'ja-ja', title: 'Japanese from Japanese', external: true, 'external-filename': null },
{ type: 'sub', id: 5, lang: 'ja-orig', title: 'auto-ja-orig.vtt', external: true, 'external-filename': '/tmp/auto-ja-orig.vtt' },
{ type: 'sub', id: 6, lang: 'en', title: 'manual-en.en.srt', external: true, 'external-filename': '/tmp/manual-en.en.srt' },
];
},
refreshCurrentSubtitle: () => {},
startTokenizationWarmups: async () => {},
waitForTokenizationReady: async () => {},
waitForAnkiReady: async () => {},
wait: async () => {},
waitForPlaybackWindowReady: async () => {},
waitForOverlayGeometryReady: async () => {},
focusOverlayWindow: () => {},
showMpvOsd: () => {},
reportSubtitleFailure: () => {
throw new Error('authoritative bind should not report failure');
},
warn: (message) => {
throw new Error(message);
},
log: () => {},
getYoutubeOutputDir: () => '/tmp',
});
await runtime.openManualPicker({ url: 'https://example.com' });
assert.equal(commands.some((command) => command[0] === 'sub-remove'), false);
});
test('youtube flow reuses existing manual youtube subtitle tracks when both requested languages already exist', async () => {
const commands: Array<Array<string | number>> = [];
let selectedPrimarySid: number | null = null;
let selectedSecondarySid: number | null = null;
const refreshedSidebarSources: string[] = [];
const runtime = createYoutubeFlowRuntime({
probeYoutubeTracks: async () => ({
videoId: 'video123',
title: 'Video 123',
tracks: [
{ ...primaryTrack, id: 'manual:ja', sourceLanguage: 'ja', kind: 'manual', title: 'Japanese' },
{ ...secondaryTrack, id: 'manual:en', sourceLanguage: 'en', kind: 'manual', title: 'English' },
],
}),
acquireYoutubeSubtitleTracks: async () => {
throw new Error('should not batch download when both manual tracks already exist in mpv');
},
acquireYoutubeSubtitleTrack: async ({ track }) => {
if (track.language === 'ja') {
return { path: '/tmp/manual-ja.ja.srt' };
}
throw new Error('should not download secondary track when manual english already exists');
},
retimeYoutubePrimaryTrack: async ({ primaryPath }) => primaryPath,
openPicker: async (payload) => {
queueMicrotask(() => {
void runtime.resolveActivePicker({
sessionId: payload.sessionId,
action: 'use-selected',
primaryTrackId: 'manual:ja',
secondaryTrackId: 'manual:en',
});
});
return true;
},
pauseMpv: () => {},
resumeMpv: () => {},
sendMpvCommand: (command) => {
commands.push(command);
if (command[0] === 'set_property' && command[1] === 'sid' && typeof command[2] === 'number') {
selectedPrimarySid = command[2];
}
if (
command[0] === 'set_property' &&
command[1] === 'secondary-sid' &&
typeof command[2] === 'number'
) {
selectedSecondarySid = command[2];
}
},
requestMpvProperty: async (name) => {
if (name === 'sub-text') {
return '字幕です';
}
if (name === 'sid') {
return selectedPrimarySid;
}
if (name === 'secondary-sid') {
return selectedSecondarySid;
}
return [
{ type: 'sub', id: 1, lang: 'en', title: 'English', external: true, 'external-filename': null },
{ type: 'sub', id: 2, lang: 'ja', title: 'Japanese', external: true, 'external-filename': null },
{ type: 'sub', id: 3, lang: 'ja-en', title: 'Japanese from English', external: true, 'external-filename': null },
{ type: 'sub', id: 4, lang: 'ja-ja', title: 'Japanese from Japanese', external: true, 'external-filename': null },
];
},
refreshCurrentSubtitle: () => {},
refreshSubtitleSidebarSource: async (sourcePath) => {
refreshedSidebarSources.push(sourcePath);
},
startTokenizationWarmups: async () => {},
waitForTokenizationReady: async () => {},
waitForAnkiReady: async () => {},
wait: async () => {},
waitForPlaybackWindowReady: async () => {},
waitForOverlayGeometryReady: async () => {},
focusOverlayWindow: () => {},
showMpvOsd: () => {},
reportSubtitleFailure: () => {
throw new Error('existing manual tracks should not report failure');
},
warn: (message) => {
throw new Error(message);
},
log: () => {},
getYoutubeOutputDir: () => '/tmp',
});
await runtime.openManualPicker({ url: 'https://example.com' });
assert.equal(selectedPrimarySid, 2);
assert.equal(selectedSecondarySid, 1);
assert.equal(commands.some((command) => command[0] === 'sub-add'), false);
assert.deepEqual(refreshedSidebarSources, ['/tmp/manual-ja.ja.srt']);
assert.equal(commands.some((command) => command[0] === 'sub-remove'), false);
});
test('youtube flow waits for manual youtube tracks to appear before falling back to injected copies', async () => {
const commands: Array<Array<string | number>> = [];
let selectedPrimarySid: number | null = null;
let selectedSecondarySid: number | null = null;
let trackListReads = 0;
const runtime = createYoutubeFlowRuntime({
probeYoutubeTracks: async () => ({
videoId: 'video123',
title: 'Video 123',
tracks: [
{ ...primaryTrack, id: 'manual:ja', sourceLanguage: 'ja', kind: 'manual', title: 'Japanese' },
{ ...secondaryTrack, id: 'manual:en', sourceLanguage: 'en', kind: 'manual', title: 'English' },
],
}),
acquireYoutubeSubtitleTracks: async () => {
throw new Error('should not batch download when manual tracks appear after startup');
},
acquireYoutubeSubtitleTrack: async ({ track }) => {
if (track.language === 'ja') {
return { path: '/tmp/manual-ja.ja.srt' };
}
throw new Error('should not download secondary track when manual english appears in mpv');
},
retimeYoutubePrimaryTrack: async ({ primaryPath }) => primaryPath,
openPicker: async (payload) => {
queueMicrotask(() => {
void runtime.resolveActivePicker({
sessionId: payload.sessionId,
action: 'use-selected',
primaryTrackId: 'manual:ja',
secondaryTrackId: 'manual:en',
});
});
return true;
},
pauseMpv: () => {},
resumeMpv: () => {},
sendMpvCommand: (command) => {
commands.push(command);
if (command[0] === 'set_property' && command[1] === 'sid' && typeof command[2] === 'number') {
selectedPrimarySid = command[2];
}
if (
command[0] === 'set_property' &&
command[1] === 'secondary-sid' &&
typeof command[2] === 'number'
) {
selectedSecondarySid = command[2];
}
},
requestMpvProperty: async (name) => {
if (name === 'sub-text') {
return '字幕です';
}
if (name === 'sid') {
return selectedPrimarySid;
}
if (name === 'secondary-sid') {
return selectedSecondarySid;
}
trackListReads += 1;
if (trackListReads === 1) {
return [];
}
return [
{ type: 'sub', id: 1, lang: 'en', title: 'English', external: true, 'external-filename': null },
{ type: 'sub', id: 2, lang: 'ja', title: 'Japanese', external: true, 'external-filename': null },
{ type: 'sub', id: 3, lang: 'ja-en', title: 'Japanese from English', external: true, 'external-filename': null },
{ type: 'sub', id: 4, lang: 'ja-ja', title: 'Japanese from Japanese', external: true, 'external-filename': null },
];
},
refreshCurrentSubtitle: () => {},
startTokenizationWarmups: async () => {},
waitForTokenizationReady: async () => {},
waitForAnkiReady: async () => {},
wait: async () => {},
waitForPlaybackWindowReady: async () => {},
waitForOverlayGeometryReady: async () => {},
focusOverlayWindow: () => {},
showMpvOsd: () => {},
reportSubtitleFailure: () => {
throw new Error('delayed manual tracks should not report failure');
},
warn: (message) => {
throw new Error(message);
},
log: () => {},
getYoutubeOutputDir: () => '/tmp',
});
await runtime.openManualPicker({ url: 'https://example.com' });
assert.equal(selectedPrimarySid, 2);
assert.equal(selectedSecondarySid, 1);
assert.equal(commands.some((command) => command[0] === 'sub-add'), false);
});
test('youtube flow reuses manual youtube tracks even when mpv exposes external filenames', async () => {
const commands: Array<Array<string | number>> = [];
let selectedPrimarySid: number | null = null;
let selectedSecondarySid: number | null = null;
const runtime = createYoutubeFlowRuntime({
probeYoutubeTracks: async () => ({
videoId: 'video123',
title: 'Video 123',
tracks: [
{
id: 'manual:ja',
language: 'ja',
sourceLanguage: 'ja',
kind: 'manual',
title: 'Japanese',
label: 'Japanese',
},
{
id: 'manual:en',
language: 'en',
sourceLanguage: 'en',
kind: 'manual',
title: 'English',
label: 'English',
},
],
}),
acquireYoutubeSubtitleTracks: async () => {
throw new Error('should not batch-download when existing manual tracks are reusable');
},
acquireYoutubeSubtitleTrack: async ({ track }) => {
if (track.id === 'manual:ja') {
return { path: '/tmp/manual-ja.ja.srt' };
}
throw new Error('should not download secondary track when existing manual english track is reusable');
},
retimeYoutubePrimaryTrack: async ({ primaryPath }) => primaryPath,
openPicker: async () => false,
pauseMpv: () => {},
resumeMpv: () => {},
sendMpvCommand: (command) => {
commands.push(command);
if (command[0] === 'set_property' && command[1] === 'sid') {
selectedPrimarySid = Number(command[2]);
}
if (command[0] === 'set_property' && command[1] === 'secondary-sid') {
selectedSecondarySid = Number(command[2]);
}
},
requestMpvProperty: async (name) => {
if (name === 'track-list') {
return [
{
type: 'sub',
id: 1,
lang: 'en',
title: 'English',
external: true,
'external-filename': '/tmp/mpv-ytdl-track-en.vtt',
},
{
type: 'sub',
id: 2,
lang: 'ja',
title: 'Japanese',
external: true,
'external-filename': '/tmp/mpv-ytdl-track-ja.vtt',
},
{
type: 'sub',
id: 3,
lang: 'ja-en',
title: 'Japanese from English',
external: true,
'external-filename': '/tmp/mpv-ytdl-track-ja-en.vtt',
},
];
}
if (name === 'sid') {
return selectedPrimarySid;
}
if (name === 'secondary-sid') {
return selectedSecondarySid;
}
if (name === 'sub-text') {
return '';
}
return null;
},
refreshCurrentSubtitle: () => {},
refreshSubtitleSidebarSource: async () => {},
startTokenizationWarmups: async () => {},
waitForTokenizationReady: async () => {},
waitForAnkiReady: async () => {},
wait: async () => {},
waitForPlaybackWindowReady: async () => {},
waitForOverlayGeometryReady: async () => {},
focusOverlayWindow: () => {},
showMpvOsd: () => {},
reportSubtitleFailure: (message) => {
throw new Error(message);
},
warn: (message) => {
throw new Error(message);
},
log: () => {},
getYoutubeOutputDir: () => '/tmp',
});
await runtime.runYoutubePlaybackFlow({
url: 'https://example.com/watch?v=video123',
mode: 'download',
});
assert.equal(selectedPrimarySid, 2);
assert.equal(selectedSecondarySid, 1);
assert.equal(commands.some((command) => command[0] === 'sub-add'), false);
});
test('youtube flow falls back to existing auto secondary track when auto secondary download fails', async () => {
const commands: Array<Array<string | number>> = [];
let selectedPrimarySid: number | null = null;
let selectedSecondarySid: number | null = null;
let primaryTrackAdded = false;
const runtime = createYoutubeFlowRuntime({
probeYoutubeTracks: async () => ({
videoId: 'video123',
title: 'Video 123',
tracks: [
{
id: 'auto:ja-orig',
language: 'ja-orig',
sourceLanguage: 'ja-orig',
kind: 'auto',
title: 'Japanese (Original)',
label: 'Japanese (Original) (auto)',
},
{
id: 'auto:en',
language: 'en',
sourceLanguage: 'en',
kind: 'auto',
title: 'English',
label: 'English (auto)',
},
],
}),
acquireYoutubeSubtitleTracks: async () =>
new Map<string, string>([['auto:ja-orig', '/tmp/auto-ja-orig.ja-orig.vtt']]),
acquireYoutubeSubtitleTrack: async ({ track }) => {
if (track.id === 'auto:en') {
throw new Error('HTTP 429 while downloading en');
}
return { path: '/tmp/auto-ja-orig.ja-orig.vtt' };
},
retimeYoutubePrimaryTrack: async ({ primaryPath }) => primaryPath,
openPicker: async () => false,
pauseMpv: () => {},
resumeMpv: () => {},
sendMpvCommand: (command) => {
commands.push(command);
if (
command[0] === 'sub-add' &&
command[1] === '/tmp/auto-ja-orig.ja-orig.vtt' &&
command[2] === 'select'
) {
primaryTrackAdded = true;
}
if (command[0] === 'set_property' && command[1] === 'sid' && typeof command[2] === 'number') {
selectedPrimarySid = command[2];
}
if (
command[0] === 'set_property' &&
command[1] === 'secondary-sid' &&
typeof command[2] === 'number'
) {
selectedSecondarySid = command[2];
}
},
requestMpvProperty: async (name) => {
if (name === 'sub-text') {
return '';
}
if (name === 'sid') {
return selectedPrimarySid;
}
if (name === 'secondary-sid') {
return selectedSecondarySid;
}
return primaryTrackAdded
? [
{
type: 'sub',
id: 1,
lang: 'en',
title: 'English',
external: true,
'external-filename': '/tmp/mpv-auto-en.vtt',
},
{
type: 'sub',
id: 3,
lang: 'ja-orig',
title: 'Japanese (Original)',
external: true,
'external-filename': '/tmp/mpv-auto-ja-orig.vtt',
},
{
type: 'sub',
id: 4,
lang: 'ja-orig',
title: 'auto-ja-orig.ja-orig.vtt',
external: true,
'external-filename': '/tmp/auto-ja-orig.ja-orig.vtt',
},
]
: [
{
type: 'sub',
id: 1,
lang: 'en',
title: 'English',
external: true,
'external-filename': '/tmp/mpv-auto-en.vtt',
},
{
type: 'sub',
id: 3,
lang: 'ja-orig',
title: 'Japanese (Original)',
external: true,
'external-filename': '/tmp/mpv-auto-ja-orig.vtt',
},
];
},
refreshCurrentSubtitle: () => {},
refreshSubtitleSidebarSource: async () => {},
startTokenizationWarmups: async () => {},
waitForTokenizationReady: async () => {},
waitForAnkiReady: async () => {},
wait: async () => {},
waitForPlaybackWindowReady: async () => {},
waitForOverlayGeometryReady: async () => {},
focusOverlayWindow: () => {},
showMpvOsd: () => {},
reportSubtitleFailure: (message) => {
throw new Error(message);
},
warn: (message) => {
throw new Error(message);
},
log: () => {},
getYoutubeOutputDir: () => '/tmp',
});
await runtime.runYoutubePlaybackFlow({
url: 'https://example.com/watch?v=video123',
mode: 'download',
});
assert.equal(selectedPrimarySid, 4);
assert.equal(selectedSecondarySid, 1);
});

View File

@@ -175,10 +175,113 @@ function normalizeTrackListEntry(track: Record<string, unknown>): {
}; };
} }
function matchesTitleBasename(title: string, basename: string): boolean {
const normalizedTitle = title.trim();
return normalizedTitle.length > 0 && path.basename(normalizedTitle) === basename;
}
function isLikelyTranslatedYoutubeTrack(entry: { lang: string; title: string }): boolean {
const normalizedTitle = entry.title.trim().toLowerCase();
if (normalizedTitle.includes(' from ')) {
return true;
}
return /-[a-z]{2,}(?:-[a-z0-9]+)?$/i.test(entry.lang.trim());
}
function matchExistingManualYoutubeTrackId(
trackListRaw: unknown,
trackOption: YoutubeTrackOption,
excludeId: number | null = null,
): number | null {
if (!Array.isArray(trackListRaw)) {
return null;
}
const expectedTitle = trackOption.title?.trim().toLowerCase() || '';
const expectedLanguages = new Set(
[trackOption.language, trackOption.sourceLanguage]
.map((value) => value.trim().toLowerCase())
.filter((value) => value.length > 0),
);
const tracks = trackListRaw
.filter(
(track): track is Record<string, unknown> => Boolean(track) && typeof track === 'object',
)
.filter((track) => track.type === 'sub')
.map(normalizeTrackListEntry)
.filter((track) => track.external && track.id !== null && track.id !== excludeId)
.filter((track) => !isLikelyTranslatedYoutubeTrack(track));
const exactTitleMatch = tracks.find(
(track) =>
expectedTitle.length > 0 &&
track.title.trim().toLowerCase() === expectedTitle &&
expectedLanguages.has(track.lang.trim().toLowerCase()),
);
if (exactTitleMatch?.id !== null && exactTitleMatch?.id !== undefined) {
return exactTitleMatch.id;
}
if (expectedTitle.length === 0) {
const languageOnlyMatch = tracks.find((track) =>
expectedLanguages.has(track.lang.trim().toLowerCase()),
);
if (languageOnlyMatch?.id !== null && languageOnlyMatch?.id !== undefined) {
return languageOnlyMatch.id;
}
}
return null;
}
function matchExistingYoutubeTrackId(
trackListRaw: unknown,
trackOption: YoutubeTrackOption,
excludeId: number | null = null,
): number | null {
if (!Array.isArray(trackListRaw)) {
return null;
}
const expectedTitle = trackOption.title?.trim().toLowerCase() || '';
const expectedLanguages = new Set(
[trackOption.language, trackOption.sourceLanguage]
.map((value) => value.trim().toLowerCase())
.filter((value) => value.length > 0),
);
const tracks = trackListRaw
.filter(
(track): track is Record<string, unknown> => Boolean(track) && typeof track === 'object',
)
.filter((track) => track.type === 'sub')
.map(normalizeTrackListEntry)
.filter((track) => track.external && track.id !== null && track.id !== excludeId);
const exactTitleMatch = tracks.find(
(track) =>
expectedTitle.length > 0 &&
track.title.trim().toLowerCase() === expectedTitle &&
expectedLanguages.has(track.lang.trim().toLowerCase()),
);
if (exactTitleMatch?.id !== null && exactTitleMatch?.id !== undefined) {
return exactTitleMatch.id;
}
if (expectedTitle.length === 0) {
const languageOnlyMatch = tracks.find((track) =>
expectedLanguages.has(track.lang.trim().toLowerCase()),
);
if (languageOnlyMatch?.id !== null && languageOnlyMatch?.id !== undefined) {
return languageOnlyMatch.id;
}
}
return null;
}
function matchExternalTrackId( function matchExternalTrackId(
trackListRaw: unknown, trackListRaw: unknown,
filePath: string, filePath: string,
trackOption: YoutubeTrackOption,
excludeId: number | null = null, excludeId: number | null = null,
): number | null { ): number | null {
if (!Array.isArray(trackListRaw)) { if (!Array.isArray(trackListRaw)) {
@@ -209,16 +312,9 @@ function matchExternalTrackId(
return basenameMatch.id; return basenameMatch.id;
} }
const languageMatch = externalTracks.find((track) => track.lang === trackOption.sourceLanguage); const titleMatch = externalTracks.find((track) => matchesTitleBasename(track.title, basename));
if (languageMatch?.id !== null && languageMatch?.id !== undefined) { if (titleMatch?.id !== null && titleMatch?.id !== undefined) {
return languageMatch.id; return titleMatch.id;
}
const normalizedLanguageMatch = externalTracks.find(
(track) => track.lang === trackOption.language,
);
if (normalizedLanguageMatch?.id !== null && normalizedLanguageMatch?.id !== undefined) {
return normalizedLanguageMatch.id;
} }
return null; return null;
@@ -226,43 +322,61 @@ function matchExternalTrackId(
async function injectDownloadedSubtitles( async function injectDownloadedSubtitles(
deps: YoutubeFlowDeps, deps: YoutubeFlowDeps,
primaryTrack: YoutubeTrackOption, primarySelection: {
primaryPath: string, track: YoutubeTrackOption;
existingTrackId: number | null;
injectedPath: string | null;
},
secondaryTrack: YoutubeTrackOption | null, secondaryTrack: YoutubeTrackOption | null,
secondaryPath: string | null, secondarySelection: {
existingTrackId: number | null;
injectedPath: string | null;
} | null,
): Promise<boolean> { ): Promise<boolean> {
deps.sendMpvCommand(['set_property', 'sub-delay', 0]); deps.sendMpvCommand(['set_property', 'sub-delay', 0]);
deps.sendMpvCommand(['set_property', 'sid', 'no']); deps.sendMpvCommand(['set_property', 'sid', 'no']);
deps.sendMpvCommand(['set_property', 'secondary-sid', 'no']); deps.sendMpvCommand(['set_property', 'secondary-sid', 'no']);
deps.sendMpvCommand([ if (primarySelection.injectedPath) {
'sub-add',
primaryPath,
'select',
path.basename(primaryPath),
primaryTrack.sourceLanguage,
]);
if (secondaryPath && secondaryTrack) {
deps.sendMpvCommand([ deps.sendMpvCommand([
'sub-add', 'sub-add',
secondaryPath, primarySelection.injectedPath,
'select',
path.basename(primarySelection.injectedPath),
primarySelection.track.sourceLanguage,
]);
}
if (secondarySelection?.injectedPath && secondaryTrack) {
deps.sendMpvCommand([
'sub-add',
secondarySelection.injectedPath,
'cached', 'cached',
path.basename(secondaryPath), path.basename(secondarySelection.injectedPath),
secondaryTrack.sourceLanguage, secondaryTrack.sourceLanguage,
]); ]);
} }
let trackListRaw: unknown = null; let trackListRaw: unknown = await deps.requestMpvProperty('track-list');
let primaryTrackId: number | null = null; let primaryTrackId: number | null = primarySelection.existingTrackId;
let secondaryTrackId: number | null = null; let secondaryTrackId: number | null = secondarySelection?.existingTrackId ?? null;
for (let attempt = 0; attempt < 12; attempt += 1) { for (let attempt = 0; attempt < 12; attempt += 1) {
await deps.wait(attempt === 0 ? 150 : 100); if (attempt > 0 || primarySelection.injectedPath || secondarySelection?.injectedPath) {
trackListRaw = await deps.requestMpvProperty('track-list'); await deps.wait(attempt === 0 ? 150 : 100);
primaryTrackId = matchExternalTrackId(trackListRaw, primaryPath, primaryTrack); trackListRaw = await deps.requestMpvProperty('track-list');
secondaryTrackId = }
secondaryPath && secondaryTrack if (primaryTrackId === null && primarySelection.injectedPath) {
? matchExternalTrackId(trackListRaw, secondaryPath, secondaryTrack, primaryTrackId) primaryTrackId = matchExternalTrackId(trackListRaw, primarySelection.injectedPath);
: null; }
if (primaryTrackId !== null && (!secondaryPath || secondaryTrackId !== null)) { if (secondarySelection?.injectedPath && secondaryTrack && secondaryTrackId === null) {
secondaryTrackId = matchExternalTrackId(
trackListRaw,
secondarySelection.injectedPath,
primaryTrackId,
);
}
if (
primaryTrackId !== null &&
(!secondaryTrack || secondarySelection === null || secondaryTrackId !== null)
) {
break; break;
} }
} }
@@ -276,20 +390,25 @@ async function injectDownloadedSubtitles(
deps.sendMpvCommand(['set_property', 'sub-visibility', 'yes']); 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: ${
primarySelection.injectedPath ? path.basename(primarySelection.injectedPath) : primarySelection.track.label
}`,
); );
} }
if (secondaryPath && secondaryTrack) { if (secondaryTrack && secondarySelection) {
if (secondaryTrackId !== null) { if (secondaryTrackId !== null) {
await ensureSubtitleTrackSelection({ await ensureSubtitleTrackSelection({
deps, deps,
property: 'secondary-sid', property: 'secondary-sid',
targetId: secondaryTrackId, 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: ${
secondarySelection.injectedPath
? path.basename(secondarySelection.injectedPath)
: secondaryTrack.label
}`,
); );
} }
} }
@@ -304,7 +423,7 @@ async function injectDownloadedSubtitles(
} }
deps.showMpvOsd( deps.showMpvOsd(
secondaryPath && secondaryTrack ? 'Primary and secondary subtitles loaded.' : 'Subtitles loaded.', secondaryTrack ? 'Primary and secondary subtitles loaded.' : 'Subtitles loaded.',
); );
return true; return true;
} }
@@ -455,33 +574,134 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
osdProgress.setMessage('Downloading subtitles...'); osdProgress.setMessage('Downloading subtitles...');
} }
try { try {
const acquired = await acquireSelectedTracks({ let initialTrackListRaw: unknown = null;
targetUrl: input.url, let existingPrimaryTrackId: number | null = null;
outputDir: input.outputDir, let existingSecondaryTrackId: number | null = null;
primaryTrack: input.primaryTrack, for (let attempt = 0; attempt < 8; attempt += 1) {
secondaryTrack: input.secondaryTrack, if (attempt > 0) {
secondaryFailureLabel: input.secondaryFailureLabel, await deps.wait(attempt === 1 ? 150 : 100);
}); }
const resolvedPrimaryPath = await deps.retimeYoutubePrimaryTrack({ initialTrackListRaw = await deps.requestMpvProperty('track-list');
targetUrl: input.url, existingPrimaryTrackId =
primaryTrack: input.primaryTrack, input.primaryTrack.kind === 'manual'
primaryPath: acquired.primaryPath, ? matchExistingManualYoutubeTrackId(initialTrackListRaw, input.primaryTrack)
secondaryTrack: input.secondaryTrack, : null;
secondaryPath: acquired.secondaryPath, existingSecondaryTrackId =
}); input.secondaryTrack?.kind === 'manual'
? matchExistingManualYoutubeTrackId(
initialTrackListRaw,
input.secondaryTrack,
existingPrimaryTrackId,
)
: null;
const primaryReady = input.primaryTrack.kind !== 'manual' || existingPrimaryTrackId !== null;
const secondaryReady =
!input.secondaryTrack ||
input.secondaryTrack.kind !== 'manual' ||
existingSecondaryTrackId !== null;
if (primaryReady && secondaryReady) {
break;
}
}
let primarySidebarPath: string;
let primaryInjectedPath: string | null = null;
let secondaryInjectedPath: string | null = null;
if (existingPrimaryTrackId !== null) {
primarySidebarPath = (
await deps.acquireYoutubeSubtitleTrack({
targetUrl: input.url,
outputDir: input.outputDir,
track: input.primaryTrack,
})
).path;
} else if (existingSecondaryTrackId !== null || !input.secondaryTrack) {
primaryInjectedPath = (
await deps.acquireYoutubeSubtitleTrack({
targetUrl: input.url,
outputDir: input.outputDir,
track: input.primaryTrack,
})
).path;
primarySidebarPath = await deps.retimeYoutubePrimaryTrack({
targetUrl: input.url,
primaryTrack: input.primaryTrack,
primaryPath: primaryInjectedPath,
secondaryTrack: input.secondaryTrack,
secondaryPath: null,
});
primaryInjectedPath = primarySidebarPath;
} else {
const acquired = await acquireSelectedTracks({
targetUrl: input.url,
outputDir: input.outputDir,
primaryTrack: input.primaryTrack,
secondaryTrack: existingSecondaryTrackId === null ? input.secondaryTrack : null,
secondaryFailureLabel: input.secondaryFailureLabel,
});
primarySidebarPath = await deps.retimeYoutubePrimaryTrack({
targetUrl: input.url,
primaryTrack: input.primaryTrack,
primaryPath: acquired.primaryPath,
secondaryTrack: input.secondaryTrack,
secondaryPath: acquired.secondaryPath,
});
primaryInjectedPath = primarySidebarPath;
secondaryInjectedPath = acquired.secondaryPath;
}
if (input.secondaryTrack && existingSecondaryTrackId === null && secondaryInjectedPath === null) {
try {
secondaryInjectedPath = (
await deps.acquireYoutubeSubtitleTrack({
targetUrl: input.url,
outputDir: input.outputDir,
track: input.secondaryTrack,
})
).path;
} catch (error) {
const fallbackExistingSecondaryTrackId =
input.secondaryTrack.kind === 'auto'
? matchExistingYoutubeTrackId(
initialTrackListRaw,
input.secondaryTrack,
existingPrimaryTrackId,
)
: null;
if (fallbackExistingSecondaryTrackId !== null) {
existingSecondaryTrackId = fallbackExistingSecondaryTrackId;
} else {
deps.warn(
`${input.secondaryFailureLabel}: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
}
}
deps.showMpvOsd('Loading subtitles...'); deps.showMpvOsd('Loading subtitles...');
const refreshedActiveSubtitle = await injectDownloadedSubtitles( const refreshedActiveSubtitle = await injectDownloadedSubtitles(
deps, deps,
input.primaryTrack, {
resolvedPrimaryPath, track: input.primaryTrack,
existingTrackId: existingPrimaryTrackId,
injectedPath: primaryInjectedPath,
},
input.secondaryTrack, input.secondaryTrack,
acquired.secondaryPath, input.secondaryTrack
? {
existingTrackId: existingSecondaryTrackId,
injectedPath: secondaryInjectedPath,
}
: null,
); );
if (!refreshedActiveSubtitle) { if (!refreshedActiveSubtitle) {
return false; return false;
} }
try { try {
await deps.refreshSubtitleSidebarSource?.(resolvedPrimaryPath); await deps.refreshSubtitleSidebarSource?.(primarySidebarPath);
} catch (error) { } catch (error) {
deps.warn( deps.warn(
`Failed to refresh parsed subtitle cues for sidebar: ${ `Failed to refresh parsed subtitle cues for sidebar: ${