Files
SubMiner/src/main/runtime/youtube-flow.test.ts
sudacode 5feed360ca feat: add app-owned YouTube subtitle flow with absPlayer-style parsing (#31)
* fix: harden preload argv parsing for popup windows

* fix: align youtube playback with shared overlay startup

* fix: unwrap mpv youtube streams for anki media mining

* docs: update docs for youtube subtitle and mining flow

* refactor: unify cli and runtime wiring for startup and youtube flow

* feat: update subtitle sidebar overlay behavior

* chore: add shared log-file source for diagnostics

* fix(ci): add changelog fragment for immersion changes

* fix: address CodeRabbit review feedback

* fix: persist canonical title from youtube metadata

* style: format stats library tab

* fix: address latest review feedback

* style: format stats library files

* test: stub launcher youtube deps in CI

* test: isolate launcher youtube flow deps

* test: stub launcher youtube deps in failing case

* test: force x11 backend in launcher ci harness

* test: address latest review feedback

* fix(launcher): preserve user YouTube ytdl raw options

* docs(backlog): update task tracking notes

* fix(immersion): special-case youtube media paths in runtime and tracking

* feat(stats): improve YouTube media metadata and picker key handling

* fix(ci): format stats media library hook

* fix: address latest CodeRabbit review items

* docs: update youtube release notes and docs

* feat: auto-load youtube subtitles before manual picker

* fix: restore app-owned youtube subtitle flow

* docs: update youtube playback docs and config copy

* refactor: remove legacy youtube launcher mode plumbing

* fix: refine youtube subtitle startup binding

* docs: clarify youtube subtitle startup behavior

* fix: address PR #31 latest review follow-ups

* fix: address PR #31 follow-up review comments

* test: harden youtube picker test harness

* udpate backlog

* fix: add timeout to youtube metadata probe

* docs: refresh youtube and stats docs

* update backlog

* update backlog

* chore: release v0.9.0
2026-03-24 00:01:24 -07:00

1146 lines
35 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { createYoutubeFlowRuntime } from './youtube-flow';
import type { YoutubePickerOpenPayload, YoutubeTrackOption } from '../../types';
const primaryTrack: YoutubeTrackOption = {
id: 'auto:ja-orig',
language: 'ja',
sourceLanguage: 'ja-orig',
kind: 'auto',
label: 'Japanese (auto)',
};
const secondaryTrack: YoutubeTrackOption = {
id: 'manual:en',
language: 'en',
sourceLanguage: 'en',
kind: 'manual',
label: 'English (manual)',
};
test('youtube flow 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 () => ({
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 }) => ({
path: `/tmp/${track.id.replace(/[^a-z0-9_-]+/gi, '-')}.vtt`,
}),
retimeYoutubePrimaryTrack: async ({ primaryPath }) => `${primaryPath}.retimed`,
openPicker: async (payload) => {
openedPayloads.push(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.retimed',
},
{
type: 'sub',
id: 6,
lang: 'en',
title: 'secondary',
external: true,
'external-filename': '/tmp/manual-en.vtt',
},
];
},
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);
},
log: () => {},
getYoutubeOutputDir: () => '/tmp',
});
await runtime.openManualPicker({ url: 'https://example.com' });
assert.equal(openedPayloads.length, 1);
assert.equal(openedPayloads[0]?.defaultPrimaryTrackId, primaryTrack.id);
assert.equal(openedPayloads[0]?.defaultSecondaryTrackId, secondaryTrack.id);
assert.ok(waits.includes(150));
assert.deepEqual(osdMessages, [
'Getting subtitles...',
'Downloading subtitles...',
'Loading subtitles...',
'Primary and secondary subtitles loaded.',
]);
assert.ok(
commands.some(
(command) =>
command[0] === 'sub-add' &&
command[1] === '/tmp/auto-ja-orig.vtt.retimed' &&
command[2] === 'select',
),
);
assert.ok(
commands.some(
(command) =>
command[0] === 'set_property' &&
command[1] === 'sub-visibility' &&
command[2] === 'yes',
),
);
assert.ok(
commands.every(
(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[] = [];
let secondaryTrackAdded = false;
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);
if (
command[0] === 'sub-add' &&
command[1] === '/tmp/manual:en.vtt' &&
command[2] === 'cached'
) {
secondaryTrackAdded = true;
}
},
requestMpvProperty: async (name) => {
if (name === 'sub-text') {
return '字幕です';
}
return secondaryTrackAdded
? [
{
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',
},
]
: [
{
type: 'sub',
id: 5,
lang: 'ja-orig',
title: 'primary',
external: true,
'external-filename': '/tmp/auto-ja-orig.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.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);
},
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: 'manual-en.vtt',
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));
});
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);
});