diff --git a/launcher/commands/playback-command.test.ts b/launcher/commands/playback-command.test.ts index b20b587..29a4b38 100644 --- a/launcher/commands/playback-command.test.ts +++ b/launcher/commands/playback-command.test.ts @@ -82,16 +82,26 @@ function createContext(): LauncherCommandContext { }; } -test('youtube playback launches overlay with youtube-play args in the primary app start', async () => { +test('youtube playback launches overlay with app-owned youtube flow args', async () => { const calls: string[] = []; const context = createContext(); + let receivedStartMpvOptions: Record | null = null; await runPlaybackCommandWithDeps(context, { ensurePlaybackSetupReady: async () => {}, chooseTarget: async (_args, _scriptPath) => ({ target: context.args.target, kind: 'url' }), checkDependencies: () => {}, registerCleanup: () => {}, - startMpv: async () => { + startMpv: async ( + _target, + _targetKind, + _args, + _socketPath, + _appPath, + _preloadedSubtitles, + options, + ) => { + receivedStartMpvOptions = options ?? null; calls.push('startMpv'); }, waitForUnixSocketReady: async () => true, @@ -110,4 +120,8 @@ test('youtube playback launches overlay with youtube-play args in the primary ap 'startMpv', 'startOverlay:--youtube-play https://www.youtube.com/watch?v=65Ovd7t8sNw --youtube-mode download', ]); + assert.deepEqual(receivedStartMpvOptions, { + startPaused: true, + disableYoutubeSubtitleAutoLoad: true, + }); }); diff --git a/launcher/mpv.ts b/launcher/mpv.ts index 161af4a..6305351 100644 --- a/launcher/mpv.ts +++ b/launcher/mpv.ts @@ -560,28 +560,25 @@ export async function startMpv( if (targetKind === 'url' && isYoutubeTarget(target)) { log('info', args.logLevel, 'Applying URL playback options'); mpvArgs.push('--ytdl=yes'); - - if (isYoutubeTarget(target)) { - const subtitleLangs = uniqueNormalizedLangCodes([ - ...args.youtubePrimarySubLangs, - ...args.youtubeSecondarySubLangs, - ]).join(','); - const audioLangs = uniqueNormalizedLangCodes(args.youtubeAudioLangs).join(','); - log('info', args.logLevel, 'Applying YouTube playback options'); - log('debug', args.logLevel, `YouTube subtitle langs: ${subtitleLangs}`); - log('debug', args.logLevel, `YouTube audio langs: ${audioLangs}`); - mpvArgs.push(`--ytdl-format=${DEFAULT_YOUTUBE_YTDL_FORMAT}`, `--alang=${audioLangs}`); - if (options?.disableYoutubeSubtitleAutoLoad !== true) { - mpvArgs.push( - '--sub-auto=fuzzy', - `--slang=${subtitleLangs}`, - '--ytdl-raw-options-append=write-subs=', - '--ytdl-raw-options-append=sub-format=vtt/best', - `--ytdl-raw-options-append=sub-langs=${subtitleLangs}`, - ); - } else { - mpvArgs.push('--sub-auto=no'); - } + const subtitleLangs = uniqueNormalizedLangCodes([ + ...args.youtubePrimarySubLangs, + ...args.youtubeSecondarySubLangs, + ]).join(','); + const audioLangs = uniqueNormalizedLangCodes(args.youtubeAudioLangs).join(','); + log('info', args.logLevel, 'Applying YouTube playback options'); + log('debug', args.logLevel, `YouTube subtitle langs: ${subtitleLangs}`); + log('debug', args.logLevel, `YouTube audio langs: ${audioLangs}`); + mpvArgs.push(`--ytdl-format=${DEFAULT_YOUTUBE_YTDL_FORMAT}`, `--alang=${audioLangs}`); + if (options?.disableYoutubeSubtitleAutoLoad !== true) { + mpvArgs.push( + '--sub-auto=fuzzy', + `--slang=${subtitleLangs}`, + '--ytdl-raw-options-append=write-subs=', + '--ytdl-raw-options-append=sub-format=vtt/best', + `--ytdl-raw-options-append=sub-langs=${subtitleLangs}`, + ); + } else { + mpvArgs.push('--sub-auto=no'); } } if (args.mpvArgs) { diff --git a/src/cli/args.test.ts b/src/cli/args.test.ts index f8d0ded..1e24531 100644 --- a/src/cli/args.test.ts +++ b/src/cli/args.test.ts @@ -56,8 +56,13 @@ test('parseArgs captures launch-mpv targets and keeps it out of app startup', () assert.equal(shouldStartApp(args), false); }); -test('parseArgs captures youtube playback commands and mode', () => { - const args = parseArgs(['--youtube-play', 'https://youtube.com/watch?v=abc', '--youtube-mode', 'generate']); +test('parseArgs captures youtube startup forwarding flags', () => { + const args = parseArgs([ + '--youtube-play', + 'https://youtube.com/watch?v=abc', + '--youtube-mode', + 'generate', + ]); assert.equal(args.youtubePlay, 'https://youtube.com/watch?v=abc'); assert.equal(args.youtubeMode, 'generate'); diff --git a/src/cli/args.ts b/src/cli/args.ts index eba4946..d9dd468 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -499,7 +499,7 @@ export function commandNeedsOverlayRuntime(args: CliArgs): boolean { args.triggerFieldGrouping || args.triggerSubsync || args.markAudioCard || - args.openRuntimeOptions - || Boolean(args.youtubePlay) + args.openRuntimeOptions || + Boolean(args.youtubePlay) ); } diff --git a/src/core/services/cli-command.test.ts b/src/core/services/cli-command.test.ts index d59d287..eb92472 100644 --- a/src/core/services/cli-command.test.ts +++ b/src/core/services/cli-command.test.ts @@ -186,8 +186,8 @@ function createDeps(overrides: Partial = {}) { runJellyfinCommand: async () => { calls.push('runJellyfinCommand'); }, - runYoutubePlaybackFlow: async ({ url, mode }) => { - calls.push(`runYoutubePlaybackFlow:${url}:${mode}`); + runYoutubePlaybackFlow: async (request) => { + calls.push(`runYoutubePlaybackFlow:${request.url}:${request.mode}:${request.source}`); }, printHelp: () => { calls.push('printHelp'); @@ -212,25 +212,6 @@ function createDeps(overrides: Partial = {}) { return { deps, calls, osd }; } -test('handleCliCommand reconnects MPV for second-instance --start when overlay runtime is already initialized', () => { - const { deps, calls } = createDeps({ - isOverlayRuntimeInitialized: () => true, - }); - const args = makeArgs({ start: true }); - - handleCliCommand(args, 'second-instance', deps); - - assert.ok(calls.includes('setMpvClientSocketPath:/tmp/subminer.sock')); - assert.equal( - calls.some((value) => value.includes('connectMpvClient')), - true, - ); - assert.equal( - calls.some((value) => value.includes('initializeOverlayRuntime')), - false, - ); -}); - test('handleCliCommand starts youtube playback flow on initial launch', () => { const { deps, calls } = createDeps({ runYoutubePlaybackFlow: async (request) => { @@ -265,6 +246,25 @@ test('handleCliCommand defaults youtube mode to download when omitted', () => { ]); }); +test('handleCliCommand reconnects MPV for second-instance --start when overlay runtime is already initialized', () => { + const { deps, calls } = createDeps({ + isOverlayRuntimeInitialized: () => true, + }); + const args = makeArgs({ start: true }); + + handleCliCommand(args, 'second-instance', deps); + + assert.ok(calls.includes('setMpvClientSocketPath:/tmp/subminer.sock')); + assert.equal( + calls.some((value) => value.includes('connectMpvClient')), + true, + ); + assert.equal( + calls.some((value) => value.includes('initializeOverlayRuntime')), + false, + ); +}); + test('handleCliCommand processes --start for second-instance when overlay runtime is not initialized', () => { const { deps, calls } = createDeps(); const args = makeArgs({ start: true }); diff --git a/src/core/services/cli-command.ts b/src/core/services/cli-command.ts index 95e32bb..f136e00 100644 --- a/src/core/services/cli-command.ts +++ b/src/core/services/cli-command.ts @@ -404,18 +404,11 @@ export function handleCliCommand( deps.openJellyfinSetup(); deps.log('Opened Jellyfin setup flow.'); } else if (args.youtubePlay) { - const youtubeUrl = args.youtubePlay; - runAsyncWithOsd( - () => - deps.runYoutubePlaybackFlow({ - url: youtubeUrl, - mode: args.youtubeMode ?? 'download', - source, - }), - deps, - 'runYoutubePlaybackFlow', - 'YouTube playback failed', - ); + void deps.runYoutubePlaybackFlow({ + url: args.youtubePlay, + mode: args.youtubeMode ?? 'download', + source, + }); } else if (args.dictionary) { const shouldStopAfterRun = source === 'initial' && !deps.hasMainWindow(); deps.log('Generating character dictionary for current anime...'); diff --git a/src/core/services/youtube/generate.ts b/src/core/services/youtube/generate.ts index c76c115..a4f369b 100644 --- a/src/core/services/youtube/generate.ts +++ b/src/core/services/youtube/generate.ts @@ -1,16 +1,10 @@ -import type { YoutubeFlowMode } from '../../../types'; import type { YoutubeTrackOption } from './track-probe'; import { downloadYoutubeSubtitleTrack, downloadYoutubeSubtitleTracks } from './track-download'; -export function isYoutubeGenerationMode(mode: YoutubeFlowMode): boolean { - return mode === 'generate'; -} - export async function acquireYoutubeSubtitleTrack(input: { targetUrl: string; outputDir: string; track: YoutubeTrackOption; - mode: YoutubeFlowMode; }): Promise<{ path: string }> { return await downloadYoutubeSubtitleTrack(input); } @@ -19,7 +13,6 @@ export async function acquireYoutubeSubtitleTracks(input: { targetUrl: string; outputDir: string; tracks: YoutubeTrackOption[]; - mode: YoutubeFlowMode; }): Promise> { return await downloadYoutubeSubtitleTracks(input); } diff --git a/src/core/services/youtube/timedtext.test.ts b/src/core/services/youtube/timedtext.test.ts index 062220e..1f543cd 100644 --- a/src/core/services/youtube/timedtext.test.ts +++ b/src/core/services/youtube/timedtext.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { convertYoutubeTimedTextToVtt } from './timedtext'; +import { convertYoutubeTimedTextToVtt, normalizeYoutubeAutoVtt } from './timedtext'; test('convertYoutubeTimedTextToVtt leaves malformed numeric entities literal', () => { const result = convertYoutubeTimedTextToVtt( @@ -38,3 +38,38 @@ test('convertYoutubeTimedTextToVtt does not swallow text after zero-length overl ].join('\n'), ); }); + +test('normalizeYoutubeAutoVtt strips cumulative rolling-caption prefixes', () => { + const result = normalizeYoutubeAutoVtt( + [ + 'WEBVTT', + '', + '00:00:01.000 --> 00:00:02.000', + '今日は', + '', + '00:00:02.000 --> 00:00:03.000', + '今日はいい天気ですね', + '', + '00:00:03.000 --> 00:00:04.000', + '今日はいい天気ですね本当に', + '', + ].join('\n'), + ); + + assert.equal( + result, + [ + 'WEBVTT', + '', + '00:00:01.000 --> 00:00:02.000', + '今日は', + '', + '00:00:02.000 --> 00:00:03.000', + 'いい天気ですね', + '', + '00:00:03.000 --> 00:00:04.000', + '本当に', + '', + ].join('\n'), + ); +}); diff --git a/src/core/services/youtube/timedtext.ts b/src/core/services/youtube/timedtext.ts index 6580c57..e7d2231 100644 --- a/src/core/services/youtube/timedtext.ts +++ b/src/core/services/youtube/timedtext.ts @@ -115,3 +115,52 @@ export function convertYoutubeTimedTextToVtt(xml: string): string { return `WEBVTT\n\n${blocks.join('\n\n')}\n`; } + +function normalizeRollingCaptionText(text: string, previousText: string): string { + if (!previousText || !text.startsWith(previousText)) { + return text; + } + return text.slice(previousText.length).trimStart(); +} + +export function normalizeYoutubeAutoVtt(content: string): string { + const normalizedContent = content.replace(/\r\n?/g, '\n'); + const blocks = normalizedContent.split(/\n{2,}/); + if (blocks.length === 0) { + return content; + } + + let previousText = ''; + let changed = false; + const normalizedBlocks = blocks.map((block) => { + if (!block.includes('-->')) { + return block; + } + + const lines = block.split('\n'); + const timingLineIndex = lines.findIndex((line) => line.includes('-->')); + if (timingLineIndex < 0 || timingLineIndex === lines.length - 1) { + return block; + } + + const textLines = lines.slice(timingLineIndex + 1); + const originalText = textLines.join('\n').trim(); + if (!originalText) { + return block; + } + + const normalizedText = normalizeRollingCaptionText(originalText, previousText); + previousText = originalText; + if (!normalizedText || normalizedText === originalText) { + return block; + } + + changed = true; + return [...lines.slice(0, timingLineIndex + 1), normalizedText].join('\n'); + }); + + if (!changed) { + return content; + } + return `${normalizedBlocks.join('\n\n')}\n`; +} diff --git a/src/core/services/youtube/track-download.test.ts b/src/core/services/youtube/track-download.test.ts index f6c4d17..7832e2e 100644 --- a/src/core/services/youtube/track-download.test.ts +++ b/src/core/services/youtube/track-download.test.ts @@ -174,7 +174,6 @@ test('downloadYoutubeSubtitleTrack prefers subtitle files over later webp artifa kind: 'auto', label: 'Japanese (auto)', }, - mode: 'download', }); assert.equal(path.extname(result.path), '.vtt'); @@ -204,7 +203,6 @@ test('downloadYoutubeSubtitleTrack ignores stale subtitle files from prior runs' kind: 'auto', label: 'Japanese (auto)', }, - mode: 'download', }), /No subtitle file was downloaded/, ); @@ -233,7 +231,6 @@ test('downloadYoutubeSubtitleTrack uses auto subtitle flags and raw source langu kind: 'auto', label: 'Japanese (auto)', }, - mode: 'download', }); assert.equal(path.extname(result.path), '.vtt'); @@ -264,7 +261,6 @@ test('downloadYoutubeSubtitleTrack keeps manual subtitle flag for manual tracks' kind: 'manual', label: 'Japanese (manual)', }, - mode: 'download', }); assert.equal(path.extname(result.path), '.vtt'); @@ -273,6 +269,43 @@ test('downloadYoutubeSubtitleTrack keeps manual subtitle flag for manual tracks' }); }); +test('downloadYoutubeSubtitleTrack normalizes rolling auto-caption vtt output from yt-dlp', async () => { + if (process.platform === 'win32') { + return; + } + + await withFakeYtDlp('rolling-auto', async (root) => { + const result = await downloadYoutubeSubtitleTrack({ + targetUrl: 'https://www.youtube.com/watch?v=abc123', + outputDir: path.join(root, 'out'), + track: { + id: 'auto:ja-orig', + language: 'ja', + sourceLanguage: 'ja-orig', + kind: 'auto', + label: 'Japanese (auto)', + }, + }); + + assert.equal( + fs.readFileSync(result.path, 'utf8'), + [ + 'WEBVTT', + '', + '00:00:01.000 --> 00:00:02.000', + '今日は', + '', + '00:00:02.000 --> 00:00:03.000', + 'いい天気ですね', + '', + '00:00:03.000 --> 00:00:04.000', + '本当に', + '', + ].join('\n'), + ); + }); +}); + test('downloadYoutubeSubtitleTrack prefers direct download URL when available', async () => { await withTempDir(async (root) => { await withStubFetch( @@ -293,7 +326,6 @@ test('downloadYoutubeSubtitleTrack prefers direct download URL when available', downloadUrl: 'https://example.com/subs/ja.vtt', fileExtension: 'vtt', }, - mode: 'download', }); assert.equal(path.basename(result.path), 'auto-ja-orig.ja-orig.vtt'); @@ -320,7 +352,6 @@ test('downloadYoutubeSubtitleTrack sanitizes metadata source language in filenam downloadUrl: 'https://example.com/subs/ja.vtt', fileExtension: 'vtt', }, - mode: 'download', }); assert.equal(path.dirname(result.path), path.join(root, 'out')); @@ -359,7 +390,6 @@ test('downloadYoutubeSubtitleTrack converts srv3 auto subtitles into regular vtt downloadUrl: 'https://example.com/subs/ja.srv3', fileExtension: 'srv3', }, - mode: 'download', }); assert.equal(path.basename(result.path), 'auto-ja-orig.ja-orig.vtt'); @@ -410,7 +440,6 @@ test('downloadYoutubeSubtitleTracks downloads primary and secondary in one invoc label: 'English (auto)', }, ], - mode: 'download', }); assert.match(path.basename(result.get('auto:ja-orig') ?? ''), /\.ja-orig\.vtt$/); @@ -444,7 +473,6 @@ test('downloadYoutubeSubtitleTracks preserves successfully downloaded primary fi label: 'English (auto)', }, ], - mode: 'download', }); assert.match(path.basename(result.get('auto:ja-orig') ?? ''), /\.ja-orig\.vtt$/); @@ -484,7 +512,6 @@ test('downloadYoutubeSubtitleTracks prefers direct download URLs when available' fileExtension: 'vtt', }, ], - mode: 'download', }); assert.deepEqual(seen, [ @@ -530,7 +557,6 @@ test('downloadYoutubeSubtitleTracks keeps duplicate source-language direct downl fileExtension: 'vtt', }, ], - mode: 'download', }); assert.deepEqual(seen, [ diff --git a/src/core/services/youtube/track-download.ts b/src/core/services/youtube/track-download.ts index 7245cc2..62c4bbe 100644 --- a/src/core/services/youtube/track-download.ts +++ b/src/core/services/youtube/track-download.ts @@ -1,9 +1,12 @@ import fs from 'node:fs'; import path from 'node:path'; import { spawn } from 'node:child_process'; -import type { YoutubeFlowMode } from '../../../types'; import type { YoutubeTrackOption } from './track-probe'; -import { convertYoutubeTimedTextToVtt, isYoutubeTimedTextExtension } from './timedtext'; +import { + convertYoutubeTimedTextToVtt, + isYoutubeTimedTextExtension, + normalizeYoutubeAutoVtt, +} from './timedtext'; const YOUTUBE_SUBTITLE_EXTENSIONS = new Set(['.srt', '.vtt', '.ass']); const YOUTUBE_BATCH_PREFIX = 'youtube-batch'; @@ -171,7 +174,11 @@ async function downloadSubtitleFromUrl(input: { throw new Error(`HTTP ${response.status} while downloading ${input.track.sourceLanguage}`); } const body = await response.text(); - const normalizedBody = isYoutubeTimedTextExtension(ext) ? convertYoutubeTimedTextToVtt(body) : body; + const normalizedBody = isYoutubeTimedTextExtension(ext) + ? convertYoutubeTimedTextToVtt(body) + : input.track.kind === 'auto' && safeExt === 'vtt' + ? normalizeYoutubeAutoVtt(body) + : body; fs.writeFileSync(targetPath, normalizedBody, 'utf8'); return { path: targetPath }; } @@ -185,11 +192,21 @@ function canDownloadSubtitleFromUrl(track: YoutubeTrackOption): boolean { return isYoutubeTimedTextExtension(ext) || YOUTUBE_SUBTITLE_EXTENSIONS.has(`.${ext}`); } +function normalizeDownloadedAutoSubtitle(pathname: string, track: YoutubeTrackOption): void { + if (track.kind !== 'auto' || path.extname(pathname).toLowerCase() !== '.vtt') { + return; + } + const content = fs.readFileSync(pathname, 'utf8'); + const normalized = normalizeYoutubeAutoVtt(content); + if (normalized !== content) { + fs.writeFileSync(pathname, normalized, 'utf8'); + } +} + export async function downloadYoutubeSubtitleTrack(input: { targetUrl: string; outputDir: string; track: YoutubeTrackOption; - mode: YoutubeFlowMode; }): Promise<{ path: string }> { fs.mkdirSync(input.outputDir, { recursive: true }); const prefix = input.track.id.replace(/[^a-z0-9_-]+/gi, '-'); @@ -215,7 +232,7 @@ export async function downloadYoutubeSubtitleTrack(input: { targetUrl: input.targetUrl, outputTemplate, sourceLanguages: [input.track.sourceLanguage], - includeAutoSubs: input.mode === 'generate' || input.track.kind === 'auto', + includeAutoSubs: input.track.kind === 'auto', includeManualSubs: input.track.kind === 'manual', }), ]; @@ -225,6 +242,7 @@ export async function downloadYoutubeSubtitleTrack(input: { if (!subtitlePath) { throw new Error(`No subtitle file was downloaded for ${input.track.sourceLanguage}`); } + normalizeDownloadedAutoSubtitle(subtitlePath, input.track); return { path: subtitlePath }; } @@ -232,7 +250,6 @@ export async function downloadYoutubeSubtitleTracks(input: { targetUrl: string; outputDir: string; tracks: YoutubeTrackOption[]; - mode: YoutubeFlowMode; }): Promise> { fs.mkdirSync(input.outputDir, { recursive: true }); const hasDuplicateSourceLanguages = @@ -260,8 +277,7 @@ export async function downloadYoutubeSubtitleTracks(input: { } const outputTemplate = path.join(input.outputDir, `${YOUTUBE_BATCH_PREFIX}.%(ext)s`); - const includeAutoSubs = - input.mode === 'generate' || input.tracks.some((track) => track.kind === 'auto'); + const includeAutoSubs = input.tracks.some((track) => track.kind === 'auto'); const includeManualSubs = input.tracks.some((track) => track.kind === 'manual'); const result = await runCaptureDetailed( @@ -283,6 +299,7 @@ export async function downloadYoutubeSubtitleTracks(input: { track.sourceLanguage, ); if (subtitlePath) { + normalizeDownloadedAutoSubtitle(subtitlePath, track); results.set(track.id, subtitlePath); } } diff --git a/src/main.ts b/src/main.ts index a37b36a..b4d9236 100644 --- a/src/main.ts +++ b/src/main.ts @@ -324,6 +324,10 @@ import { shouldAutoOpenFirstRunSetup, } from './main/runtime/first-run-setup-service'; import { createYoutubeFlowRuntime } from './main/runtime/youtube-flow'; +import { + clearYoutubePrimarySubtitleNotificationTimer, + createYoutubePrimarySubtitleNotificationRuntime, +} from './main/runtime/youtube-primary-subtitle-notification'; import { resolveAutoplayReadyMaxReleaseAttempts } from './main/runtime/startup-autoplay-release-policy'; import { buildFirstRunSetupHtml, @@ -802,9 +806,6 @@ const appState = createAppState({ texthookerPort: DEFAULT_TEXTHOOKER_PORT, }); const startBackgroundWarmupsIfAllowed = (): void => { - if (appState.youtubePlaybackFlowPending) { - return; - } startBackgroundWarmups(); }; const youtubeFlowRuntime = createYoutubeFlowRuntime({ @@ -942,11 +943,9 @@ const youtubeFlowRuntime = createYoutubeFlowRuntime({ async function runYoutubePlaybackFlowMain(request: { url: string; - mode: 'download' | 'generate'; + mode: NonNullable; source: CliCommandSource; }): Promise { - const wasYoutubePlaybackFlowPending = appState.youtubePlaybackFlowPending; - appState.youtubePlaybackFlowPending = true; if (process.platform === 'win32' && !appState.mpvClient?.connected) { const launchResult = launchWindowsMpv( [request.url], @@ -969,18 +968,12 @@ async function runYoutubePlaybackFlowMain(request: { if (!appState.mpvClient?.connected) { appState.mpvClient?.connect(); } - try { - await youtubeFlowRuntime.runYoutubePlaybackFlow({ - url: request.url, - mode: request.mode, - }); - logger.info(`YouTube playback flow completed from ${request.source}.`); - } finally { - appState.youtubePlaybackFlowPending = wasYoutubePlaybackFlowPending; - if (!wasYoutubePlaybackFlowPending) { - startBackgroundWarmupsIfAllowed(); - } - } + + await youtubeFlowRuntime.runYoutubePlaybackFlow({ + url: request.url, + mode: request.mode, + }); + logger.info(`YouTube playback flow completed from ${request.source}.`); } let firstRunSetupMessage: string | null = null; @@ -1236,6 +1229,12 @@ const currentMediaTokenizationGate = createCurrentMediaTokenizationGate(); const startupOsdSequencer = createStartupOsdSequencer({ showOsd: (message) => showMpvOsd(message), }); +const youtubePrimarySubtitleNotificationRuntime = createYoutubePrimarySubtitleNotificationRuntime({ + getPrimarySubtitleLanguages: () => getResolvedConfig().youtubeSubgen.primarySubLanguages, + notifyFailure: (message) => reportYoutubeSubtitleFailure(message), + schedule: (fn, delayMs) => setTimeout(fn, delayMs), + clearSchedule: clearYoutubePrimarySubtitleNotificationTimer, +}); function isYoutubePlaybackActiveNow(): boolean { return isYoutubePlaybackActive( @@ -1267,7 +1266,6 @@ async function openYoutubeTrackPickerFromPlayback(): Promise { } await youtubeFlowRuntime.openManualPicker({ url: currentMediaPath, - mode: 'download', }); } @@ -1275,9 +1273,6 @@ function maybeSignalPluginAutoplayReady( payload: SubtitleData, options?: { forceWhilePaused?: boolean }, ): void { - if (appState.youtubePlaybackFlowPending) { - return; - } if (!payload.text.trim()) { return; } @@ -3544,6 +3539,7 @@ const { startupOsdSequencer.reset(); clearScheduledSubtitlePrefetchRefresh(); subtitlePrefetchInitController.cancelPendingInit(); + youtubePrimarySubtitleNotificationRuntime.handleMediaPathChange(path); if (path) { ensureImmersionTrackerStarted(); // Delay slightly to allow MPV's track-list to be populated. @@ -3571,9 +3567,6 @@ const { immersionMediaRuntime.syncFromCurrentMediaState(); }, signalAutoplayReadyIfWarm: () => { - if (appState.youtubePlaybackFlowPending) { - return; - } if (!isTokenizationWarmupReady()) { return; } @@ -3604,11 +3597,13 @@ const { } lastObservedTimePos = time; }, - onSubtitleTrackChange: () => { + onSubtitleTrackChange: (sid) => { scheduleSubtitlePrefetchRefresh(); + youtubePrimarySubtitleNotificationRuntime.handleSubtitleTrackChange(sid); }, - onSubtitleTrackListChange: () => { + onSubtitleTrackListChange: (trackList) => { scheduleSubtitlePrefetchRefresh(); + youtubePrimarySubtitleNotificationRuntime.handleSubtitleTrackListChange(trackList); }, updateSubtitleRenderMetrics: (patch) => { updateMpvSubtitleRenderMetrics(patch as Partial); @@ -4867,10 +4862,7 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } = appState.overlayRuntimeInitialized = initialized; }, startBackgroundWarmups: () => { - if ( - (appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)) || - appState.youtubePlaybackFlowPending - ) { + if (appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)) { return; } startBackgroundWarmups(); diff --git a/src/main/cli-runtime.ts b/src/main/cli-runtime.ts index 4283893..b0f07a8 100644 --- a/src/main/cli-runtime.ts +++ b/src/main/cli-runtime.ts @@ -1,6 +1,5 @@ import { handleCliCommand, createCliCommandDepsRuntime } from '../core/services'; import type { CliArgs, CliCommandSource } from '../cli/args'; -import type { YoutubeFlowMode } from '../types'; import { createCliCommandRuntimeServiceDeps, CliCommandRuntimeServiceDepsParams, @@ -39,11 +38,7 @@ export interface CliCommandRuntimeServiceContext { openJellyfinSetup: CliCommandRuntimeServiceDepsParams['jellyfin']['openSetup']; runStatsCommand: CliCommandRuntimeServiceDepsParams['jellyfin']['runStatsCommand']; runJellyfinCommand: CliCommandRuntimeServiceDepsParams['jellyfin']['runCommand']; - runYoutubePlaybackFlow: (request: { - url: string; - mode: YoutubeFlowMode; - source: CliCommandSource; - }) => Promise; + runYoutubePlaybackFlow: CliCommandRuntimeServiceDepsParams['app']['runYoutubePlaybackFlow']; openYomitanSettings: () => void; cycleSecondarySubMode: () => void; openRuntimeOptionsPalette: () => void; diff --git a/src/main/runtime/cli-command-context-deps.test.ts b/src/main/runtime/cli-command-context-deps.test.ts index 4ee2d92..fe242d8 100644 --- a/src/main/runtime/cli-command-context-deps.test.ts +++ b/src/main/runtime/cli-command-context-deps.test.ts @@ -61,7 +61,7 @@ test('build cli command context deps maps handlers and values', () => { calls.push('run-jellyfin'); }, runYoutubePlaybackFlow: async () => { - calls.push('run-youtube'); + calls.push('run-youtube-playback'); }, openYomitanSettings: () => calls.push('yomitan'), cycleSecondarySubMode: () => calls.push('cycle-secondary'), diff --git a/src/main/runtime/cli-command-context-factory.test.ts b/src/main/runtime/cli-command-context-factory.test.ts index 14e82c6..c7b32da 100644 --- a/src/main/runtime/cli-command-context-factory.test.ts +++ b/src/main/runtime/cli-command-context-factory.test.ts @@ -9,7 +9,6 @@ test('cli command context factory composes main deps and context handlers', () = mpvClient: null, texthookerPort: 5174, overlayRuntimeInitialized: false, - youtubePlaybackFlowPending: false, }; const createContext = createCliCommandContextFactory({ diff --git a/src/main/runtime/cli-command-context-main-deps.test.ts b/src/main/runtime/cli-command-context-main-deps.test.ts index 19411a1..ee4a402 100644 --- a/src/main/runtime/cli-command-context-main-deps.test.ts +++ b/src/main/runtime/cli-command-context-main-deps.test.ts @@ -9,7 +9,6 @@ test('cli command context main deps builder maps state and callbacks', async () mpvClient: null, texthookerPort: 5174, overlayRuntimeInitialized: false, - youtubePlaybackFlowPending: false, }; const build = createBuildCliCommandContextMainDepsHandler({ @@ -86,9 +85,8 @@ test('cli command context main deps builder maps state and callbacks', async () calls.push('run-jellyfin'); }, runYoutubePlaybackFlow: async () => { - calls.push('run-youtube'); + calls.push('run-youtube-playback'); }, - openYomitanSettings: () => calls.push('open-yomitan'), cycleSecondarySubMode: () => calls.push('cycle-secondary'), openRuntimeOptionsPalette: () => calls.push('open-runtime-options'), diff --git a/src/main/runtime/cli-command-context-main-deps.ts b/src/main/runtime/cli-command-context-main-deps.ts index 40e26f8..ed39ce5 100644 --- a/src/main/runtime/cli-command-context-main-deps.ts +++ b/src/main/runtime/cli-command-context-main-deps.ts @@ -1,5 +1,4 @@ import type { CliArgs } from '../../cli/args'; -import type { YoutubeFlowMode } from '../../types'; import type { CliCommandContextFactoryDeps } from './cli-command-context'; type CliCommandContextMainState = { @@ -42,11 +41,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: { generateCharacterDictionary: CliCommandContextFactoryDeps['generateCharacterDictionary']; runStatsCommand: CliCommandContextFactoryDeps['runStatsCommand']; runJellyfinCommand: (args: CliArgs) => Promise; - runYoutubePlaybackFlow: (request: { - url: string; - mode: YoutubeFlowMode; - source: 'initial' | 'second-instance'; - }) => Promise; + runYoutubePlaybackFlow: CliCommandContextFactoryDeps['runYoutubePlaybackFlow']; openYomitanSettings: () => void; cycleSecondarySubMode: () => void; diff --git a/src/main/runtime/cli-command-context.ts b/src/main/runtime/cli-command-context.ts index f9f0359..f159d86 100644 --- a/src/main/runtime/cli-command-context.ts +++ b/src/main/runtime/cli-command-context.ts @@ -1,5 +1,4 @@ import type { CliArgs } from '../../cli/args'; -import type { YoutubeFlowMode } from '../../types'; import type { CliCommandRuntimeServiceContext, CliCommandRuntimeServiceContextHandlers, @@ -42,11 +41,7 @@ export type CliCommandContextFactoryDeps = { generateCharacterDictionary: CliCommandRuntimeServiceContext['generateCharacterDictionary']; runStatsCommand: CliCommandRuntimeServiceContext['runStatsCommand']; runJellyfinCommand: (args: CliArgs) => Promise; - runYoutubePlaybackFlow: (request: { - url: string; - mode: YoutubeFlowMode; - source: 'initial' | 'second-instance'; - }) => Promise; + runYoutubePlaybackFlow: CliCommandRuntimeServiceContext['runYoutubePlaybackFlow']; openYomitanSettings: () => void; cycleSecondarySubMode: () => void; openRuntimeOptionsPalette: () => void; diff --git a/src/main/runtime/youtube-flow.test.ts b/src/main/runtime/youtube-flow.test.ts index fca1953..19c37d1 100644 --- a/src/main/runtime/youtube-flow.test.ts +++ b/src/main/runtime/youtube-flow.test.ts @@ -19,507 +19,13 @@ const secondaryTrack: YoutubeTrackOption = { label: 'English (manual)', }; -test('youtube flow auto-loads default primary+secondary subtitles without opening the picker', async () => { - const commands: Array> = []; - const osdMessages: string[] = []; - const order: string[] = []; - const refreshedSubtitles: string[] = []; - const focusOverlayCalls: string[] = []; - let trackListRequests = 0; - - const runtime = createYoutubeFlowRuntime({ - probeYoutubeTracks: async () => ({ - videoId: 'video123', - title: 'Video 123', - tracks: [primaryTrack, secondaryTrack], - }), - acquireYoutubeSubtitleTracks: async ({ tracks }) => { - assert.deepEqual( - tracks.map((track) => track.id), - [primaryTrack.id, secondaryTrack.id], - ); - return new Map([ - [primaryTrack.id, '/tmp/auto-ja-orig.vtt'], - [secondaryTrack.id, '/tmp/manual-en.vtt'], - ]); - }, - acquireYoutubeSubtitleTrack: async ({ track }) => { - if (track.id === primaryTrack.id) { - return { path: '/tmp/auto-ja-orig.vtt' }; - } - return { path: '/tmp/manual-en.vtt' }; - }, - retimeYoutubePrimaryTrack: async ({ primaryPath, secondaryPath }) => { - assert.equal(primaryPath, '/tmp/auto-ja-orig.vtt'); - assert.equal(secondaryPath, '/tmp/manual-en.vtt'); - return '/tmp/auto-ja-orig_retimed.vtt'; - }, - startTokenizationWarmups: async () => { - order.push('start-tokenization-warmups'); - }, - waitForTokenizationReady: async () => { - order.push('wait-tokenization-ready'); - }, - waitForAnkiReady: async () => { - order.push('wait-anki-ready'); - }, - waitForPlaybackWindowReady: async () => { - throw new Error('startup auto-load should not wait for modal window readiness'); - }, - waitForOverlayGeometryReady: async () => { - throw new Error('startup auto-load should not wait for modal overlay geometry'); - }, - focusOverlayWindow: () => { - focusOverlayCalls.push('focus-overlay'); - }, - openPicker: async () => { - throw new Error('startup auto-load should not open the picker'); - }, - reportSubtitleFailure: () => { - throw new Error('startup auto-load should not report failure on success'); - }, - pauseMpv: () => { - commands.push(['set_property', 'pause', 'yes']); - }, - resumeMpv: () => { - commands.push(['set_property', 'pause', 'no']); - }, - sendMpvCommand: (command) => { - commands.push(command); - }, - requestMpvProperty: async (name: string) => { - if (name === 'sub-text') { - return '字幕です'; - } - assert.equal(name, 'track-list'); - trackListRequests += 1; - if (trackListRequests === 1) { - return [{ type: 'sub', id: 1, lang: 'ja', external: false, title: 'internal' }]; - } - return [ - { - type: 'sub', - id: 5, - lang: 'ja-orig', - title: 'primary', - external: true, - 'external-filename': '/tmp/auto-ja-orig_retimed.vtt', - }, - { - type: 'sub', - id: 6, - lang: 'en', - title: 'secondary', - external: true, - 'external-filename': '/tmp/manual-en.vtt', - }, - ]; - }, - refreshCurrentSubtitle: (text) => { - refreshedSubtitles.push(text); - }, - wait: async () => {}, - showMpvOsd: (text) => { - osdMessages.push(text); - }, - warn: (message: string) => { - throw new Error(message); - }, - log: () => {}, - getYoutubeOutputDir: () => '/tmp', - }); - await runtime.runYoutubePlaybackFlow({ url: 'https://example.com', mode: 'download' }); - - assert.deepEqual(order, [ - 'start-tokenization-warmups', - 'wait-tokenization-ready', - 'wait-anki-ready', - ]); - assert.deepEqual(osdMessages, [ - 'Opening YouTube video', - 'Getting subtitles...', - 'Loading subtitles...', - 'Primary and secondary subtitles loaded.', - ]); - assert.deepEqual(commands, [ - ['set_property', 'pause', 'yes'], - ['set_property', 'sub-auto', 'no'], - ['set_property', 'sid', 'no'], - ['set_property', 'secondary-sid', 'no'], - ['set_property', 'sub-visibility', 'no'], - ['set_property', 'secondary-sub-visibility', 'no'], - ['set_property', 'sub-delay', 0], - ['set_property', 'sid', 'no'], - ['set_property', 'secondary-sid', 'no'], - ['sub-add', '/tmp/auto-ja-orig_retimed.vtt', 'select', 'auto-ja-orig_retimed.vtt', 'ja-orig'], - ['sub-add', '/tmp/manual-en.vtt', 'cached', 'manual-en.vtt', 'en'], - ['set_property', 'sid', 5], - ['set_property', 'secondary-sid', 6], - ['script-message', 'subminer-autoplay-ready'], - ['set_property', 'pause', 'no'], - ]); - assert.deepEqual(focusOverlayCalls, ['focus-overlay']); - assert.deepEqual(refreshedSubtitles, ['字幕です']); -}); - -test('youtube flow refreshes parsed subtitle cues from the resolved primary subtitle path after auto-load', async () => { - const refreshedSidebarSources: string[] = []; - let trackListRequests = 0; - - const runtime = createYoutubeFlowRuntime({ - probeYoutubeTracks: async () => ({ - videoId: 'video123', - title: 'Video 123', - tracks: [primaryTrack], - }), - acquireYoutubeSubtitleTracks: async () => { - throw new Error('single-track auto-load should not batch acquire'); - }, - acquireYoutubeSubtitleTrack: async () => ({ path: '/tmp/auto-ja-orig.vtt' }), - retimeYoutubePrimaryTrack: async () => '/tmp/auto-ja-orig_retimed.vtt', - startTokenizationWarmups: async () => {}, - waitForTokenizationReady: async () => {}, - waitForAnkiReady: async () => {}, - waitForPlaybackWindowReady: async () => {}, - waitForOverlayGeometryReady: async () => {}, - focusOverlayWindow: () => {}, - openPicker: async () => false, - reportSubtitleFailure: () => { - throw new Error('primary subtitle should load successfully'); - }, - pauseMpv: () => {}, - resumeMpv: () => {}, - sendMpvCommand: () => {}, - requestMpvProperty: async (name: string) => { - if (name === 'sub-text') { - return '字幕です'; - } - assert.equal(name, 'track-list'); - trackListRequests += 1; - return [ - { - type: 'sub', - id: 5, - lang: 'ja-orig', - title: 'primary', - external: true, - 'external-filename': '/tmp/auto-ja-orig_retimed.vtt', - }, - ]; - }, - refreshCurrentSubtitle: () => {}, - refreshSubtitleSidebarSource: async (sourcePath: string) => { - refreshedSidebarSources.push(sourcePath); - }, - wait: async () => {}, - showMpvOsd: () => {}, - warn: (message: string) => { - throw new Error(message); - }, - log: () => {}, - getYoutubeOutputDir: () => '/tmp', - } as never); - - await runtime.runYoutubePlaybackFlow({ url: 'https://example.com', mode: 'download' }); - - assert.equal(trackListRequests > 0, true); - assert.deepEqual(refreshedSidebarSources, ['/tmp/auto-ja-orig_retimed.vtt']); -}); - -test('youtube flow retries secondary after partial batch subtitle failure', async () => { - const acquireSingleCalls: string[] = []; - const commands: Array> = []; - const focusOverlayCalls: string[] = []; - const refreshedSubtitles: string[] = []; - const warns: string[] = []; - const waits: number[] = []; - let trackListRequests = 0; - - const runtime = createYoutubeFlowRuntime({ - probeYoutubeTracks: async () => ({ - videoId: 'video123', - title: 'Video 123', - tracks: [primaryTrack, secondaryTrack], - }), - acquireYoutubeSubtitleTracks: async () => { - return new Map([[primaryTrack.id, '/tmp/auto-ja-orig.vtt']]); - }, - acquireYoutubeSubtitleTrack: async ({ track }) => { - acquireSingleCalls.push(track.id); - return { path: `/tmp/${track.id}.vtt` }; - }, - retimeYoutubePrimaryTrack: async ({ primaryPath, secondaryPath }) => { - assert.equal(primaryPath, '/tmp/auto-ja-orig.vtt'); - assert.equal(secondaryPath, '/tmp/manual:en.vtt'); - return primaryPath; - }, - startTokenizationWarmups: async () => {}, - waitForTokenizationReady: async () => {}, - waitForAnkiReady: async () => {}, - waitForPlaybackWindowReady: async () => { - throw new Error('startup auto-load should not wait for modal window readiness'); - }, - waitForOverlayGeometryReady: async () => { - throw new Error('startup auto-load should not wait for modal overlay geometry'); - }, - focusOverlayWindow: () => { - focusOverlayCalls.push('focus-overlay'); - }, - openPicker: async () => { - throw new Error('startup auto-load should not open the picker'); - }, - reportSubtitleFailure: () => { - throw new Error('secondary retry should not report primary failure'); - }, - pauseMpv: () => {}, - resumeMpv: () => {}, - sendMpvCommand: (command) => { - commands.push(command); - }, - requestMpvProperty: async (name) => { - if (name === 'sub-text') { - return '字幕です'; - } - assert.equal(name, 'track-list'); - trackListRequests += 1; - if (trackListRequests === 1) { - return [ - { - type: 'sub', - id: 5, - lang: 'ja-orig', - title: 'primary', - external: true, - 'external-filename': '/tmp/auto-ja-orig.vtt', - }, - ]; - } - return [ - { - type: 'sub', - id: 5, - lang: 'ja-orig', - title: 'primary', - external: true, - 'external-filename': '/tmp/auto-ja-orig.vtt', - }, - { - type: 'sub', - id: 6, - lang: 'en', - title: 'secondary', - external: true, - 'external-filename': '/tmp/manual:en.vtt', - }, - ]; - }, - refreshCurrentSubtitle: (text) => { - refreshedSubtitles.push(text); - }, - wait: async (ms) => { - waits.push(ms); - }, - showMpvOsd: () => {}, - warn: (message) => { - warns.push(message); - }, - log: () => {}, - getYoutubeOutputDir: () => '/tmp', - }); - - await runtime.runYoutubePlaybackFlow({ url: 'https://example.com', mode: 'download' }); - - assert.deepEqual(acquireSingleCalls, [secondaryTrack.id]); - assert.ok(waits.includes(350)); - assert.deepEqual(focusOverlayCalls, ['focus-overlay']); - assert.deepEqual(refreshedSubtitles, ['字幕です']); - assert.ok( - commands.some( - (command) => - command[0] === 'sub-add' && - command[1] === '/tmp/manual:en.vtt' && - command[2] === 'cached', - ), - ); - assert.equal(warns.length, 0); -}); - -test('youtube flow waits for tokenization readiness before releasing playback', async () => { - const commands: Array> = []; - const releaseOrder: string[] = []; - let tokenizationReadyRegistered = false; - let resolveTokenizationReady: () => void = () => { - throw new Error('expected tokenization readiness waiter'); - }; - - const runtime = createYoutubeFlowRuntime({ - probeYoutubeTracks: async () => ({ - videoId: 'video123', - title: 'Video 123', - tracks: [primaryTrack], - }), - acquireYoutubeSubtitleTracks: async () => new Map(), - acquireYoutubeSubtitleTrack: async () => ({ path: '/tmp/auto-ja-orig.vtt' }), - retimeYoutubePrimaryTrack: async ({ primaryPath }) => primaryPath, - startTokenizationWarmups: async () => { - releaseOrder.push('start-warmups'); - }, - waitForTokenizationReady: async () => { - releaseOrder.push('wait-tokenization-ready:start'); - await new Promise((resolve) => { - tokenizationReadyRegistered = true; - resolveTokenizationReady = resolve; - }); - releaseOrder.push('wait-tokenization-ready:end'); - }, - waitForAnkiReady: async () => { - releaseOrder.push('wait-anki-ready'); - }, - waitForPlaybackWindowReady: async () => { - throw new Error('startup auto-load should not wait for modal window readiness'); - }, - waitForOverlayGeometryReady: async () => { - throw new Error('startup auto-load should not wait for modal overlay geometry'); - }, - focusOverlayWindow: () => { - releaseOrder.push('focus-overlay'); - }, - openPicker: async () => { - throw new Error('startup auto-load should not open the picker'); - }, - reportSubtitleFailure: () => { - throw new Error('successful auto-load should not report failure'); - }, - pauseMpv: () => {}, - resumeMpv: () => { - commands.push(['set_property', 'pause', 'no']); - releaseOrder.push('resume'); - }, - sendMpvCommand: (command) => { - commands.push(command); - if (command[0] === 'script-message' && command[1] === 'subminer-autoplay-ready') { - releaseOrder.push('autoplay-ready'); - } - }, - requestMpvProperty: async (name) => { - if (name === 'sub-text') { - return '字幕です'; - } - return [ - { - type: 'sub', - id: 5, - lang: 'ja-orig', - title: 'primary', - external: true, - 'external-filename': '/tmp/auto-ja-orig.vtt', - }, - ]; - }, - refreshCurrentSubtitle: () => {}, - wait: async () => {}, - showMpvOsd: () => {}, - warn: (message) => { - throw new Error(message); - }, - log: () => {}, - getYoutubeOutputDir: () => '/tmp', - }); - - const flowPromise = runtime.runYoutubePlaybackFlow({ url: 'https://example.com', mode: 'download' }); - await new Promise((resolve) => setTimeout(resolve, 0)); - - assert.equal(tokenizationReadyRegistered, true); - assert.deepEqual(releaseOrder, ['start-warmups', 'wait-tokenization-ready:start']); - assert.equal(commands.some((command) => command[1] === 'subminer-autoplay-ready'), false); - - resolveTokenizationReady(); - await flowPromise; - - assert.deepEqual(releaseOrder, [ - 'start-warmups', - 'wait-tokenization-ready:start', - 'wait-tokenization-ready:end', - 'wait-anki-ready', - 'autoplay-ready', - 'resume', - 'focus-overlay', - ]); -}); - -test('youtube flow reports primary auto-load failure through the configured reporter when the primary subtitle never binds', async () => { - const commands: Array> = []; - const warns: string[] = []; - const reportedFailures: 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, - startTokenizationWarmups: async () => {}, - waitForTokenizationReady: async () => { - throw new Error('bind failure should not wait for tokenization readiness'); - }, - waitForAnkiReady: async () => {}, - waitForPlaybackWindowReady: async () => { - throw new Error('startup auto-load should not wait for modal window readiness'); - }, - waitForOverlayGeometryReady: async () => { - throw new Error('startup auto-load should not wait for modal overlay geometry'); - }, - focusOverlayWindow: () => {}, - openPicker: async () => { - throw new Error('startup auto-load should not open the picker'); - }, - reportSubtitleFailure: (message) => { - reportedFailures.push(message); - }, - pauseMpv: () => {}, - resumeMpv: () => {}, - sendMpvCommand: (command) => { - commands.push(command); - }, - requestMpvProperty: async (name) => { - if (name === 'track-list') { - return []; - } - throw new Error(`unexpected property request: ${name}`); - }, - refreshCurrentSubtitle: () => { - throw new Error('should not refresh subtitle text on bind failure'); - }, - wait: async () => {}, - showMpvOsd: () => {}, - warn: (message) => { - warns.push(message); - }, - log: () => {}, - getYoutubeOutputDir: () => '/tmp', - }); - - await runtime.runYoutubePlaybackFlow({ url: 'https://example.com', mode: 'download' }); - - assert.equal( - commands.some((command) => command[0] === 'set_property' && command[1] === 'sid' && command[2] !== 'no'), - false, - ); - assert.deepEqual(reportedFailures, [ - 'Primary subtitles failed to load. Use the YouTube subtitle picker to try manually.', - ]); - assert.equal(warns.some((message) => message.includes('Unable to bind downloaded primary subtitle track')), true); -}); - test('youtube flow can open a manual picker session and load the selected subtitles', async () => { const commands: Array> = []; const focusOverlayCalls: string[] = []; const osdMessages: string[] = []; const openedPayloads: YoutubePickerOpenPayload[] = []; const waits: number[] = []; + const refreshedSidebarSources: string[] = []; const runtime = createYoutubeFlowRuntime({ probeYoutubeTracks: async () => ({ @@ -539,18 +45,6 @@ test('youtube flow can open a manual picker session and load the selected subtit }, acquireYoutubeSubtitleTrack: async ({ track }) => ({ path: `/tmp/${track.id}.vtt` }), retimeYoutubePrimaryTrack: async ({ primaryPath }) => `${primaryPath}.retimed`, - startTokenizationWarmups: async () => {}, - waitForTokenizationReady: async () => {}, - waitForAnkiReady: async () => {}, - waitForPlaybackWindowReady: async () => { - waits.push(1); - }, - waitForOverlayGeometryReady: async () => { - waits.push(2); - }, - focusOverlayWindow: () => { - focusOverlayCalls.push('focus-overlay'); - }, openPicker: async (payload) => { openedPayloads.push(payload); queueMicrotask(() => { @@ -563,15 +57,8 @@ test('youtube flow can open a manual picker session and load the selected subtit }); return true; }, - reportSubtitleFailure: () => { - throw new Error('manual picker success should not report failure'); - }, - pauseMpv: () => { - throw new Error('manual picker should not pause playback'); - }, - resumeMpv: () => { - throw new Error('manual picker should not resume playback'); - }, + pauseMpv: () => {}, + resumeMpv: () => {}, sendMpvCommand: (command) => { commands.push(command); }, @@ -599,12 +86,30 @@ test('youtube flow can open a manual picker session and load the selected subtit ]; }, refreshCurrentSubtitle: () => {}, + refreshSubtitleSidebarSource: async (sourcePath: string) => { + refreshedSidebarSources.push(sourcePath); + }, + startTokenizationWarmups: async () => {}, + waitForTokenizationReady: async () => {}, + waitForAnkiReady: async () => {}, wait: async (ms) => { waits.push(ms); }, + waitForPlaybackWindowReady: async () => { + waits.push(1); + }, + waitForOverlayGeometryReady: async () => { + waits.push(2); + }, + focusOverlayWindow: () => { + focusOverlayCalls.push('focus-overlay'); + }, showMpvOsd: (text) => { osdMessages.push(text); }, + reportSubtitleFailure: () => { + throw new Error('manual picker success should not report failure'); + }, warn: (message) => { throw new Error(message); }, @@ -612,7 +117,7 @@ test('youtube flow can open a manual picker session and load the selected subtit getYoutubeOutputDir: () => '/tmp', }); - await runtime.openManualPicker({ url: 'https://example.com', mode: 'download' }); + await runtime.openManualPicker({ url: 'https://example.com' }); assert.equal(openedPayloads.length, 1); assert.equal(openedPayloads[0]?.defaultPrimaryTrackId, primaryTrack.id); @@ -632,5 +137,314 @@ test('youtube flow can open a manual picker session and load the selected subtit command[2] === 'select', ), ); + assert.ok( + commands.some( + (command) => + command[0] === 'set_property' && + command[1] === 'sub-visibility' && + command[2] === 'yes', + ), + ); + assert.ok( + commands.some( + (command) => + command[0] === 'set_property' && + command[1] === 'secondary-sub-visibility' && + command[2] === 'yes', + ), + ); + assert.deepEqual(refreshedSidebarSources, ['/tmp/auto-ja-orig.vtt.retimed']); assert.deepEqual(focusOverlayCalls, ['focus-overlay']); }); + +test('youtube flow retries secondary after partial batch subtitle failure', async () => { + const acquireSingleCalls: string[] = []; + const commands: Array> = []; + const waits: number[] = []; + + 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); + }, + requestMpvProperty: async (name) => { + if (name === 'sub-text') { + return '字幕です'; + } + return [ + { + type: 'sub', + id: 5, + lang: 'ja-orig', + title: 'primary', + external: true, + 'external-filename': '/tmp/auto-ja-orig.vtt', + }, + { + type: 'sub', + id: 6, + lang: 'en', + title: 'secondary', + external: true, + 'external-filename': '/tmp/manual:en.vtt', + }, + ]; + }, + refreshCurrentSubtitle: () => {}, + startTokenizationWarmups: async () => {}, + waitForTokenizationReady: async () => {}, + waitForAnkiReady: async () => {}, + wait: async (ms) => { + waits.push(ms); + }, + waitForPlaybackWindowReady: async () => {}, + waitForOverlayGeometryReady: async () => {}, + focusOverlayWindow: () => {}, + showMpvOsd: () => {}, + reportSubtitleFailure: () => { + throw new Error('secondary retry should not report primary failure'); + }, + warn: (message) => { + throw new Error(message); + }, + log: () => {}, + getYoutubeOutputDir: () => '/tmp', + }); + + await runtime.openManualPicker({ url: 'https://example.com' }); + + assert.deepEqual(acquireSingleCalls, [secondaryTrack.id]); + assert.ok(waits.includes(350)); + assert.ok( + commands.some( + (command) => + command[0] === 'sub-add' && + command[1] === '/tmp/manual:en.vtt' && + command[2] === 'cached', + ), + ); +}); + +test('youtube flow reports probe failure through the configured reporter in manual mode', async () => { + const failures: string[] = []; + + const runtime = createYoutubeFlowRuntime({ + probeYoutubeTracks: async () => { + throw new Error('probe failed'); + }, + acquireYoutubeSubtitleTracks: async () => new Map(), + acquireYoutubeSubtitleTrack: async () => ({ path: '/tmp/unused.vtt' }), + retimeYoutubePrimaryTrack: async ({ primaryPath }) => primaryPath, + openPicker: async () => true, + pauseMpv: () => {}, + resumeMpv: () => {}, + sendMpvCommand: () => {}, + requestMpvProperty: async () => null, + refreshCurrentSubtitle: () => {}, + startTokenizationWarmups: async () => {}, + waitForTokenizationReady: async () => {}, + waitForAnkiReady: async () => {}, + wait: async () => {}, + waitForPlaybackWindowReady: async () => {}, + waitForOverlayGeometryReady: async () => {}, + focusOverlayWindow: () => {}, + showMpvOsd: () => {}, + reportSubtitleFailure: (message) => { + failures.push(message); + }, + warn: () => {}, + log: () => {}, + getYoutubeOutputDir: () => '/tmp', + }); + + await runtime.openManualPicker({ url: 'https://example.com' }); + + assert.deepEqual(failures, [ + 'Primary subtitles failed to load. Use the YouTube subtitle picker to try manually.', + ]); +}); + +test('youtube flow does not report failure when subtitle track binds before cue text appears', async () => { + const failures: string[] = []; + + const runtime = createYoutubeFlowRuntime({ + probeYoutubeTracks: async () => ({ + videoId: 'video123', + title: 'Video 123', + tracks: [primaryTrack], + }), + acquireYoutubeSubtitleTracks: async () => new Map(), + acquireYoutubeSubtitleTrack: async () => ({ path: '/tmp/auto-ja-orig.vtt' }), + retimeYoutubePrimaryTrack: async ({ primaryPath }) => primaryPath, + openPicker: async (payload) => { + queueMicrotask(() => { + void runtime.resolveActivePicker({ + sessionId: payload.sessionId, + action: 'use-selected', + primaryTrackId: primaryTrack.id, + secondaryTrackId: null, + }); + }); + return true; + }, + pauseMpv: () => {}, + resumeMpv: () => {}, + sendMpvCommand: () => {}, + requestMpvProperty: async (name) => { + if (name === 'sub-text') { + return ''; + } + return [ + { + type: 'sub', + id: 5, + lang: 'ja-orig', + title: 'primary', + external: true, + 'external-filename': '/tmp/auto-ja-orig.vtt', + }, + ]; + }, + refreshCurrentSubtitle: () => { + throw new Error('should not refresh empty subtitle text'); + }, + startTokenizationWarmups: async () => {}, + waitForTokenizationReady: async () => {}, + waitForAnkiReady: async () => {}, + wait: async () => {}, + waitForPlaybackWindowReady: async () => {}, + waitForOverlayGeometryReady: async () => {}, + focusOverlayWindow: () => {}, + showMpvOsd: () => {}, + reportSubtitleFailure: (message) => { + failures.push(message); + }, + warn: (message) => { + throw new Error(message); + }, + log: () => {}, + getYoutubeOutputDir: () => '/tmp', + }); + + await runtime.openManualPicker({ url: 'https://example.com' }); + + assert.deepEqual(failures, []); +}); + +test('youtube flow retries secondary subtitle selection until mpv reports the expected secondary sid', async () => { + const commands: Array> = []; + 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}.vtt` }), + retimeYoutubePrimaryTrack: async ({ primaryPath }) => primaryPath, + openPicker: async (payload) => { + queueMicrotask(() => { + void runtime.resolveActivePicker({ + sessionId: payload.sessionId, + action: 'use-selected', + primaryTrackId: primaryTrack.id, + secondaryTrackId: secondaryTrack.id, + }); + }); + return true; + }, + pauseMpv: () => {}, + resumeMpv: () => {}, + sendMpvCommand: (command) => { + commands.push(command); + }, + requestMpvProperty: async (name) => { + if (name === 'sub-text') { + return '字幕です'; + } + if (name === 'secondary-sid') { + secondarySidReads += 1; + return secondarySidReads >= 2 ? 6 : null; + } + return [ + { + type: 'sub', + id: 5, + lang: 'ja-orig', + title: 'primary', + external: true, + 'external-filename': '/tmp/auto-ja-orig.vtt', + }, + { + type: 'sub', + id: 6, + lang: 'en', + title: 'English', + external: true, + 'external-filename': null, + }, + ]; + }, + refreshCurrentSubtitle: () => {}, + startTokenizationWarmups: async () => {}, + waitForTokenizationReady: async () => {}, + waitForAnkiReady: async () => {}, + wait: async (ms) => { + waits.push(ms); + }, + waitForPlaybackWindowReady: async () => {}, + waitForOverlayGeometryReady: async () => {}, + focusOverlayWindow: () => {}, + showMpvOsd: () => {}, + reportSubtitleFailure: () => { + throw new Error('secondary selection retry should not report failure'); + }, + warn: (message) => { + throw new Error(message); + }, + log: () => {}, + getYoutubeOutputDir: () => '/tmp', + }); + + await runtime.openManualPicker({ url: 'https://example.com' }); + + assert.equal( + commands.filter( + (command) => + command[0] === 'set_property' && command[1] === 'secondary-sid' && command[2] === 6, + ).length, + 2, + ); + assert.ok(waits.includes(100)); +}); diff --git a/src/main/runtime/youtube-flow.ts b/src/main/runtime/youtube-flow.ts index 603efd3..cbb3f28 100644 --- a/src/main/runtime/youtube-flow.ts +++ b/src/main/runtime/youtube-flow.ts @@ -1,7 +1,6 @@ import os from 'node:os'; import path from 'node:path'; import type { - YoutubeFlowMode, YoutubePickerOpenPayload, YoutubePickerResolveRequest, YoutubePickerResolveResult, @@ -21,6 +20,7 @@ import { import { resolveSubtitleSourcePath } from './subtitle-prefetch-source'; type YoutubeFlowOpenPicker = (payload: YoutubePickerOpenPayload) => Promise; +type YoutubeFlowMode = 'download' | 'generate'; type YoutubeFlowDeps = { probeYoutubeTracks: (url: string) => Promise; @@ -134,6 +134,22 @@ function parseTrackId(value: unknown): number | null { return null; } +async function ensureSubtitleTrackSelection(input: { + deps: YoutubeFlowDeps; + property: 'sid' | 'secondary-sid'; + targetId: number; +}): Promise { + input.deps.sendMpvCommand(['set_property', input.property, input.targetId]); + for (let attempt = 0; attempt < 4; attempt += 1) { + const currentId = parseTrackId(await input.deps.requestMpvProperty(input.property)); + if (currentId === input.targetId) { + return; + } + await input.deps.wait(100); + input.deps.sendMpvCommand(['set_property', input.property, input.targetId]); + } +} + function normalizeTrackListEntry(track: Record): { id: number | null; lang: string; @@ -252,7 +268,12 @@ async function injectDownloadedSubtitles( } if (primaryTrackId !== null) { - deps.sendMpvCommand(['set_property', 'sid', primaryTrackId]); + await ensureSubtitleTrackSelection({ + deps, + property: 'sid', + targetId: primaryTrackId, + }); + deps.sendMpvCommand(['set_property', 'sub-visibility', 'yes']); } else { deps.warn( `Unable to bind downloaded primary subtitle track in mpv: ${path.basename(primaryPath)}`, @@ -260,7 +281,12 @@ async function injectDownloadedSubtitles( } if (secondaryPath && secondaryTrack) { if (secondaryTrackId !== null) { - deps.sendMpvCommand(['set_property', 'secondary-sid', secondaryTrackId]); + await ensureSubtitleTrackSelection({ + deps, + property: 'secondary-sid', + targetId: secondaryTrackId, + }); + deps.sendMpvCommand(['set_property', 'secondary-sub-visibility', 'yes']); } else { deps.warn( `Unable to bind downloaded secondary subtitle track in mpv: ${path.basename(secondaryPath)}`, @@ -280,7 +306,7 @@ async function injectDownloadedSubtitles( deps.showMpvOsd( secondaryPath && secondaryTrack ? 'Primary and secondary subtitles loaded.' : 'Subtitles loaded.', ); - return typeof currentSubText === 'string' && currentSubText.trim().length > 0; + return true; } export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) { @@ -291,7 +317,6 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) { outputDir: string; primaryTrack: YoutubeTrackOption; secondaryTrack: YoutubeTrackOption | null; - mode: YoutubeFlowMode; secondaryFailureLabel: string; }): Promise<{ primaryPath: string; secondaryPath: string | null }> => { if (!input.secondaryTrack) { @@ -300,7 +325,6 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) { targetUrl: input.targetUrl, outputDir: input.outputDir, track: input.primaryTrack, - mode: input.mode, }) ).path; return { primaryPath, secondaryPath: null }; @@ -311,7 +335,6 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) { targetUrl: input.targetUrl, outputDir: input.outputDir, tracks: [input.primaryTrack, input.secondaryTrack], - mode: input.mode, }); const primaryPath = batchResult.get(input.primaryTrack.id) ?? null; const secondaryPath = batchResult.get(input.secondaryTrack.id) ?? null; @@ -332,7 +355,6 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) { targetUrl: input.targetUrl, outputDir: input.outputDir, track: input.secondaryTrack, - mode: input.mode, }) ).path; return { primaryPath, secondaryPath: retriedSecondaryPath }; @@ -355,7 +377,6 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) { targetUrl: input.targetUrl, outputDir: input.outputDir, track: input.primaryTrack, - mode: input.mode, }) ).path; return { primaryPath, secondaryPath: null }; @@ -403,7 +424,6 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) { const buildOpenPayload = ( input: { url: string; - mode: YoutubeFlowMode; }, probe: YoutubeTrackProbeResult, ): YoutubePickerOpenPayload => { @@ -411,7 +431,6 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) { return { sessionId: createSessionId(), url: input.url, - mode: input.mode, tracks: probe.tracks, defaultPrimaryTrackId: defaults.primaryTrackId, defaultSecondaryTrackId: defaults.secondaryTrackId, @@ -441,7 +460,6 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) { outputDir: input.outputDir, primaryTrack: input.primaryTrack, secondaryTrack: input.secondaryTrack, - mode: input.mode, secondaryFailureLabel: input.secondaryFailureLabel, }); const resolvedPrimaryPath = await deps.retimeYoutubePrimaryTrack({ @@ -484,7 +502,7 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) { const openManualPicker = async (input: { url: string; - mode: YoutubeFlowMode; + mode?: YoutubeFlowMode; }): Promise => { let probe: YoutubeTrackProbeResult; try { @@ -549,15 +567,18 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) { try { deps.showMpvOsd('Getting subtitles...'); - await loadTracksIntoMpv({ + const loaded = await loadTracksIntoMpv({ url: input.url, - mode: input.mode, + mode: input.mode ?? 'download', outputDir: normalizeOutputPath(deps.getYoutubeOutputDir()), primaryTrack, secondaryTrack, secondaryFailureLabel: 'Failed to download secondary YouTube subtitle track', showDownloadProgress: true, }); + if (!loaded) { + reportPrimarySubtitleFailure(); + } } catch (error) { deps.warn( `Failed to download primary YouTube subtitle track: ${ diff --git a/src/main/runtime/youtube-primary-subtitle-notification.test.ts b/src/main/runtime/youtube-primary-subtitle-notification.test.ts new file mode 100644 index 0000000..22e5634 --- /dev/null +++ b/src/main/runtime/youtube-primary-subtitle-notification.test.ts @@ -0,0 +1,149 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createYoutubePrimarySubtitleNotificationRuntime, + type YoutubePrimarySubtitleNotificationTimer, +} from './youtube-primary-subtitle-notification'; + +function createTimerHarness() { + let nextId = 1; + const timers = new Map void>(); + return { + schedule: (fn: () => void): YoutubePrimarySubtitleNotificationTimer => { + const id = nextId++; + timers.set(id, fn); + return { id }; + }, + clear: (timer: YoutubePrimarySubtitleNotificationTimer | null) => { + if (!timer) { + return; + } + if (typeof timer === 'object' && 'id' in timer) { + timers.delete(timer.id); + } + }, + runAll: () => { + const pending = [...timers.values()]; + timers.clear(); + for (const fn of pending) { + fn(); + } + }, + size: () => timers.size, + }; +} + +test('notifier reports missing preferred primary subtitle once for youtube media', () => { + const notifications: string[] = []; + const timers = createTimerHarness(); + const runtime = createYoutubePrimarySubtitleNotificationRuntime({ + getPrimarySubtitleLanguages: () => ['ja', 'jpn'], + notifyFailure: (message) => { + notifications.push(message); + }, + schedule: (fn) => timers.schedule(fn), + clearSchedule: (timer) => timers.clear(timer), + }); + + runtime.handleMediaPathChange('https://www.youtube.com/watch?v=abc'); + runtime.handleSubtitleTrackChange(null); + runtime.handleSubtitleTrackListChange([ + { type: 'sub', id: 2, lang: 'en', title: 'English', external: true }, + ]); + + assert.equal(timers.size(), 1); + timers.runAll(); + timers.runAll(); + + assert.deepEqual(notifications, [ + 'Primary subtitle failed to download or load. Try again from the subtitle modal.', + ]); +}); + +test('notifier suppresses failure when preferred primary subtitle is selected', () => { + const notifications: string[] = []; + const timers = createTimerHarness(); + const runtime = createYoutubePrimarySubtitleNotificationRuntime({ + getPrimarySubtitleLanguages: () => ['ja', 'jpn'], + notifyFailure: (message) => { + notifications.push(message); + }, + schedule: (fn) => timers.schedule(fn), + clearSchedule: (timer) => timers.clear(timer), + }); + + runtime.handleMediaPathChange('https://www.youtube.com/watch?v=abc'); + runtime.handleSubtitleTrackListChange([ + { type: 'sub', id: 5, lang: 'ja', title: 'Japanese', external: true }, + ]); + runtime.handleSubtitleTrackChange(5); + timers.runAll(); + + assert.deepEqual(notifications, []); +}); + +test('notifier suppresses failure when any external subtitle track is selected', () => { + const notifications: string[] = []; + const timers = createTimerHarness(); + const runtime = createYoutubePrimarySubtitleNotificationRuntime({ + getPrimarySubtitleLanguages: () => ['ja', 'jpn'], + notifyFailure: (message) => { + notifications.push(message); + }, + schedule: (fn) => timers.schedule(fn), + clearSchedule: (timer) => timers.clear(timer), + }); + + runtime.handleMediaPathChange('https://www.youtube.com/watch?v=abc'); + runtime.handleSubtitleTrackListChange([ + { type: 'sub', id: 5, lang: '', title: 'auto-ja-orig.ja-orig.vtt', external: true }, + ]); + runtime.handleSubtitleTrackChange(5); + timers.runAll(); + + assert.deepEqual(notifications, []); +}); + +test('notifier resets when media changes away from youtube', () => { + const notifications: string[] = []; + const timers = createTimerHarness(); + const runtime = createYoutubePrimarySubtitleNotificationRuntime({ + getPrimarySubtitleLanguages: () => ['ja'], + notifyFailure: (message) => { + notifications.push(message); + }, + schedule: (fn) => timers.schedule(fn), + clearSchedule: (timer) => timers.clear(timer), + }); + + runtime.handleMediaPathChange('https://www.youtube.com/watch?v=abc'); + runtime.handleMediaPathChange('/tmp/video.mkv'); + timers.runAll(); + + assert.deepEqual(notifications, []); +}); + +test('notifier ignores empty and null media paths and waits for track list before reporting', () => { + const notifications: string[] = []; + const timers = createTimerHarness(); + const runtime = createYoutubePrimarySubtitleNotificationRuntime({ + getPrimarySubtitleLanguages: () => ['ja'], + notifyFailure: (message) => { + notifications.push(message); + }, + schedule: (fn) => timers.schedule(fn), + clearSchedule: (timer) => timers.clear(timer), + }); + + runtime.handleMediaPathChange(null); + runtime.handleMediaPathChange(''); + assert.equal(timers.size(), 0); + + runtime.handleMediaPathChange('https://www.youtube.com/watch?v=abc'); + runtime.handleSubtitleTrackChange(7); + runtime.handleSubtitleTrackListChange([ + { type: 'sub', id: 7, lang: 'ja', title: 'Japanese', external: true }, + ]); + timers.runAll(); + assert.deepEqual(notifications, []); +}); diff --git a/src/main/runtime/youtube-primary-subtitle-notification.ts b/src/main/runtime/youtube-primary-subtitle-notification.ts new file mode 100644 index 0000000..17fc6e0 --- /dev/null +++ b/src/main/runtime/youtube-primary-subtitle-notification.ts @@ -0,0 +1,168 @@ +import { isYoutubeMediaPath } from './youtube-playback'; +import { normalizeYoutubeLangCode } from '../../core/services/youtube/labels'; + +export type YoutubePrimarySubtitleNotificationTimer = ReturnType | { id: number }; + +type SubtitleTrackEntry = { + id: number | null; + type: string; + lang: string; + external: boolean; +}; + +function parseTrackId(value: unknown): number | null { + if (typeof value === 'number' && Number.isInteger(value)) { + return value; + } + if (typeof value === 'string') { + const parsed = Number(value.trim()); + return Number.isInteger(parsed) ? parsed : null; + } + return null; +} + +function normalizeTrack(entry: unknown): SubtitleTrackEntry | null { + if (!entry || typeof entry !== 'object') { + return null; + } + const track = entry as Record; + return { + id: parseTrackId(track.id), + type: String(track.type || '').trim(), + lang: String(track.lang || '').trim(), + external: track.external === true, + }; +} + +export function clearYoutubePrimarySubtitleNotificationTimer( + timer: YoutubePrimarySubtitleNotificationTimer | null, +): void { + if (!timer) { + return; + } + if (typeof timer === 'object' && timer !== null && 'id' in timer) { + clearTimeout((timer as { id: number }).id); + return; + } + clearTimeout(timer); +} + +function buildPreferredLanguageSet(values: string[]): Set { + const normalized = values + .map((value) => normalizeYoutubeLangCode(value)) + .filter((value) => value.length > 0); + return new Set(normalized); +} + +function matchesPreferredLanguage(language: string, preferred: Set): boolean { + if (preferred.size === 0) { + return false; + } + const normalized = normalizeYoutubeLangCode(language); + if (!normalized) { + return false; + } + if (preferred.has(normalized)) { + return true; + } + const base = normalized.split('-')[0] || normalized; + return preferred.has(base); +} + +function hasSelectedPrimarySubtitle( + sid: number | null, + trackList: unknown[] | null, + preferredLanguages: Set, +): boolean { + if (sid === null || !Array.isArray(trackList)) { + return false; + } + + const activeTrack = + trackList.map(normalizeTrack).find((track) => track?.type === 'sub' && track.id === sid) ?? null; + if (!activeTrack) { + return false; + } + if (activeTrack.external) { + return true; + } + return matchesPreferredLanguage(activeTrack.lang, preferredLanguages); +} + +export function createYoutubePrimarySubtitleNotificationRuntime(deps: { + getPrimarySubtitleLanguages: () => string[]; + notifyFailure: (message: string) => void; + schedule: (fn: () => void, delayMs: number) => YoutubePrimarySubtitleNotificationTimer; + clearSchedule: (timer: YoutubePrimarySubtitleNotificationTimer | null) => void; + delayMs?: number; +}) { + const delayMs = deps.delayMs ?? 5000; + let currentMediaPath: string | null = null; + let currentSid: number | null = null; + let currentTrackList: unknown[] | null = null; + let pendingTimer: YoutubePrimarySubtitleNotificationTimer | null = null; + let lastReportedMediaPath: string | null = null; + + const clearPendingTimer = (): void => { + deps.clearSchedule(pendingTimer); + pendingTimer = null; + }; + + const maybeReportFailure = (): void => { + const mediaPath = currentMediaPath?.trim() || ''; + if (!mediaPath || !isYoutubeMediaPath(mediaPath)) { + return; + } + if (lastReportedMediaPath === mediaPath) { + return; + } + const preferredLanguages = buildPreferredLanguageSet(deps.getPrimarySubtitleLanguages()); + if (preferredLanguages.size === 0) { + return; + } + if (hasSelectedPrimarySubtitle(currentSid, currentTrackList, preferredLanguages)) { + return; + } + lastReportedMediaPath = mediaPath; + deps.notifyFailure('Primary subtitle failed to download or load. Try again from the subtitle modal.'); + }; + + const schedulePendingCheck = (): void => { + clearPendingTimer(); + const mediaPath = currentMediaPath?.trim() || ''; + if (!mediaPath || !isYoutubeMediaPath(mediaPath)) { + return; + } + pendingTimer = deps.schedule(() => { + pendingTimer = null; + maybeReportFailure(); + }, delayMs); + }; + + return { + handleMediaPathChange: (path: string | null): void => { + const normalizedPath = typeof path === 'string' && path.trim().length > 0 ? path.trim() : null; + if (currentMediaPath !== normalizedPath) { + lastReportedMediaPath = null; + } + currentMediaPath = normalizedPath; + currentSid = null; + currentTrackList = null; + schedulePendingCheck(); + }, + handleSubtitleTrackChange: (sid: number | null): void => { + currentSid = sid; + const preferredLanguages = buildPreferredLanguageSet(deps.getPrimarySubtitleLanguages()); + if (hasSelectedPrimarySubtitle(currentSid, currentTrackList, preferredLanguages)) { + clearPendingTimer(); + } + }, + handleSubtitleTrackListChange: (trackList: unknown[] | null): void => { + currentTrackList = trackList; + const preferredLanguages = buildPreferredLanguageSet(deps.getPrimarySubtitleLanguages()); + if (hasSelectedPrimarySubtitle(currentSid, currentTrackList, preferredLanguages)) { + clearPendingTimer(); + } + }, + }; +}