From cbab8717e1a2dba03434f0743a9fa34735cc58df Mon Sep 17 00:00:00 2001 From: sudacode Date: Mon, 23 Mar 2026 20:22:41 -0700 Subject: [PATCH] fix: refine youtube subtitle startup binding --- src/main/runtime/youtube-flow.test.ts | 745 +++++++++++++++++++++++++- src/main/runtime/youtube-flow.ts | 336 ++++++++++-- 2 files changed, 998 insertions(+), 83 deletions(-) diff --git a/src/main/runtime/youtube-flow.test.ts b/src/main/runtime/youtube-flow.test.ts index 19c37d1..ccc6fe1 100644 --- a/src/main/runtime/youtube-flow.test.ts +++ b/src/main/runtime/youtube-flow.test.ts @@ -43,7 +43,9 @@ test('youtube flow can open a manual picker session and load the selected subtit [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`, openPicker: async (payload) => { openedPayloads.push(payload); @@ -146,11 +148,13 @@ test('youtube flow can open a manual picker session and load the selected subtit ), ); assert.ok( - commands.some( + commands.every( (command) => - command[0] === 'set_property' && - command[1] === 'secondary-sub-visibility' && - command[2] === 'yes', + !( + command[0] === 'set_property' && + command[1] === 'secondary-sub-visibility' && + command[2] === 'yes' + ), ), ); 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 commands: Array> = []; const waits: number[] = []; + let secondaryTrackAdded = false; const runtime = createYoutubeFlowRuntime({ probeYoutubeTracks: async () => ({ @@ -190,29 +195,47 @@ test('youtube flow retries secondary after partial batch subtitle failure', asyn 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 [ - { - 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', - }, - ]; + 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 () => {}, @@ -371,7 +394,9 @@ test('youtube flow retries secondary subtitle selection until mpv reports the ex [primaryTrack.id, '/tmp/auto-ja-orig.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, openPicker: async (payload) => { queueMicrotask(() => { @@ -410,7 +435,7 @@ test('youtube flow retries secondary subtitle selection until mpv reports the ex type: 'sub', id: 6, lang: 'en', - title: 'English', + title: 'manual-en.vtt', external: true, 'external-filename': null, }, @@ -448,3 +473,673 @@ test('youtube flow retries secondary subtitle selection until mpv reports the ex ); 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); +}); diff --git a/src/main/runtime/youtube-flow.ts b/src/main/runtime/youtube-flow.ts index cbb3f28..ac0ea80 100644 --- a/src/main/runtime/youtube-flow.ts +++ b/src/main/runtime/youtube-flow.ts @@ -175,10 +175,113 @@ function normalizeTrackListEntry(track: Record): { }; } +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 => 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 => 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( trackListRaw: unknown, filePath: string, - trackOption: YoutubeTrackOption, excludeId: number | null = null, ): number | null { if (!Array.isArray(trackListRaw)) { @@ -209,16 +312,9 @@ function matchExternalTrackId( return basenameMatch.id; } - const languageMatch = externalTracks.find((track) => track.lang === trackOption.sourceLanguage); - if (languageMatch?.id !== null && languageMatch?.id !== undefined) { - return languageMatch.id; - } - - const normalizedLanguageMatch = externalTracks.find( - (track) => track.lang === trackOption.language, - ); - if (normalizedLanguageMatch?.id !== null && normalizedLanguageMatch?.id !== undefined) { - return normalizedLanguageMatch.id; + const titleMatch = externalTracks.find((track) => matchesTitleBasename(track.title, basename)); + if (titleMatch?.id !== null && titleMatch?.id !== undefined) { + return titleMatch.id; } return null; @@ -226,43 +322,61 @@ function matchExternalTrackId( async function injectDownloadedSubtitles( deps: YoutubeFlowDeps, - primaryTrack: YoutubeTrackOption, - primaryPath: string, + primarySelection: { + track: YoutubeTrackOption; + existingTrackId: number | null; + injectedPath: string | null; + }, secondaryTrack: YoutubeTrackOption | null, - secondaryPath: string | null, + secondarySelection: { + existingTrackId: number | null; + injectedPath: string | null; + } | null, ): Promise { deps.sendMpvCommand(['set_property', 'sub-delay', 0]); deps.sendMpvCommand(['set_property', 'sid', 'no']); deps.sendMpvCommand(['set_property', 'secondary-sid', 'no']); - deps.sendMpvCommand([ - 'sub-add', - primaryPath, - 'select', - path.basename(primaryPath), - primaryTrack.sourceLanguage, - ]); - if (secondaryPath && secondaryTrack) { + if (primarySelection.injectedPath) { deps.sendMpvCommand([ 'sub-add', - secondaryPath, + primarySelection.injectedPath, + 'select', + path.basename(primarySelection.injectedPath), + primarySelection.track.sourceLanguage, + ]); + } + if (secondarySelection?.injectedPath && secondaryTrack) { + deps.sendMpvCommand([ + 'sub-add', + secondarySelection.injectedPath, 'cached', - path.basename(secondaryPath), + path.basename(secondarySelection.injectedPath), secondaryTrack.sourceLanguage, ]); } - let trackListRaw: unknown = null; - let primaryTrackId: number | null = null; - let secondaryTrackId: number | null = null; + let trackListRaw: unknown = await deps.requestMpvProperty('track-list'); + let primaryTrackId: number | null = primarySelection.existingTrackId; + let secondaryTrackId: number | null = secondarySelection?.existingTrackId ?? null; for (let attempt = 0; attempt < 12; attempt += 1) { - await deps.wait(attempt === 0 ? 150 : 100); - trackListRaw = await deps.requestMpvProperty('track-list'); - primaryTrackId = matchExternalTrackId(trackListRaw, primaryPath, primaryTrack); - secondaryTrackId = - secondaryPath && secondaryTrack - ? matchExternalTrackId(trackListRaw, secondaryPath, secondaryTrack, primaryTrackId) - : null; - if (primaryTrackId !== null && (!secondaryPath || secondaryTrackId !== null)) { + if (attempt > 0 || primarySelection.injectedPath || secondarySelection?.injectedPath) { + await deps.wait(attempt === 0 ? 150 : 100); + trackListRaw = await deps.requestMpvProperty('track-list'); + } + if (primaryTrackId === null && primarySelection.injectedPath) { + primaryTrackId = matchExternalTrackId(trackListRaw, primarySelection.injectedPath); + } + if (secondarySelection?.injectedPath && secondaryTrack && secondaryTrackId === null) { + secondaryTrackId = matchExternalTrackId( + trackListRaw, + secondarySelection.injectedPath, + primaryTrackId, + ); + } + if ( + primaryTrackId !== null && + (!secondaryTrack || secondarySelection === null || secondaryTrackId !== null) + ) { break; } } @@ -276,20 +390,25 @@ async function injectDownloadedSubtitles( deps.sendMpvCommand(['set_property', 'sub-visibility', 'yes']); } else { 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) { await ensureSubtitleTrackSelection({ deps, property: 'secondary-sid', targetId: secondaryTrackId, }); - deps.sendMpvCommand(['set_property', 'secondary-sub-visibility', 'yes']); } else { deps.warn( - `Unable to bind downloaded secondary subtitle track in mpv: ${path.basename(secondaryPath)}`, + `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( - secondaryPath && secondaryTrack ? 'Primary and secondary subtitles loaded.' : 'Subtitles loaded.', + secondaryTrack ? 'Primary and secondary subtitles loaded.' : 'Subtitles loaded.', ); return true; } @@ -455,33 +574,134 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) { osdProgress.setMessage('Downloading subtitles...'); } try { - const acquired = await acquireSelectedTracks({ - targetUrl: input.url, - outputDir: input.outputDir, - primaryTrack: input.primaryTrack, - secondaryTrack: input.secondaryTrack, - secondaryFailureLabel: input.secondaryFailureLabel, - }); - const resolvedPrimaryPath = await deps.retimeYoutubePrimaryTrack({ - targetUrl: input.url, - primaryTrack: input.primaryTrack, - primaryPath: acquired.primaryPath, - secondaryTrack: input.secondaryTrack, - secondaryPath: acquired.secondaryPath, - }); + let initialTrackListRaw: unknown = null; + let existingPrimaryTrackId: number | null = null; + let existingSecondaryTrackId: number | null = null; + for (let attempt = 0; attempt < 8; attempt += 1) { + if (attempt > 0) { + await deps.wait(attempt === 1 ? 150 : 100); + } + initialTrackListRaw = await deps.requestMpvProperty('track-list'); + existingPrimaryTrackId = + input.primaryTrack.kind === 'manual' + ? matchExistingManualYoutubeTrackId(initialTrackListRaw, input.primaryTrack) + : null; + 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...'); const refreshedActiveSubtitle = await injectDownloadedSubtitles( deps, - input.primaryTrack, - resolvedPrimaryPath, + { + track: input.primaryTrack, + existingTrackId: existingPrimaryTrackId, + injectedPath: primaryInjectedPath, + }, input.secondaryTrack, - acquired.secondaryPath, + input.secondaryTrack + ? { + existingTrackId: existingSecondaryTrackId, + injectedPath: secondaryInjectedPath, + } + : null, ); if (!refreshedActiveSubtitle) { return false; } try { - await deps.refreshSubtitleSidebarSource?.(resolvedPrimaryPath); + await deps.refreshSubtitleSidebarSource?.(primarySidebarPath); } catch (error) { deps.warn( `Failed to refresh parsed subtitle cues for sidebar: ${