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> = []; 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([ [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> = []; const waits: number[] = []; let secondaryTrackAdded = false; const runtime = createYoutubeFlowRuntime({ probeYoutubeTracks: async () => ({ videoId: 'video123', title: 'Video 123', tracks: [primaryTrack, secondaryTrack], }), acquireYoutubeSubtitleTracks: async () => new Map([[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> = []; const waits: number[] = []; let secondarySidReads = 0; const runtime = createYoutubeFlowRuntime({ probeYoutubeTracks: async () => ({ videoId: 'video123', title: 'Video 123', tracks: [primaryTrack, secondaryTrack], }), acquireYoutubeSubtitleTracks: async () => new Map([ [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> = []; 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([ [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> = []; 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([ [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> = []; 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> = []; 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> = []; 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> = []; 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([['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); });