diff --git a/src/anki-integration/anki-connect-proxy.ts b/src/anki-integration/anki-connect-proxy.ts index 4ba236c..0d98a55 100644 --- a/src/anki-integration/anki-connect-proxy.ts +++ b/src/anki-integration/anki-connect-proxy.ts @@ -35,6 +35,9 @@ export class AnkiConnectProxyServer { private pendingNoteIdSet = new Set(); private inFlightNoteIds = new Set(); private processingQueue = false; + private readyPromise: Promise | null = null; + private resolveReady: (() => void) | null = null; + private rejectReady: ((error: Error) => void) | null = null; constructor(private readonly deps: AnkiConnectProxyServerDeps) { this.client = axios.create({ @@ -48,6 +51,13 @@ export class AnkiConnectProxyServer { return this.server !== null; } + waitUntilReady(): Promise { + if (!this.server || this.server.listening) { + return Promise.resolve(); + } + return this.readyPromise ?? Promise.resolve(); + } + start(options: StartProxyOptions): void { this.stop(); @@ -58,15 +68,26 @@ export class AnkiConnectProxyServer { return; } + this.readyPromise = new Promise((resolve, reject) => { + this.resolveReady = resolve; + this.rejectReady = reject; + }); + this.server = http.createServer((req, res) => { void this.handleRequest(req, res, options.upstreamUrl); }); this.server.on('error', (error) => { + this.rejectReady?.(error as Error); + this.resolveReady = null; + this.rejectReady = null; this.deps.logError('[anki-proxy] Server error:', (error as Error).message); }); this.server.listen(options.port, options.host, () => { + this.resolveReady?.(); + this.resolveReady = null; + this.rejectReady = null; this.deps.logInfo( `[anki-proxy] Listening on http://${options.host}:${options.port} -> ${options.upstreamUrl}`, ); @@ -79,6 +100,10 @@ export class AnkiConnectProxyServer { this.server = null; this.deps.logInfo('[anki-proxy] Stopped'); } + this.rejectReady?.(new Error('AnkiConnect proxy stopped before becoming ready')); + this.readyPromise = null; + this.resolveReady = null; + this.rejectReady = null; this.pendingNoteIds = []; this.pendingNoteIdSet.clear(); this.inFlightNoteIds.clear(); diff --git a/src/anki-integration/runtime.test.ts b/src/anki-integration/runtime.test.ts index 017686f..e234f15 100644 --- a/src/anki-integration/runtime.test.ts +++ b/src/anki-integration/runtime.test.ts @@ -26,6 +26,7 @@ function createRuntime( start: ({ host, port, upstreamUrl }) => calls.push(`proxy:start:${host}:${port}:${upstreamUrl}`), stop: () => calls.push('proxy:stop'), + waitUntilReady: async () => undefined, }), logInfo: () => undefined, logWarn: () => undefined, @@ -80,6 +81,44 @@ test('AnkiIntegrationRuntime starts proxy transport when proxy mode is enabled', assert.deepEqual(calls, ['known:start', 'proxy:start:127.0.0.1:9999:http://upstream:8765']); }); +test('AnkiIntegrationRuntime waits for proxy readiness when proxy mode is enabled', async () => { + let releaseReady!: () => void; + const waitUntilReadyCalls: string[] = []; + const readyPromise = new Promise((resolve) => { + releaseReady = resolve; + }); + const { runtime } = createRuntime( + { + proxy: { + enabled: true, + host: '127.0.0.1', + port: 9999, + upstreamUrl: 'http://upstream:8765', + }, + }, + { + proxyServerFactory: () => ({ + start: () => undefined, + stop: () => undefined, + waitUntilReady: async () => { + waitUntilReadyCalls.push('proxy:wait-until-ready'); + await readyPromise; + }, + }), + }, + ); + + runtime.start(); + const waitPromise = runtime.waitUntilReady().then(() => { + waitUntilReadyCalls.push('proxy:ready'); + }); + + assert.deepEqual(waitUntilReadyCalls, ['proxy:wait-until-ready']); + releaseReady(); + await waitPromise; + assert.deepEqual(waitUntilReadyCalls, ['proxy:wait-until-ready', 'proxy:ready']); +}); + test('AnkiIntegrationRuntime switches transports and clears known words when runtime patch disables highlighting', () => { const { runtime, calls } = createRuntime({ knownWords: { diff --git a/src/anki-integration/runtime.ts b/src/anki-integration/runtime.ts index 2661d02..df1ef9f 100644 --- a/src/anki-integration/runtime.ts +++ b/src/anki-integration/runtime.ts @@ -9,6 +9,7 @@ import { export interface AnkiIntegrationRuntimeProxyServer { start(options: { host: string; port: number; upstreamUrl: string }): void; stop(): void; + waitUntilReady(): Promise; } interface AnkiIntegrationRuntimeDeps { @@ -131,6 +132,13 @@ export class AnkiIntegrationRuntime { return this.config; } + waitUntilReady(): Promise { + if (!this.started || !this.isProxyTransportEnabled()) { + return Promise.resolve(); + } + return this.getOrCreateProxyServer().waitUntilReady(); + } + start(): void { if (this.started) { this.stop(); diff --git a/src/core/services/index.ts b/src/core/services/index.ts index c46adb1..b63e7a7 100644 --- a/src/core/services/index.ts +++ b/src/core/services/index.ts @@ -79,7 +79,10 @@ export { handleOverlayWindowBeforeInputEvent, isTabInputForMpvForwarding, } from './overlay-window-input'; -export { initializeOverlayRuntime } from './overlay-runtime-init'; +export { + initializeOverlayAnkiIntegration, + initializeOverlayRuntime, +} from './overlay-runtime-init'; export { setVisibleOverlayVisible, updateVisibleOverlayVisibility } from './overlay-visibility'; export { MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY, diff --git a/src/core/services/overlay-runtime-init.test.ts b/src/core/services/overlay-runtime-init.test.ts index b9f8354..63240a6 100644 --- a/src/core/services/overlay-runtime-init.test.ts +++ b/src/core/services/overlay-runtime-init.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { initializeOverlayRuntime } from './overlay-runtime-init'; +import { initializeOverlayAnkiIntegration, initializeOverlayRuntime } from './overlay-runtime-init'; test('initializeOverlayRuntime skips Anki integration when ankiConnect.enabled is false', () => { let createdIntegrations = 0; @@ -109,6 +109,49 @@ test('initializeOverlayRuntime starts Anki integration when ankiConnect.enabled assert.equal(setIntegrationCalls, 1); }); +test('initializeOverlayAnkiIntegration can initialize Anki transport after overlay runtime already exists', () => { + let createdIntegrations = 0; + let startedIntegrations = 0; + let setIntegrationCalls = 0; + + initializeOverlayAnkiIntegration({ + getResolvedConfig: () => ({ + ankiConnect: { enabled: true } as never, + }), + getSubtitleTimingTracker: () => ({}), + getMpvClient: () => ({ + send: () => {}, + }), + getRuntimeOptionsManager: () => ({ + getEffectiveAnkiConnectConfig: (config) => config as never, + }), + createAnkiIntegration: (args) => { + createdIntegrations += 1; + assert.equal(args.config.enabled, true); + return { + start: () => { + startedIntegrations += 1; + }, + }; + }, + setAnkiIntegration: () => { + setIntegrationCalls += 1; + }, + showDesktopNotification: () => {}, + createFieldGroupingCallback: () => async () => ({ + keepNoteId: 11, + deleteNoteId: 12, + deleteDuplicate: false, + cancelled: false, + }), + getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json', + }); + + assert.equal(createdIntegrations, 1); + assert.equal(startedIntegrations, 1); + assert.equal(setIntegrationCalls, 1); +}); + test('initializeOverlayRuntime can skip starting Anki integration transport', () => { let createdIntegrations = 0; let startedIntegrations = 0; diff --git a/src/core/services/overlay-runtime-init.ts b/src/core/services/overlay-runtime-init.ts index bbe8405..85513fe 100644 --- a/src/core/services/overlay-runtime-init.ts +++ b/src/core/services/overlay-runtime-init.ts @@ -47,6 +47,24 @@ function createDefaultAnkiIntegration(args: CreateAnkiIntegrationArgs): AnkiInte } export function initializeOverlayRuntime(options: { + getMpvSocketPath: () => string; + getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig; ai?: AiConfig }; + getSubtitleTimingTracker: () => unknown | null; + getMpvClient: () => { + send?: (payload: { command: string[] }) => void; + } | null; + getRuntimeOptionsManager: () => { + getEffectiveAnkiConnectConfig: (config?: AnkiConnectConfig) => AnkiConnectConfig; + } | null; + getAnkiIntegration?: () => unknown | null; + setAnkiIntegration: (integration: unknown | null) => void; + showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void; + createFieldGroupingCallback: () => ( + data: KikuFieldGroupingRequestData, + ) => Promise; + getKnownWordCacheStatePath: () => string; + shouldStartAnkiIntegration?: () => boolean; + createAnkiIntegration?: (args: CreateAnkiIntegrationArgs) => AnkiIntegrationLike; backendOverride: string | null; createMainWindow: () => void; registerGlobalShortcuts: () => void; @@ -60,23 +78,6 @@ export function initializeOverlayRuntime(options: { override?: string | null, targetMpvSocketPath?: string | null, ) => BaseWindowTracker | null; - getMpvSocketPath: () => string; - getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig; ai?: AiConfig }; - getSubtitleTimingTracker: () => unknown | null; - getMpvClient: () => { - send?: (payload: { command: string[] }) => void; - } | null; - getRuntimeOptionsManager: () => { - getEffectiveAnkiConnectConfig: (config?: AnkiConnectConfig) => AnkiConnectConfig; - } | null; - setAnkiIntegration: (integration: unknown | null) => void; - showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void; - createFieldGroupingCallback: () => ( - data: KikuFieldGroupingRequestData, - ) => Promise; - getKnownWordCacheStatePath: () => string; - shouldStartAnkiIntegration?: () => boolean; - createAnkiIntegration?: (args: CreateAnkiIntegrationArgs) => AnkiIntegrationLike; }): void { options.createMainWindow(); options.registerGlobalShortcuts(); @@ -112,35 +113,64 @@ export function initializeOverlayRuntime(options: { windowTracker.start(); } + initializeOverlayAnkiIntegration(options); + + options.updateVisibleOverlayVisibility(); +} + +export function initializeOverlayAnkiIntegration(options: { + getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig; ai?: AiConfig }; + getSubtitleTimingTracker: () => unknown | null; + getMpvClient: () => { + send?: (payload: { command: string[] }) => void; + } | null; + getRuntimeOptionsManager: () => { + getEffectiveAnkiConnectConfig: (config?: AnkiConnectConfig) => AnkiConnectConfig; + } | null; + getAnkiIntegration?: () => unknown | null; + setAnkiIntegration: (integration: unknown | null) => void; + showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void; + createFieldGroupingCallback: () => ( + data: KikuFieldGroupingRequestData, + ) => Promise; + getKnownWordCacheStatePath: () => string; + shouldStartAnkiIntegration?: () => boolean; + createAnkiIntegration?: (args: CreateAnkiIntegrationArgs) => AnkiIntegrationLike; +}): boolean { + if (options.getAnkiIntegration?.()) { + return false; + } + const config = options.getResolvedConfig(); const subtitleTimingTracker = options.getSubtitleTimingTracker(); const mpvClient = options.getMpvClient(); const runtimeOptionsManager = options.getRuntimeOptionsManager(); if ( - config.ankiConnect?.enabled === true && - subtitleTimingTracker && - mpvClient && - runtimeOptionsManager + config.ankiConnect?.enabled !== true || + !subtitleTimingTracker || + !mpvClient || + !runtimeOptionsManager ) { - const effectiveAnkiConfig = runtimeOptionsManager.getEffectiveAnkiConnectConfig( - config.ankiConnect, - ); - const createAnkiIntegration = options.createAnkiIntegration ?? createDefaultAnkiIntegration; - const integration = createAnkiIntegration({ - config: effectiveAnkiConfig, - aiConfig: mergeAiConfig(config.ai, config.ankiConnect?.ai), - subtitleTimingTracker, - mpvClient, - showDesktopNotification: options.showDesktopNotification, - createFieldGroupingCallback: options.createFieldGroupingCallback, - knownWordCacheStatePath: options.getKnownWordCacheStatePath(), - }); - if (options.shouldStartAnkiIntegration?.() !== false) { - integration.start(); - } - options.setAnkiIntegration(integration); + return false; } - options.updateVisibleOverlayVisibility(); + const effectiveAnkiConfig = runtimeOptionsManager.getEffectiveAnkiConnectConfig( + config.ankiConnect, + ); + const createAnkiIntegration = options.createAnkiIntegration ?? createDefaultAnkiIntegration; + const integration = createAnkiIntegration({ + config: effectiveAnkiConfig, + aiConfig: mergeAiConfig(config.ai, config.ankiConnect?.ai), + subtitleTimingTracker, + mpvClient, + showDesktopNotification: options.showDesktopNotification, + createFieldGroupingCallback: options.createFieldGroupingCallback, + knownWordCacheStatePath: options.getKnownWordCacheStatePath(), + }); + if (options.shouldStartAnkiIntegration?.() !== false) { + integration.start(); + } + options.setAnkiIntegration(integration); + return true; } diff --git a/src/core/services/startup.test.ts b/src/core/services/startup.test.ts index 246972d..7e6b098 100644 --- a/src/core/services/startup.test.ts +++ b/src/core/services/startup.test.ts @@ -194,3 +194,93 @@ test('runAppReadyRuntime headless refresh bootstraps Anki runtime without UI sta 'run-headless-command', ]); }); + +test('runAppReadyRuntime loads Yomitan before auto-initializing overlay runtime', async () => { + const calls: string[] = []; + + await runAppReadyRuntime({ + ensureDefaultConfigBootstrap: () => { + calls.push('bootstrap'); + }, + loadSubtitlePosition: () => { + calls.push('load-subtitle-position'); + }, + resolveKeybindings: () => { + calls.push('resolve-keybindings'); + }, + createMpvClient: () => { + calls.push('create-mpv'); + }, + reloadConfig: () => { + calls.push('reload-config'); + }, + getResolvedConfig: () => ({ + websocket: { enabled: false }, + annotationWebsocket: { enabled: false }, + texthooker: { launchAtStartup: false }, + }), + getConfigWarnings: () => [], + logConfigWarning: () => {}, + setLogLevel: () => { + calls.push('set-log-level'); + }, + initRuntimeOptionsManager: () => { + calls.push('init-runtime-options'); + }, + setSecondarySubMode: () => { + calls.push('set-secondary-sub-mode'); + }, + defaultSecondarySubMode: 'hover', + defaultWebsocketPort: 0, + defaultAnnotationWebsocketPort: 0, + defaultTexthookerPort: 0, + hasMpvWebsocketPlugin: () => false, + startSubtitleWebsocket: () => { + calls.push('subtitle-ws'); + }, + startAnnotationWebsocket: () => { + calls.push('annotation-ws'); + }, + startTexthooker: () => { + calls.push('texthooker'); + }, + log: () => { + calls.push('log'); + }, + createMecabTokenizerAndCheck: async () => {}, + createSubtitleTimingTracker: () => { + calls.push('subtitle-timing'); + }, + createImmersionTracker: () => { + calls.push('immersion'); + }, + startJellyfinRemoteSession: async () => {}, + loadYomitanExtension: async () => { + calls.push('load-yomitan'); + }, + handleFirstRunSetup: async () => { + calls.push('first-run'); + }, + prewarmSubtitleDictionaries: async () => {}, + startBackgroundWarmups: () => { + calls.push('warmups'); + }, + texthookerOnlyMode: false, + shouldAutoInitializeOverlayRuntimeFromConfig: () => true, + setVisibleOverlayVisible: () => { + calls.push('visible-overlay'); + }, + initializeOverlayRuntime: () => { + calls.push('init-overlay'); + }, + handleInitialArgs: () => { + calls.push('handle-initial-args'); + }, + shouldUseMinimalStartup: () => false, + shouldSkipHeavyStartup: () => false, + }); + + assert.ok(calls.indexOf('load-yomitan') !== -1); + assert.ok(calls.indexOf('init-overlay') !== -1); + assert.ok(calls.indexOf('load-yomitan') < calls.indexOf('init-overlay')); +}); diff --git a/src/core/services/startup.ts b/src/core/services/startup.ts index 206647d..fcb32b4 100644 --- a/src/core/services/startup.ts +++ b/src/core/services/startup.ts @@ -290,13 +290,14 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise { + return await downloadYoutubeSubtitleTrack(input); +} + +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/labels.ts b/src/core/services/youtube/labels.ts new file mode 100644 index 0000000..cafd0d9 --- /dev/null +++ b/src/core/services/youtube/labels.ts @@ -0,0 +1,40 @@ +export type YoutubeTrackKind = 'manual' | 'auto'; + +export function normalizeYoutubeLangCode(value: string): string { + return value.trim().toLowerCase().replace(/_/g, '-').replace(/[^a-z0-9-]+/g, ''); +} + +export function isJapaneseYoutubeLang(value: string): boolean { + const normalized = normalizeYoutubeLangCode(value); + return ( + normalized === 'ja' || + normalized === 'jp' || + normalized === 'jpn' || + normalized === 'japanese' || + normalized.startsWith('ja-') || + normalized.startsWith('jp-') + ); +} + +export function isEnglishYoutubeLang(value: string): boolean { + const normalized = normalizeYoutubeLangCode(value); + return ( + normalized === 'en' || + normalized === 'eng' || + normalized === 'english' || + normalized === 'enus' || + normalized === 'en-us' || + normalized.startsWith('en-') + ); +} + +export function formatYoutubeTrackLabel(input: { + language: string; + kind: YoutubeTrackKind; + title?: string; +}): string { + const language = input.language.trim() || 'unknown'; + const base = input.title?.trim() || language; + return `${base} (${input.kind})`; +} + diff --git a/src/core/services/youtube/retime.test.ts b/src/core/services/youtube/retime.test.ts new file mode 100644 index 0000000..9e4a1b6 --- /dev/null +++ b/src/core/services/youtube/retime.test.ts @@ -0,0 +1,33 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { retimeYoutubeSubtitle } from './retime'; + +test('retimeYoutubeSubtitle uses the downloaded subtitle path as-is', async () => { + if (process.platform === 'win32') { + return; + } + + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-youtube-retime-')); + try { + const primaryPath = path.join(root, 'primary.vtt'); + const referencePath = path.join(root, 'reference.vtt'); + fs.writeFileSync(primaryPath, 'WEBVTT\n', 'utf8'); + fs.writeFileSync(referencePath, 'WEBVTT\n', 'utf8'); + + const result = await retimeYoutubeSubtitle({ + primaryPath, + secondaryPath: referencePath, + }); + + assert.equal(result.ok, true); + assert.equal(result.strategy, 'none'); + assert.equal(result.path, primaryPath); + assert.equal(result.message, 'Using downloaded subtitle as-is (no automatic retime enabled)'); + assert.equal(fs.readFileSync(result.path, 'utf8'), 'WEBVTT\n'); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/src/core/services/youtube/retime.ts b/src/core/services/youtube/retime.ts new file mode 100644 index 0000000..878bb2e --- /dev/null +++ b/src/core/services/youtube/retime.ts @@ -0,0 +1,11 @@ +export async function retimeYoutubeSubtitle(input: { + primaryPath: string; + secondaryPath: string | null; +}): Promise<{ ok: boolean; path: string; strategy: 'none'; message: string }> { + return { + ok: true, + path: input.primaryPath, + strategy: 'none', + message: `Using downloaded subtitle as-is${input.secondaryPath ? ' (no automatic retime enabled)' : ''}`, + }; +} diff --git a/src/core/services/youtube/timedtext.ts b/src/core/services/youtube/timedtext.ts new file mode 100644 index 0000000..35878cb --- /dev/null +++ b/src/core/services/youtube/timedtext.ts @@ -0,0 +1,89 @@ +interface YoutubeTimedTextRow { + startMs: number; + durationMs: number; + text: string; +} + +const YOUTUBE_TIMEDTEXT_EXTENSIONS = new Set(['srv1', 'srv2', 'srv3', 'ytsrv3']); + +function decodeHtmlEntities(value: string): string { + return value + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/&#(\d+);/g, (_match, codePoint) => String.fromCodePoint(Number(codePoint))) + .replace(/&#x([0-9a-f]+);/gi, (_match, codePoint) => + String.fromCodePoint(Number.parseInt(codePoint, 16)), + ); +} + +function parseAttributeMap(raw: string): Map { + const attrs = new Map(); + for (const match of raw.matchAll(/([a-zA-Z0-9:_-]+)="([^"]*)"/g)) { + attrs.set(match[1]!, match[2]!); + } + return attrs; +} + +function extractYoutubeTimedTextRows(xml: string): YoutubeTimedTextRow[] { + const rows: YoutubeTimedTextRow[] = []; + + for (const match of xml.matchAll(/]*)>([\s\S]*?)<\/p>/g)) { + const attrs = parseAttributeMap(match[1] ?? ''); + const startMs = Number(attrs.get('t')); + const durationMs = Number(attrs.get('d')); + if (!Number.isFinite(startMs) || !Number.isFinite(durationMs)) { + continue; + } + + const inner = (match[2] ?? '') + .replace(//gi, '\n') + .replace(/<[^>]+>/g, ''); + const text = decodeHtmlEntities(inner).trim(); + if (!text) { + continue; + } + + rows.push({ startMs, durationMs, text }); + } + + return rows; +} + +function formatVttTimestamp(ms: number): string { + const totalMs = Math.max(0, Math.floor(ms)); + const hours = Math.floor(totalMs / 3_600_000); + const minutes = Math.floor((totalMs % 3_600_000) / 60_000); + const seconds = Math.floor((totalMs % 60_000) / 1_000); + const millis = totalMs % 1_000; + return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}.${String(millis).padStart(3, '0')}`; +} + +export function isYoutubeTimedTextExtension(value: string | undefined): boolean { + if (!value) { + return false; + } + return YOUTUBE_TIMEDTEXT_EXTENSIONS.has(value.trim().toLowerCase()); +} + +export function convertYoutubeTimedTextToVtt(xml: string): string { + const rows = extractYoutubeTimedTextRows(xml); + if (rows.length === 0) { + return 'WEBVTT\n'; + } + + const blocks = rows.map((row, index) => { + const nextRow = rows[index + 1]; + const unclampedEnd = row.startMs + row.durationMs; + const clampedEnd = + nextRow && unclampedEnd > nextRow.startMs + ? Math.max(row.startMs, nextRow.startMs - 1) + : unclampedEnd; + + return `${formatVttTimestamp(row.startMs)} --> ${formatVttTimestamp(clampedEnd)}\n${row.text}`; + }); + + return `WEBVTT\n\n${blocks.join('\n\n')}\n`; +} diff --git a/src/core/services/youtube/track-download.test.ts b/src/core/services/youtube/track-download.test.ts new file mode 100644 index 0000000..800dfd0 --- /dev/null +++ b/src/core/services/youtube/track-download.test.ts @@ -0,0 +1,472 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { downloadYoutubeSubtitleTrack, downloadYoutubeSubtitleTracks } from './track-download'; + +async function withTempDir(fn: (dir: string) => Promise): Promise { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-youtube-track-download-')); + try { + return await fn(dir); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +function makeFakeYtDlpScript(dir: string): string { + const scriptPath = path.join(dir, 'yt-dlp'); +const script = `#!/usr/bin/env node +const fs = require('node:fs'); +const path = require('node:path'); + +const args = process.argv.slice(2); +let outputTemplate = ''; +const wantsAutoSubs = args.includes('--write-auto-subs'); +const wantsManualSubs = args.includes('--write-subs'); +const subLangIndex = args.indexOf('--sub-langs'); +const subLang = subLangIndex >= 0 ? args[subLangIndex + 1] || '' : ''; +const subLangs = subLang ? subLang.split(',').filter(Boolean) : []; +for (let i = 0; i < args.length; i += 1) { + if (args[i] === '-o' && typeof args[i + 1] === 'string') { + outputTemplate = args[i + 1]; + i += 1; + } +} + +if (process.env.YTDLP_EXPECT_AUTO_SUBS === '1' && !wantsAutoSubs) { + process.exit(2); +} +if (process.env.YTDLP_EXPECT_MANUAL_SUBS === '1' && !wantsManualSubs) { + process.exit(3); +} +if (process.env.YTDLP_EXPECT_SUB_LANG && subLang !== process.env.YTDLP_EXPECT_SUB_LANG) { + process.exit(4); +} + +const prefix = outputTemplate.replace(/\.%\([^)]+\)s$/, ''); +if (!prefix) { + process.exit(1); +} +fs.mkdirSync(path.dirname(prefix), { recursive: true }); + +if (process.env.YTDLP_FAKE_MODE === 'multi') { + for (const lang of subLangs) { + fs.writeFileSync(\`\${prefix}.\${lang}.vtt\`, 'WEBVTT\\n'); + } +} else if (process.env.YTDLP_FAKE_MODE === 'rolling-auto') { + fs.writeFileSync( + \`\${prefix}.vtt\`, + [ + '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'), + ); +} else if (process.env.YTDLP_FAKE_MODE === 'multi-primary-only-fail') { + const primaryLang = subLangs[0]; + if (primaryLang) { + fs.writeFileSync(\`\${prefix}.\${primaryLang}.vtt\`, 'WEBVTT\\n'); + } + process.stderr.write("ERROR: Unable to download video subtitles for 'en': HTTP Error 429: Too Many Requests\\n"); + process.exit(1); +} else if (process.env.YTDLP_FAKE_MODE === 'both') { + fs.writeFileSync(\`\${prefix}.vtt\`, 'WEBVTT\\n'); + fs.writeFileSync(\`\${prefix}.orig.webp\`, 'webp'); +} else if (process.env.YTDLP_FAKE_MODE === 'webp-only') { + fs.writeFileSync(\`\${prefix}.orig.webp\`, 'webp'); +} else { + fs.writeFileSync(\`\${prefix}.vtt\`, 'WEBVTT\\n'); +} +process.exit(0); +`; + fs.writeFileSync(scriptPath, script, 'utf8'); + fs.chmodSync(scriptPath, 0o755); + return scriptPath; +} + +async function withFakeYtDlp( + mode: 'both' | 'webp-only' | 'multi' | 'multi-primary-only-fail' | 'rolling-auto', + fn: (dir: string, binDir: string) => Promise, +): Promise { + return await withTempDir(async (root) => { + const binDir = path.join(root, 'bin'); + fs.mkdirSync(binDir, { recursive: true }); + makeFakeYtDlpScript(binDir); + + const originalPath = process.env.PATH ?? ''; + process.env.PATH = `${binDir}${path.delimiter}${originalPath}`; + process.env.YTDLP_FAKE_MODE = mode; + try { + return await fn(root, binDir); + } finally { + process.env.PATH = originalPath; + delete process.env.YTDLP_FAKE_MODE; + } + }); +} + +async function withFakeYtDlpExpectations( + expectations: Partial>, + fn: () => Promise, +): Promise { + const previous = { + YTDLP_EXPECT_AUTO_SUBS: process.env.YTDLP_EXPECT_AUTO_SUBS, + YTDLP_EXPECT_MANUAL_SUBS: process.env.YTDLP_EXPECT_MANUAL_SUBS, + YTDLP_EXPECT_SUB_LANG: process.env.YTDLP_EXPECT_SUB_LANG, + }; + Object.assign(process.env, expectations); + try { + return await fn(); + } finally { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + } +} + +async function withStubFetch( + handler: (url: string) => Promise | Response, + fn: () => Promise, +): Promise { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (input: string | URL | Request) => { + const url = + typeof input === 'string' + ? input + : input instanceof URL + ? input.toString() + : input.url; + return await handler(url); + }) as typeof fetch; + try { + return await fn(); + } finally { + globalThis.fetch = originalFetch; + } +} + +test('downloadYoutubeSubtitleTrack prefers subtitle files over later webp artifacts', async () => { + if (process.platform === 'win32') { + return; + } + + await withFakeYtDlp('both', 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)', + }, + mode: 'download', + }); + + assert.equal(path.extname(result.path), '.vtt'); + assert.match(path.basename(result.path), /^auto-ja-orig\./); + }); +}); + +test('downloadYoutubeSubtitleTrack ignores stale subtitle files from prior runs', async () => { + if (process.platform === 'win32') { + return; + } + + await withFakeYtDlp('webp-only', async (root) => { + const outputDir = path.join(root, 'out'); + fs.mkdirSync(outputDir, { recursive: true }); + fs.writeFileSync(path.join(outputDir, 'auto-ja.vtt'), 'stale subtitle'); + + await assert.rejects( + async () => + await downloadYoutubeSubtitleTrack({ + targetUrl: 'https://www.youtube.com/watch?v=abc123', + outputDir, + track: { + id: 'auto:ja', + language: 'ja', + sourceLanguage: 'ja', + kind: 'auto', + label: 'Japanese (auto)', + }, + mode: 'download', + }), + /No subtitle file was downloaded/, + ); + }); +}); + +test('downloadYoutubeSubtitleTrack uses auto subtitle flags and raw source language for auto tracks', async () => { + if (process.platform === 'win32') { + return; + } + + await withFakeYtDlp('both', async (root) => { + await withFakeYtDlpExpectations( + { + YTDLP_EXPECT_AUTO_SUBS: '1', + YTDLP_EXPECT_SUB_LANG: 'ja-orig', + }, + async () => { + 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)', + }, + mode: 'download', + }); + + assert.equal(path.extname(result.path), '.vtt'); + }, + ); + }); +}); + +test('downloadYoutubeSubtitleTrack keeps manual subtitle flag for manual tracks', async () => { + if (process.platform === 'win32') { + return; + } + + await withFakeYtDlp('both', async (root) => { + await withFakeYtDlpExpectations( + { + YTDLP_EXPECT_MANUAL_SUBS: '1', + YTDLP_EXPECT_SUB_LANG: 'ja', + }, + async () => { + const result = await downloadYoutubeSubtitleTrack({ + targetUrl: 'https://www.youtube.com/watch?v=abc123', + outputDir: path.join(root, 'out'), + track: { + id: 'manual:ja', + language: 'ja', + sourceLanguage: 'ja', + kind: 'manual', + label: 'Japanese (manual)', + }, + mode: 'download', + }); + + assert.equal(path.extname(result.path), '.vtt'); + }, + ); + }); +}); + +test('downloadYoutubeSubtitleTrack prefers direct download URL when available', async () => { + await withTempDir(async (root) => { + await withStubFetch( + async (url) => { + assert.equal(url, 'https://example.com/subs/ja.vtt'); + return new Response('WEBVTT\n', { status: 200 }); + }, + async () => { + 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)', + downloadUrl: 'https://example.com/subs/ja.vtt', + fileExtension: 'vtt', + }, + mode: 'download', + }); + + assert.equal(path.basename(result.path), 'auto-ja-orig.ja-orig.vtt'); + assert.equal(fs.readFileSync(result.path, 'utf8'), 'WEBVTT\n'); + }, + ); + }); +}); + +test('downloadYoutubeSubtitleTrack converts srv3 auto subtitles into regular vtt', async () => { + await withTempDir(async (root) => { + await withStubFetch( + async (url) => { + assert.equal(url, 'https://example.com/subs/ja.srv3'); + return new Response( + [ + '', + '

今日は

', + '

今日はいい天気ですね

', + '

今日はいい天気ですね本当に

', + '
', + ].join(''), + { status: 200 }, + ); + }, + async () => { + 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)', + downloadUrl: 'https://example.com/subs/ja.srv3', + fileExtension: 'srv3', + }, + mode: 'download', + }); + + assert.equal(path.basename(result.path), 'auto-ja-orig.ja-orig.vtt'); + assert.equal( + fs.readFileSync(result.path, 'utf8'), + [ + 'WEBVTT', + '', + '00:00:01.000 --> 00:00:01.999', + '今日は', + '', + '00:00:02.000 --> 00:00:03.499', + 'いい天気ですね', + '', + '00:00:03.500 --> 00:00:06.000', + '本当に', + '', + ].join('\n'), + ); + }, + ); + }); +}); + +test('downloadYoutubeSubtitleTracks downloads primary and secondary in one invocation', async () => { + if (process.platform === 'win32') { + return; + } + + await withFakeYtDlp('multi', async (root) => { + const outputDir = path.join(root, 'out'); + const result = await downloadYoutubeSubtitleTracks({ + targetUrl: 'https://www.youtube.com/watch?v=abc123', + outputDir, + tracks: [ + { + id: 'auto:ja-orig', + language: 'ja', + sourceLanguage: 'ja-orig', + kind: 'auto', + label: 'Japanese (auto)', + }, + { + id: 'auto:en', + language: 'en', + sourceLanguage: 'en', + kind: 'auto', + label: 'English (auto)', + }, + ], + mode: 'download', + }); + + assert.match(path.basename(result.get('auto:ja-orig') ?? ''), /\.ja-orig\.vtt$/); + assert.match(path.basename(result.get('auto:en') ?? ''), /\.en\.vtt$/); + }); +}); + +test('downloadYoutubeSubtitleTracks preserves successfully downloaded primary file on partial failure', async () => { + if (process.platform === 'win32') { + return; + } + + await withFakeYtDlp('multi-primary-only-fail', async (root) => { + const outputDir = path.join(root, 'out'); + const result = await downloadYoutubeSubtitleTracks({ + targetUrl: 'https://www.youtube.com/watch?v=abc123', + outputDir, + tracks: [ + { + id: 'auto:ja-orig', + language: 'ja', + sourceLanguage: 'ja-orig', + kind: 'auto', + label: 'Japanese (auto)', + }, + { + id: 'auto:en', + language: 'en', + sourceLanguage: 'en', + kind: 'auto', + label: 'English (auto)', + }, + ], + mode: 'download', + }); + + assert.match(path.basename(result.get('auto:ja-orig') ?? ''), /\.ja-orig\.vtt$/); + assert.equal(result.has('auto:en'), false); + }); +}); + +test('downloadYoutubeSubtitleTracks prefers direct download URLs when available', async () => { + await withTempDir(async (root) => { + const seen: string[] = []; + await withStubFetch( + async (url) => { + seen.push(url); + return new Response(`WEBVTT\n${url}\n`, { status: 200 }); + }, + async () => { + const result = await downloadYoutubeSubtitleTracks({ + targetUrl: 'https://www.youtube.com/watch?v=abc123', + outputDir: path.join(root, 'out'), + tracks: [ + { + id: 'auto:ja-orig', + language: 'ja', + sourceLanguage: 'ja-orig', + kind: 'auto', + label: 'Japanese (auto)', + downloadUrl: 'https://example.com/subs/ja.vtt', + fileExtension: 'vtt', + }, + { + id: 'auto:en', + language: 'en', + sourceLanguage: 'en', + kind: 'auto', + label: 'English (auto)', + downloadUrl: 'https://example.com/subs/en.vtt', + fileExtension: 'vtt', + }, + ], + mode: 'download', + }); + + assert.deepEqual(seen, [ + 'https://example.com/subs/ja.vtt', + 'https://example.com/subs/en.vtt', + ]); + assert.match(path.basename(result.get('auto:ja-orig') ?? ''), /\.ja-orig\.vtt$/); + assert.match(path.basename(result.get('auto:en') ?? ''), /\.en\.vtt$/); + }, + ); + }); +}); diff --git a/src/core/services/youtube/track-download.ts b/src/core/services/youtube/track-download.ts new file mode 100644 index 0000000..7f446af --- /dev/null +++ b/src/core/services/youtube/track-download.ts @@ -0,0 +1,256 @@ +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'; + +const YOUTUBE_SUBTITLE_EXTENSIONS = new Set(['.srt', '.vtt', '.ass']); +const YOUTUBE_BATCH_PREFIX = 'youtube-batch'; + +function runCapture(command: string, args: string[]): Promise<{ stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + const proc = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] }); + let stdout = ''; + let stderr = ''; + proc.stdout.setEncoding('utf8'); + proc.stderr.setEncoding('utf8'); + proc.stdout.on('data', (chunk) => { + stdout += String(chunk); + }); + proc.stderr.on('data', (chunk) => { + stderr += String(chunk); + }); + proc.once('error', reject); + proc.once('close', (code) => { + if (code === 0) { + resolve({ stdout, stderr }); + return; + } + reject(new Error(stderr.trim() || `yt-dlp exited with status ${code ?? 'unknown'}`)); + }); + }); +} + +function runCaptureDetailed( + command: string, + args: string[], +): Promise<{ stdout: string; stderr: string; code: number }> { + return new Promise((resolve, reject) => { + const proc = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] }); + let stdout = ''; + let stderr = ''; + proc.stdout.setEncoding('utf8'); + proc.stderr.setEncoding('utf8'); + proc.stdout.on('data', (chunk) => { + stdout += String(chunk); + }); + proc.stderr.on('data', (chunk) => { + stderr += String(chunk); + }); + proc.once('error', reject); + proc.once('close', (code) => { + resolve({ stdout, stderr, code: code ?? 1 }); + }); + }); +} + +function pickLatestSubtitleFile(dir: string, prefix: string): string | null { + const entries = fs.readdirSync(dir).map((name) => path.join(dir, name)); + const candidates = entries.filter((candidate) => { + const basename = path.basename(candidate); + const ext = path.extname(basename).toLowerCase(); + return basename.startsWith(prefix) && YOUTUBE_SUBTITLE_EXTENSIONS.has(ext); + }); + candidates.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs); + return candidates[0] ?? null; +} + +function pickLatestSubtitleFileForLanguage( + dir: string, + prefix: string, + sourceLanguage: string, +): string | null { + const entries = fs.readdirSync(dir).map((name) => path.join(dir, name)); + const candidates = entries.filter((candidate) => { + const basename = path.basename(candidate); + const ext = path.extname(basename).toLowerCase(); + return ( + basename.startsWith(`${prefix}.`) && + basename.includes(`.${sourceLanguage}.`) && + YOUTUBE_SUBTITLE_EXTENSIONS.has(ext) + ); + }); + candidates.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs); + return candidates[0] ?? null; +} + +function buildDownloadArgs(input: { + targetUrl: string; + outputTemplate: string; + sourceLanguages: string[]; + includeAutoSubs: boolean; + includeManualSubs: boolean; +}): string[] { + const args = ['--skip-download', '--no-warnings']; + if (input.includeAutoSubs) { + args.push('--write-auto-subs'); + } + if (input.includeManualSubs) { + args.push('--write-subs'); + } + args.push( + '--sub-format', + 'srt/vtt/best', + '--sub-langs', + input.sourceLanguages.join(','), + '-o', + input.outputTemplate, + input.targetUrl, + ); + return args; +} + +async function downloadSubtitleFromUrl(input: { + outputDir: string; + prefix: string; + track: YoutubeTrackOption; +}): Promise<{ path: string }> { + if (!input.track.downloadUrl) { + throw new Error(`No direct subtitle URL available for ${input.track.sourceLanguage}`); + } + const ext = (input.track.fileExtension?.trim().toLowerCase() || 'vtt').replace(/[^a-z0-9]+/g, ''); + const safeExt = isYoutubeTimedTextExtension(ext) + ? 'vtt' + : YOUTUBE_SUBTITLE_EXTENSIONS.has(`.${ext}`) + ? ext + : 'vtt'; + const targetPath = path.join(input.outputDir, `${input.prefix}.${input.track.sourceLanguage}.${safeExt}`); + const response = await fetch(input.track.downloadUrl); + if (!response.ok) { + throw new Error(`HTTP ${response.status} while downloading ${input.track.sourceLanguage}`); + } + const body = await response.text(); + const normalizedBody = isYoutubeTimedTextExtension(ext) ? convertYoutubeTimedTextToVtt(body) : body; + fs.writeFileSync(targetPath, normalizedBody, 'utf8'); + return { path: targetPath }; +} + +function canDownloadSubtitleFromUrl(track: YoutubeTrackOption): boolean { + if (!track.downloadUrl) { + return false; + } + + const ext = (track.fileExtension?.trim().toLowerCase() || 'vtt').replace(/[^a-z0-9]+/g, ''); + return isYoutubeTimedTextExtension(ext) || YOUTUBE_SUBTITLE_EXTENSIONS.has(`.${ext}`); +} + +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, '-'); + for (const name of fs.readdirSync(input.outputDir)) { + if (name.startsWith(prefix)) { + try { + fs.rmSync(path.join(input.outputDir, name), { force: true }); + } catch { + // ignore stale files + } + } + } + if (canDownloadSubtitleFromUrl(input.track)) { + return await downloadSubtitleFromUrl({ + outputDir: input.outputDir, + prefix, + track: input.track, + }); + } + const outputTemplate = path.join(input.outputDir, `${prefix}.%(ext)s`); + const args = [ + ...buildDownloadArgs({ + targetUrl: input.targetUrl, + outputTemplate, + sourceLanguages: [input.track.sourceLanguage], + includeAutoSubs: input.mode === 'generate' || input.track.kind === 'auto', + includeManualSubs: input.track.kind === 'manual', + }), + ]; + + await runCapture('yt-dlp', args); + const subtitlePath = pickLatestSubtitleFile(input.outputDir, prefix); + if (!subtitlePath) { + throw new Error(`No subtitle file was downloaded for ${input.track.sourceLanguage}`); + } + return { path: subtitlePath }; +} + +export async function downloadYoutubeSubtitleTracks(input: { + targetUrl: string; + outputDir: string; + tracks: YoutubeTrackOption[]; + mode: YoutubeFlowMode; +}): Promise> { + fs.mkdirSync(input.outputDir, { recursive: true }); + for (const name of fs.readdirSync(input.outputDir)) { + if (name.startsWith(`${YOUTUBE_BATCH_PREFIX}.`)) { + try { + fs.rmSync(path.join(input.outputDir, name), { force: true }); + } catch { + // ignore stale files + } + } + } + if (input.tracks.every(canDownloadSubtitleFromUrl)) { + const results = new Map(); + for (const track of input.tracks) { + const download = await downloadSubtitleFromUrl({ + outputDir: input.outputDir, + prefix: YOUTUBE_BATCH_PREFIX, + track, + }); + results.set(track.id, download.path); + } + return results; + } + + const outputTemplate = path.join(input.outputDir, `${YOUTUBE_BATCH_PREFIX}.%(ext)s`); + const includeAutoSubs = + input.mode === 'generate' || input.tracks.some((track) => track.kind === 'auto'); + const includeManualSubs = input.tracks.some((track) => track.kind === 'manual'); + + const result = await runCaptureDetailed( + 'yt-dlp', + buildDownloadArgs({ + targetUrl: input.targetUrl, + outputTemplate, + sourceLanguages: input.tracks.map((track) => track.sourceLanguage), + includeAutoSubs, + includeManualSubs, + }), + ); + + const results = new Map(); + for (const track of input.tracks) { + const subtitlePath = pickLatestSubtitleFileForLanguage( + input.outputDir, + YOUTUBE_BATCH_PREFIX, + track.sourceLanguage, + ); + if (subtitlePath) { + results.set(track.id, subtitlePath); + } + } + if (results.size > 0) { + return results; + } + if (result.code !== 0) { + throw new Error(result.stderr.trim() || `yt-dlp exited with status ${result.code}`); + } + throw new Error( + `No subtitle file was downloaded for ${input.tracks.map((track) => track.sourceLanguage).join(',')}`, + ); +} diff --git a/src/core/services/youtube/track-probe.test.ts b/src/core/services/youtube/track-probe.test.ts new file mode 100644 index 0000000..cf05ddc --- /dev/null +++ b/src/core/services/youtube/track-probe.test.ts @@ -0,0 +1,80 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { probeYoutubeTracks } from './track-probe'; + +async function withTempDir(fn: (dir: string) => Promise): Promise { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-youtube-track-probe-')); + try { + return await fn(dir); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +function makeFakeYtDlpScript(dir: string, payload: unknown): void { + const scriptPath = path.join(dir, 'yt-dlp'); + const script = `#!/usr/bin/env node +process.stdout.write(${JSON.stringify(JSON.stringify(payload))}); +`; + fs.writeFileSync(scriptPath, script, 'utf8'); + fs.chmodSync(scriptPath, 0o755); +} + +async function withFakeYtDlp(payload: unknown, fn: () => Promise): Promise { + return await withTempDir(async (root) => { + const binDir = path.join(root, 'bin'); + fs.mkdirSync(binDir, { recursive: true }); + makeFakeYtDlpScript(binDir, payload); + const originalPath = process.env.PATH ?? ''; + process.env.PATH = `${binDir}${path.delimiter}${originalPath}`; + try { + return await fn(); + } finally { + process.env.PATH = originalPath; + } + }); +} + +test('probeYoutubeTracks prefers srv3 over vtt for automatic captions', async () => { + await withFakeYtDlp( + { + id: 'abc123', + title: 'Example', + automatic_captions: { + 'ja-orig': [ + { ext: 'vtt', url: 'https://example.com/ja.vtt', name: 'Japanese auto' }, + { ext: 'srv3', url: 'https://example.com/ja.srv3', name: 'Japanese auto' }, + ], + }, + }, + async () => { + const result = await probeYoutubeTracks('https://www.youtube.com/watch?v=abc123'); + assert.equal(result.videoId, 'abc123'); + assert.equal(result.tracks[0]?.downloadUrl, 'https://example.com/ja.srv3'); + assert.equal(result.tracks[0]?.fileExtension, 'srv3'); + }, + ); +}); + +test('probeYoutubeTracks keeps preferring srt for manual captions', async () => { + await withFakeYtDlp( + { + id: 'abc123', + title: 'Example', + subtitles: { + ja: [ + { ext: 'srv3', url: 'https://example.com/ja.srv3', name: 'Japanese manual' }, + { ext: 'srt', url: 'https://example.com/ja.srt', name: 'Japanese manual' }, + ], + }, + }, + async () => { + const result = await probeYoutubeTracks('https://www.youtube.com/watch?v=abc123'); + assert.equal(result.tracks[0]?.downloadUrl, 'https://example.com/ja.srt'); + assert.equal(result.tracks[0]?.fileExtension, 'srt'); + }, + ); +}); diff --git a/src/core/services/youtube/track-probe.ts b/src/core/services/youtube/track-probe.ts new file mode 100644 index 0000000..bfad523 --- /dev/null +++ b/src/core/services/youtube/track-probe.ts @@ -0,0 +1,112 @@ +import { spawn } from 'node:child_process'; +import type { YoutubeTrackOption } from '../../../types'; +import { + formatYoutubeTrackLabel, + normalizeYoutubeLangCode, + type YoutubeTrackKind, +} from './labels'; + +export type YoutubeTrackProbeResult = { + videoId: string; + title: string; + tracks: YoutubeTrackOption[]; +}; + +type YtDlpSubtitleEntry = Array<{ ext?: string; name?: string; url?: string }>; + +type YtDlpInfo = { + id?: string; + title?: string; + subtitles?: Record; + automatic_captions?: Record; +}; + +function runCapture(command: string, args: string[]): Promise<{ stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + const proc = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] }); + let stdout = ''; + let stderr = ''; + proc.stdout.setEncoding('utf8'); + proc.stderr.setEncoding('utf8'); + proc.stdout.on('data', (chunk) => { + stdout += String(chunk); + }); + proc.stderr.on('data', (chunk) => { + stderr += String(chunk); + }); + proc.once('error', reject); + proc.once('close', (code) => { + if (code === 0) { + resolve({ stdout, stderr }); + return; + } + reject(new Error(stderr.trim() || `yt-dlp exited with status ${code ?? 'unknown'}`)); + }); + }); +} + +function choosePreferredFormat( + formats: YtDlpSubtitleEntry, + kind: YoutubeTrackKind, +): { ext: string; url: string; title?: string } | null { + const preferredOrder = + kind === 'auto' + ? ['srv3', 'srv2', 'srv1', 'vtt', 'srt', 'ttml', 'json3'] + : ['srt', 'vtt', 'srv3', 'srv2', 'srv1', 'ttml', 'json3']; + for (const ext of preferredOrder) { + const match = formats.find( + (format) => typeof format.url === 'string' && format.url && format.ext === ext, + ); + if (match?.url) { + return { ext, url: match.url, title: match.name?.trim() || undefined }; + } + } + + const fallback = formats.find((format) => typeof format.url === 'string' && format.url); + if (!fallback?.url) { + return null; + } + + return { + ext: fallback.ext?.trim() || 'vtt', + url: fallback.url, + title: fallback.name?.trim() || undefined, + }; +} + +function toTracks(entries: Record | undefined, kind: YoutubeTrackKind) { + const tracks: YoutubeTrackOption[] = []; + if (!entries) return tracks; + for (const [language, formats] of Object.entries(entries)) { + if (!Array.isArray(formats) || formats.length === 0) continue; + const preferredFormat = choosePreferredFormat(formats, kind); + if (!preferredFormat) continue; + const sourceLanguage = language.trim() || language; + const normalizedLanguage = normalizeYoutubeLangCode(sourceLanguage) || sourceLanguage; + const title = preferredFormat.title; + tracks.push({ + id: `${kind}:${sourceLanguage}`, + language: normalizedLanguage, + sourceLanguage, + kind, + title, + label: formatYoutubeTrackLabel({ language: normalizedLanguage, kind, title }), + downloadUrl: preferredFormat.url, + fileExtension: preferredFormat.ext, + }); + } + return tracks; +} + +export type { YoutubeTrackOption }; + +export async function probeYoutubeTracks(targetUrl: string): Promise { + const { stdout } = await runCapture('yt-dlp', ['--dump-single-json', '--no-warnings', targetUrl]); + const info = JSON.parse(stdout) as YtDlpInfo; + const tracks = [...toTracks(info.subtitles, 'manual'), ...toTracks(info.automatic_captions, 'auto')]; + return { + videoId: info.id || '', + title: info.title || '', + tracks, + }; +} diff --git a/src/core/services/youtube/track-selection.ts b/src/core/services/youtube/track-selection.ts new file mode 100644 index 0000000..141b100 --- /dev/null +++ b/src/core/services/youtube/track-selection.ts @@ -0,0 +1,63 @@ +import { isEnglishYoutubeLang, isJapaneseYoutubeLang } from './labels'; +import type { YoutubeTrackOption } from './track-probe'; + +function pickTrack( + tracks: YoutubeTrackOption[], + matcher: (value: string) => boolean, + excludeId?: string, +): YoutubeTrackOption | null { + const matching = tracks.filter((track) => matcher(track.language) && track.id !== excludeId); + return matching[0] ?? null; +} + +export function chooseDefaultYoutubeTrackIds( + tracks: YoutubeTrackOption[], +): { primaryTrackId: string | null; secondaryTrackId: string | null } { + const primary = + pickTrack( + tracks.filter((track) => track.kind === 'manual'), + isJapaneseYoutubeLang, + ) || + pickTrack( + tracks.filter((track) => track.kind === 'auto'), + isJapaneseYoutubeLang, + ) || + tracks.find((track) => track.kind === 'manual') || + tracks[0] || + null; + + const secondary = + pickTrack( + tracks.filter((track) => track.kind === 'manual'), + isEnglishYoutubeLang, + primary?.id ?? undefined, + ) || + pickTrack( + tracks.filter((track) => track.kind === 'auto'), + isEnglishYoutubeLang, + primary?.id ?? undefined, + ) || + null; + + return { + primaryTrackId: primary?.id ?? null, + secondaryTrackId: secondary?.id ?? null, + }; +} + +export function normalizeYoutubeTrackSelection(input: { + primaryTrackId: string | null; + secondaryTrackId: string | null; +}): { + primaryTrackId: string | null; + secondaryTrackId: string | null; +} { + if (input.primaryTrackId && input.secondaryTrackId && input.primaryTrackId === input.secondaryTrackId) { + return { + primaryTrackId: input.primaryTrackId, + secondaryTrackId: null, + }; + } + return input; +} + diff --git a/src/main.ts b/src/main.ts index be30496..2dc9daa 100644 --- a/src/main.ts +++ b/src/main.ts @@ -113,6 +113,7 @@ import { } from './cli/args'; import type { CliArgs, CliCommandSource } from './cli/args'; import { printHelp } from './cli/help'; +import { IPC_CHANNELS } from './shared/ipc/contracts'; import { buildConfigParseErrorDetails, buildConfigWarningDialogDetails, @@ -279,6 +280,7 @@ import { handleMultiCopyDigit as handleMultiCopyDigitCore, hasMpvWebsocketPlugin, importYomitanDictionaryFromZip, + initializeOverlayAnkiIntegration as initializeOverlayAnkiIntegrationCore, initializeOverlayRuntime as initializeOverlayRuntimeCore, jellyfinTicksToSecondsRuntime, listJellyfinItemsRuntime, @@ -309,12 +311,19 @@ import { upsertYomitanDictionarySettings, updateLastCardFromClipboard as updateLastCardFromClipboardCore, } from './core/services'; +import { + acquireYoutubeSubtitleTrack, + acquireYoutubeSubtitleTracks, +} from './core/services/youtube/generate'; +import { retimeYoutubeSubtitle } from './core/services/youtube/retime'; +import { probeYoutubeTracks } from './core/services/youtube/track-probe'; import { startStatsServer } from './core/services/stats-server'; import { registerStatsOverlayToggle, destroyStatsWindow } from './core/services/stats-window.js'; import { createFirstRunSetupService, shouldAutoOpenFirstRunSetup, } from './main/runtime/first-run-setup-service'; +import { createYoutubeFlowRuntime } from './main/runtime/youtube-flow'; import { resolveAutoplayReadyMaxReleaseAttempts } from './main/runtime/startup-autoplay-release-policy'; import { buildFirstRunSetupHtml, @@ -332,6 +341,7 @@ import { detectWindowsMpvShortcuts, resolveWindowsMpvShortcutPaths, } from './main/runtime/windows-mpv-shortcuts'; +import { createWindowsMpvLaunchDeps, launchWindowsMpv } from './main/runtime/windows-mpv-launch'; import { createImmersionTrackerStartupHandler } from './main/runtime/immersion-startup'; import { createBuildImmersionTrackerStartupMainDepsHandler } from './main/runtime/immersion-startup-main-deps'; import { @@ -442,7 +452,7 @@ import { resolveSubtitleSourcePath, } from './main/runtime/subtitle-prefetch-source'; import { createSubtitlePrefetchInitController } from './main/runtime/subtitle-prefetch-init'; -import { codecToExtension } from './subsync/utils'; +import { codecToExtension, getSubsyncConfig } from './subsync/utils'; if (process.platform === 'linux') { app.commandLine.appendSwitch('enable-features', 'GlobalShortcutsPortal'); @@ -787,6 +797,185 @@ const appState = createAppState({ mpvSocketPath: getDefaultSocketPath(), texthookerPort: DEFAULT_TEXTHOOKER_PORT, }); +const startBackgroundWarmupsIfAllowed = (): void => { + if (appState.youtubePlaybackFlowPending) { + return; + } + startBackgroundWarmups(); +}; +const youtubeFlowRuntime = createYoutubeFlowRuntime({ + probeYoutubeTracks: (url: string) => probeYoutubeTracks(url), + acquireYoutubeSubtitleTrack: (input) => acquireYoutubeSubtitleTrack(input), + acquireYoutubeSubtitleTracks: (input) => acquireYoutubeSubtitleTracks(input), + retimeYoutubePrimaryTrack: async ({ primaryTrack, primaryPath, secondaryTrack, secondaryPath }) => { + if (primaryTrack.kind !== 'auto') { + return primaryPath; + } + const result = await retimeYoutubeSubtitle({ + primaryPath, + secondaryPath: secondaryTrack ? secondaryPath : null, + }); + logger.info(`Using YouTube subtitle path: ${result.path} (${result.strategy})`); + return result.path; + }, + openPicker: async (payload) => { + const preferDedicatedModalWindow = false; + const sendPickerOpen = (preferModalWindow: boolean): boolean => + overlayModalRuntime.sendToActiveOverlayWindow('youtube:picker-open', payload, { + restoreOnModalClose: 'youtube-track-picker', + preferModalWindow, + }); + + if (!sendPickerOpen(preferDedicatedModalWindow)) { + return false; + } + if (await overlayModalRuntime.waitForModalOpen('youtube-track-picker', 1500)) { + return true; + } + + logger.warn( + 'YouTube subtitle picker did not acknowledge modal open on first attempt; retrying on visible overlay.', + ); + if (!sendPickerOpen(!preferDedicatedModalWindow)) { + return false; + } + return await overlayModalRuntime.waitForModalOpen('youtube-track-picker', 1500); + }, + pauseMpv: () => { + sendMpvCommandRuntime(appState.mpvClient, ['set_property', 'pause', 'yes']); + }, + resumeMpv: () => { + sendMpvCommandRuntime(appState.mpvClient, ['set_property', 'pause', 'no']); + }, + sendMpvCommand: (command) => { + sendMpvCommandRuntime(appState.mpvClient, command); + }, + requestMpvProperty: async (name: string) => { + const client = appState.mpvClient; + if (!client) return null; + return await client.requestProperty(name); + }, + refreshCurrentSubtitle: (text: string) => { + subtitleProcessingController.refreshCurrentSubtitle(text); + }, + startTokenizationWarmups: async () => { + await startTokenizationWarmups(); + }, + waitForTokenizationReady: async () => { + await currentMediaTokenizationGate.waitUntilReady( + appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || null, + ); + }, + waitForAnkiReady: async () => { + const integration = appState.ankiIntegration; + if (!integration) { + return; + } + try { + await Promise.race([ + integration.waitUntilReady(), + new Promise((_, reject) => { + setTimeout(() => reject(new Error('Timed out waiting for AnkiConnect integration')), 2500); + }), + ]); + } catch (error) { + logger.warn( + 'Continuing YouTube playback before AnkiConnect integration reported ready:', + error instanceof Error ? error.message : String(error), + ); + } + }, + wait: (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)), + waitForPlaybackWindowReady: async () => { + const deadline = Date.now() + 4000; + while (Date.now() < deadline) { + const tracker = appState.windowTracker; + if (tracker && tracker.isTracking() && tracker.getGeometry()) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + logger.warn('Timed out waiting for tracked playback window before opening YouTube subtitle picker.'); + }, + waitForOverlayGeometryReady: async () => { + const deadline = Date.now() + 4000; + while (Date.now() < deadline) { + const tracker = appState.windowTracker; + const trackerGeometry = tracker?.getGeometry() ?? null; + if (trackerGeometry && geometryMatches(lastOverlayWindowGeometry, trackerGeometry)) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } + logger.warn('Timed out waiting for overlay geometry to match tracked playback window.'); + }, + focusOverlayWindow: () => { + const mainWindow = overlayManager.getMainWindow(); + if (!mainWindow || mainWindow.isDestroyed()) { + return; + } + mainWindow.setIgnoreMouseEvents(false); + if (!mainWindow.isFocused()) { + mainWindow.focus(); + } + if (!mainWindow.webContents.isFocused()) { + mainWindow.webContents.focus(); + } + }, + showMpvOsd: (text: string) => showMpvOsd(text), + warn: (message: string) => logger.warn(message), + log: (message: string) => logger.info(message), + getYoutubeOutputDir: () => path.join(os.homedir(), '.cache', 'subminer', 'youtube-subs'), +}); + +async function runYoutubePlaybackFlowMain(request: { + url: string; + mode: 'download' | 'generate'; + source: CliCommandSource; +}): Promise { + const shouldResumeWarmupsAfterFlow = appState.youtubePlaybackFlowPending; + if (process.platform === 'win32' && !appState.mpvClient?.connected) { + const launchResult = launchWindowsMpv( + [request.url], + createWindowsMpvLaunchDeps({ + showError: (title, content) => dialog.showErrorBox(title, content), + }), + [ + '--pause=yes', + '--sub-auto=no', + '--sid=no', + '--secondary-sid=no', + '--script-opts=subminer-auto_start_pause_until_ready=no', + `--input-ipc-server=${appState.mpvSocketPath}`, + ], + ); + if (!launchResult.ok) { + logger.warn('Unable to bootstrap Windows mpv for YouTube playback.'); + } + } + if (!appState.mpvClient?.connected) { + appState.mpvClient?.connect(); + } + await ensureOverlayRuntimeReady(); + try { + await youtubeFlowRuntime.runYoutubePlaybackFlow({ + url: request.url, + mode: request.mode, + }); + logger.info(`YouTube playback flow completed from ${request.source}.`); + } finally { + if (shouldResumeWarmupsAfterFlow) { + appState.youtubePlaybackFlowPending = false; + startBackgroundWarmupsIfAllowed(); + } + } +} + +async function ensureOverlayRuntimeReady(): Promise { + await ensureYomitanExtensionLoaded(); + initializeOverlayRuntime(); +} + let firstRunSetupMessage: string | null = null; const resolveWindowsMpvShortcutRuntimePaths = () => resolveWindowsMpvShortcutPaths({ @@ -1045,6 +1234,9 @@ function maybeSignalPluginAutoplayReady( payload: SubtitleData, options?: { forceWhilePaused?: boolean }, ): void { + if (appState.youtubePlaybackFlowPending) { + return; + } if (!payload.text.trim()) { return; } @@ -3064,7 +3256,7 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({ await prewarmSubtitleDictionaries(); }, startBackgroundWarmups: () => { - startBackgroundWarmups(); + startBackgroundWarmupsIfAllowed(); }, texthookerOnlyMode: appState.texthookerOnlyMode, shouldAutoInitializeOverlayRuntimeFromConfig: () => @@ -3242,6 +3434,7 @@ const { createMecabTokenizerAndCheck, prewarmSubtitleDictionaries, startBackgroundWarmups, + startTokenizationWarmups, isTokenizationWarmupReady, } = composeMpvRuntimeHandlers< MpvIpcClient, @@ -3312,6 +3505,9 @@ const { immersionMediaRuntime.syncFromCurrentMediaState(); }, signalAutoplayReadyIfWarm: () => { + if (appState.youtubePlaybackFlowPending) { + return; + } if (!isTokenizationWarmupReady()) { return; } @@ -3513,7 +3709,19 @@ const { tokenizeSubtitleDeferred = tokenizeSubtitle; function createMpvClientRuntimeService(): MpvIpcClient { - return createMpvClientRuntimeServiceHandler() as MpvIpcClient; + const client = createMpvClientRuntimeServiceHandler() as MpvIpcClient; + client.on('connection-change', ({ connected }) => { + if (connected) { + return; + } + if (!youtubeFlowRuntime.hasActiveSession()) { + return; + } + youtubeFlowRuntime.cancelActivePicker(); + broadcastToOverlayWindows(IPC_CHANNELS.event.youtubePickerCancel, null); + overlayModalRuntime.handleOverlayModalClosed('youtube-track-picker'); + }); + return client; } function resetSubtitleSidebarEmbeddedLayoutRuntime(): void { @@ -3546,6 +3754,11 @@ function getCurrentOverlayGeometry(): WindowGeometry { return getOverlayGeometryFallback(); } +function geometryMatches(a: WindowGeometry | null, b: WindowGeometry | null): boolean { + if (!a || !b) return false; + return a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height; +} + function applyOverlayRegions(geometry: WindowGeometry): void { lastOverlayWindowGeometry = geometry; overlayManager.setOverlayWindowBounds(geometry); @@ -3690,6 +3903,21 @@ function destroyTray(): void { function initializeOverlayRuntime(): void { initializeOverlayRuntimeHandler(); + initializeOverlayAnkiIntegrationCore({ + getResolvedConfig: () => getResolvedConfig(), + getSubtitleTimingTracker: () => appState.subtitleTimingTracker, + getMpvClient: () => appState.mpvClient, + getRuntimeOptionsManager: () => appState.runtimeOptionsManager, + getAnkiIntegration: () => appState.ankiIntegration, + setAnkiIntegration: (integration) => { + appState.ankiIntegration = integration as AnkiIntegration | null; + }, + showDesktopNotification, + createFieldGroupingCallback: () => createFieldGroupingCallback(), + getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'), + shouldStartAnkiIntegration: () => + !(appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)), + }); appState.ankiIntegration?.setRecordCardsMinedCallback(recordTrackedCardsMined); syncOverlayMpvSubtitleSuppression(); } @@ -4189,6 +4417,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ onOverlayModalOpened: (modal) => { overlayModalRuntime.notifyOverlayModalOpened(modal); }, + onYoutubePickerResolve: (request) => youtubeFlowRuntime.resolveActivePicker(request), openYomitanSettings: () => openYomitanSettings(), quitApp: () => requestAppQuit(), toggleVisibleOverlay: () => toggleVisibleOverlay(), @@ -4403,6 +4632,7 @@ const createCliCommandContextHandler = createCliCommandContextFactory({ runJellyfinCommand: (argsFromCommand: CliArgs) => runJellyfinCommand(argsFromCommand), runStatsCommand: (argsFromCommand: CliArgs, source: CliCommandSource) => runStatsCliCommand(argsFromCommand, source), + runYoutubePlaybackFlow: (request) => runYoutubePlaybackFlowMain(request), openYomitanSettings: () => openYomitanSettings(), cycleSecondarySubMode: () => handleCycleSecondarySubMode(), openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(), @@ -4569,7 +4799,10 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } = appState.overlayRuntimeInitialized = initialized; }, startBackgroundWarmups: () => { - if (appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)) { + if ( + (appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)) || + appState.youtubePlaybackFlowPending + ) { return; } startBackgroundWarmups(); diff --git a/src/main/runtime/youtube-flow.test.ts b/src/main/runtime/youtube-flow.test.ts new file mode 100644 index 0000000..4d8bd3c --- /dev/null +++ b/src/main/runtime/youtube-flow.test.ts @@ -0,0 +1,451 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createYoutubeFlowRuntime } from './youtube-flow'; +import type { YoutubePickerOpenPayload, YoutubeTrackOption } from '../../types'; + +const primaryTrack: YoutubeTrackOption = { + id: 'auto:ja-orig', + language: 'ja', + sourceLanguage: 'ja-orig', + kind: 'auto', + label: 'Japanese (auto)', +}; + +const secondaryTrack: YoutubeTrackOption = { + id: 'manual:en', + language: 'en', + sourceLanguage: 'en', + kind: 'manual', + label: 'English (manual)', +}; + +test('youtube flow clears internal tracks and binds external primary+secondary subtitles', async () => { + const commands: Array> = []; + const osdMessages: string[] = []; + const order: string[] = []; + const refreshedSubtitles: string[] = []; + const waits: number[] = []; + const focusOverlayCalls: string[] = []; + let pickerPayload: YoutubePickerOpenPayload | null = null; + 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 () => { + order.push('wait-window-ready'); + }, + waitForOverlayGeometryReady: async () => { + order.push('wait-overlay-geometry'); + }, + focusOverlayWindow: () => { + focusOverlayCalls.push('focus-overlay'); + }, + openPicker: async (payload) => { + assert.deepEqual(waits, [150]); + order.push('open-picker'); + pickerPayload = payload; + queueMicrotask(() => { + void runtime.resolveActivePicker({ + sessionId: payload.sessionId, + action: 'use-selected', + primaryTrackId: primaryTrack.id, + secondaryTrackId: secondaryTrack.id, + }); + }); + return true; + }, + pauseMpv: () => { + commands.push(['set_property', 'pause', 'yes']); + }, + resumeMpv: () => { + commands.push(['set_property', 'pause', 'no']); + }, + 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: 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 (ms) => { + waits.push(ms); + }, + showMpvOsd: (text) => { + osdMessages.push(text); + }, + warn: (message) => { + throw new Error(message); + }, + log: () => {}, + getYoutubeOutputDir: () => '/tmp', + }); + await runtime.runYoutubePlaybackFlow({ url: 'https://example.com', mode: 'download' }); + + assert.ok(pickerPayload); + assert.deepEqual(order, [ + 'start-tokenization-warmups', + 'wait-window-ready', + 'wait-overlay-geometry', + 'open-picker', + 'wait-tokenization-ready', + 'wait-anki-ready', + ]); + assert.deepEqual(osdMessages, [ + 'Opening YouTube video', + 'Getting subtitles...', + 'Downloading subtitles...', + 'Loading subtitles...', + 'Primary and secondary subtitles loaded.', + ]); + assert.deepEqual(commands, [ + ['set_property', 'pause', 'yes'], + ['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 can cancel active picker session', async () => { + const focusOverlayCalls: string[] = []; + const runtime = createYoutubeFlowRuntime({ + probeYoutubeTracks: async () => ({ + videoId: 'video123', + title: 'Video 123', + tracks: [primaryTrack], + }), + acquireYoutubeSubtitleTracks: async () => { + throw new Error('should not batch download after cancel'); + }, + acquireYoutubeSubtitleTrack: async () => { + throw new Error('should not download after cancel'); + }, + retimeYoutubePrimaryTrack: async ({ primaryPath }) => primaryPath, + startTokenizationWarmups: async () => {}, + waitForTokenizationReady: async () => {}, + waitForAnkiReady: async () => {}, + waitForPlaybackWindowReady: async () => {}, + waitForOverlayGeometryReady: async () => {}, + focusOverlayWindow: () => { + focusOverlayCalls.push('focus-overlay'); + }, + openPicker: async (payload) => { + queueMicrotask(() => { + assert.equal(runtime.cancelActivePicker(), true); + void runtime.resolveActivePicker({ + sessionId: payload.sessionId, + action: 'use-selected', + primaryTrackId: primaryTrack.id, + secondaryTrackId: null, + }); + }); + return true; + }, + pauseMpv: () => {}, + resumeMpv: () => {}, + sendMpvCommand: () => {}, + requestMpvProperty: async () => null, + refreshCurrentSubtitle: () => {}, + wait: async () => {}, + showMpvOsd: () => {}, + warn: () => {}, + log: () => {}, + getYoutubeOutputDir: () => '/tmp', + }); + + await runtime.runYoutubePlaybackFlow({ url: 'https://example.com', mode: 'download' }); + assert.equal(runtime.hasActiveSession(), false); + assert.equal(runtime.cancelActivePicker(), false); + assert.deepEqual(focusOverlayCalls, ['focus-overlay']); +}); + +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 () => {}, + waitForOverlayGeometryReady: async () => {}, + focusOverlayWindow: () => { + focusOverlayCalls.push('focus-overlay'); + }, + 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 '字幕です'; + } + 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(150)); + 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 () => {}, + waitForOverlayGeometryReady: async () => {}, + focusOverlayWindow: () => { + releaseOrder.push('focus-overlay'); + }, + openPicker: async (payload) => { + queueMicrotask(() => { + void runtime.resolveActivePicker({ + sessionId: payload.sessionId, + action: 'use-selected', + primaryTrackId: primaryTrack.id, + secondaryTrackId: null, + }); + }); + return true; + }, + 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', + ]); +}); diff --git a/src/main/runtime/youtube-flow.ts b/src/main/runtime/youtube-flow.ts new file mode 100644 index 0000000..9dcdd9d --- /dev/null +++ b/src/main/runtime/youtube-flow.ts @@ -0,0 +1,549 @@ +import os from 'node:os'; +import path from 'node:path'; +import type { + YoutubeFlowMode, + YoutubePickerOpenPayload, + YoutubePickerResolveRequest, + YoutubePickerResolveResult, +} from '../../types'; +import type { + YoutubeTrackOption, + YoutubeTrackProbeResult, +} from '../../core/services/youtube/track-probe'; +import { + chooseDefaultYoutubeTrackIds, + normalizeYoutubeTrackSelection, +} from '../../core/services/youtube/track-selection'; +import { + acquireYoutubeSubtitleTrack, + acquireYoutubeSubtitleTracks, +} from '../../core/services/youtube/generate'; +import { resolveSubtitleSourcePath } from './subtitle-prefetch-source'; + +type YoutubeFlowOpenPicker = (payload: YoutubePickerOpenPayload) => Promise; + +type YoutubeFlowDeps = { + probeYoutubeTracks: (url: string) => Promise; + acquireYoutubeSubtitleTrack: typeof acquireYoutubeSubtitleTrack; + acquireYoutubeSubtitleTracks: typeof acquireYoutubeSubtitleTracks; + retimeYoutubePrimaryTrack: (input: { + targetUrl: string; + primaryTrack: YoutubeTrackOption; + primaryPath: string; + secondaryTrack: YoutubeTrackOption | null; + secondaryPath: string | null; + }) => Promise; + openPicker: YoutubeFlowOpenPicker; + pauseMpv: () => void; + resumeMpv: () => void; + sendMpvCommand: (command: Array) => void; + requestMpvProperty: (name: string) => Promise; + refreshCurrentSubtitle: (text: string) => void; + startTokenizationWarmups: () => Promise; + waitForTokenizationReady: () => Promise; + waitForAnkiReady: () => Promise; + wait: (ms: number) => Promise; + waitForPlaybackWindowReady: () => Promise; + waitForOverlayGeometryReady: () => Promise; + focusOverlayWindow: () => void; + showMpvOsd: (text: string) => void; + warn: (message: string) => void; + log: (message: string) => void; + getYoutubeOutputDir: () => string; +}; + +type YoutubeFlowSession = { + sessionId: string; + resolve: (request: YoutubePickerResolveRequest) => void; + reject: (error: Error) => void; +}; + +const YOUTUBE_PICKER_SETTLE_DELAY_MS = 150; +const YOUTUBE_SECONDARY_RETRY_DELAY_MS = 350; + +function createSessionId(): string { + return `yt-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; +} + +function getTrackById(tracks: YoutubeTrackOption[], id: string | null): YoutubeTrackOption | null { + if (!id) return null; + return tracks.find((track) => track.id === id) ?? null; +} + +function normalizeOutputPath(value: string): string { + const trimmed = value.trim(); + return trimmed || path.join(os.tmpdir(), 'subminer-youtube-subs'); +} + +function createYoutubeFlowOsdProgress(showMpvOsd: (text: string) => void) { + const frames = ['|', '/', '-', '\\']; + let timer: ReturnType | null = null; + let frame = 0; + + const stop = (): void => { + if (!timer) { + return; + } + clearInterval(timer); + timer = null; + }; + + const setMessage = (message: string): void => { + stop(); + frame = 0; + showMpvOsd(message); + timer = setInterval(() => { + showMpvOsd(`${message} ${frames[frame % frames.length]}`); + frame += 1; + }, 180); + }; + + return { + setMessage, + stop, + }; +} + +function releasePlaybackGate(deps: YoutubeFlowDeps): void { + deps.sendMpvCommand(['script-message', 'subminer-autoplay-ready']); + deps.resumeMpv(); +} + +function restoreOverlayInputFocus(deps: YoutubeFlowDeps): void { + deps.focusOverlayWindow(); +} + +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 normalizeTrackListEntry(track: Record): { + id: number | null; + lang: string; + title: string; + external: boolean; + externalFilename: string | null; +} { + const externalFilenameRaw = + typeof track['external-filename'] === 'string' + ? track['external-filename'] + : typeof track.external_filename === 'string' + ? track.external_filename + : ''; + const externalFilename = externalFilenameRaw.trim() + ? resolveSubtitleSourcePath(externalFilenameRaw.trim()) + : null; + return { + id: parseTrackId(track.id), + lang: String(track.lang || '').trim(), + title: String(track.title || '').trim(), + external: track.external === true, + externalFilename, + }; +} + +function matchExternalTrackId( + trackListRaw: unknown, + filePath: string, + trackOption: YoutubeTrackOption, + excludeId: number | null = null, +): number | null { + if (!Array.isArray(trackListRaw)) { + return null; + } + + const normalizedFilePath = resolveSubtitleSourcePath(filePath); + const basename = path.basename(normalizedFilePath); + const externalTracks = 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 exactPathMatch = externalTracks.find( + (track) => track.externalFilename === normalizedFilePath, + ); + if (exactPathMatch?.id !== null && exactPathMatch?.id !== undefined) { + return exactPathMatch.id; + } + + const basenameMatch = externalTracks.find( + (track) => track.externalFilename && path.basename(track.externalFilename) === basename, + ); + if (basenameMatch?.id !== null && basenameMatch?.id !== undefined) { + 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; + } + + return null; +} + +async function injectDownloadedSubtitles( + deps: YoutubeFlowDeps, + primaryTrack: YoutubeTrackOption, + primaryPath: string, + secondaryTrack: YoutubeTrackOption | null, + secondaryPath: string | 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) { + deps.sendMpvCommand([ + 'sub-add', + secondaryPath, + 'cached', + path.basename(secondaryPath), + secondaryTrack.sourceLanguage, + ]); + } + + let trackListRaw: unknown = null; + let primaryTrackId: number | null = null; + let secondaryTrackId: number | null = 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)) { + break; + } + } + + if (primaryTrackId !== null) { + deps.sendMpvCommand(['set_property', 'sid', primaryTrackId]); + } else { + deps.warn( + `Unable to bind downloaded primary subtitle track in mpv: ${path.basename(primaryPath)}`, + ); + } + if (secondaryPath && secondaryTrack) { + if (secondaryTrackId !== null) { + deps.sendMpvCommand(['set_property', 'secondary-sid', secondaryTrackId]); + } else { + deps.warn( + `Unable to bind downloaded secondary subtitle track in mpv: ${path.basename(secondaryPath)}`, + ); + } + } + + const currentSubText = await deps.requestMpvProperty('sub-text'); + if (typeof currentSubText === 'string' && currentSubText.trim().length > 0) { + deps.refreshCurrentSubtitle(currentSubText); + } + + deps.showMpvOsd( + secondaryPath && secondaryTrack ? 'Primary and secondary subtitles loaded.' : 'Subtitles loaded.', + ); + return typeof currentSubText === 'string' && currentSubText.trim().length > 0; +} + +export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) { + let activeSession: YoutubeFlowSession | null = null; + + const acquireSelectedTracks = async (input: { + targetUrl: string; + outputDir: string; + primaryTrack: YoutubeTrackOption; + secondaryTrack: YoutubeTrackOption | null; + mode: YoutubeFlowMode; + secondaryFailureLabel: string; + }): Promise<{ primaryPath: string; secondaryPath: string | null }> => { + if (!input.secondaryTrack) { + const primaryPath = ( + await deps.acquireYoutubeSubtitleTrack({ + targetUrl: input.targetUrl, + outputDir: input.outputDir, + track: input.primaryTrack, + mode: input.mode, + }) + ).path; + return { primaryPath, secondaryPath: null }; + } + + try { + const batchResult = await deps.acquireYoutubeSubtitleTracks({ + 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; + if (primaryPath) { + if (secondaryPath) { + return { primaryPath, secondaryPath }; + } + + deps.log( + `${ + input.secondaryFailureLabel + }: No subtitle file was downloaded for ${input.secondaryTrack.sourceLanguage}; retrying secondary separately after delay.`, + ); + await deps.wait(YOUTUBE_SECONDARY_RETRY_DELAY_MS); + try { + const retriedSecondaryPath = ( + await deps.acquireYoutubeSubtitleTrack({ + targetUrl: input.targetUrl, + outputDir: input.outputDir, + track: input.secondaryTrack, + mode: input.mode, + }) + ).path; + return { primaryPath, secondaryPath: retriedSecondaryPath }; + } catch (error) { + deps.warn( + `${input.secondaryFailureLabel}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + return { primaryPath, secondaryPath: null }; + } + } + } catch { + // fall through to primary-only recovery + } + + try { + const primaryPath = ( + await deps.acquireYoutubeSubtitleTrack({ + targetUrl: input.targetUrl, + outputDir: input.outputDir, + track: input.primaryTrack, + mode: input.mode, + }) + ).path; + return { primaryPath, secondaryPath: null }; + } catch (error) { + throw error; + } + }; + + const resolveActivePicker = async ( + request: YoutubePickerResolveRequest, + ): Promise => { + if (!activeSession || activeSession.sessionId !== request.sessionId) { + return { ok: false, message: 'No active YouTube subtitle picker session.' }; + } + activeSession.resolve(request); + return { ok: true, message: 'Picker selection accepted.' }; + }; + + const cancelActivePicker = (): boolean => { + if (!activeSession) { + return false; + } + activeSession.resolve({ + sessionId: activeSession.sessionId, + action: 'continue-without-subtitles', + primaryTrackId: null, + secondaryTrackId: null, + }); + return true; + }; + + const createPickerSelectionPromise = (sessionId: string): Promise => + new Promise((resolve, reject) => { + activeSession = { sessionId, resolve, reject }; + }).finally(() => { + activeSession = null; + }); + + async function runYoutubePlaybackFlow(input: { + url: string; + mode: YoutubeFlowMode; + }): Promise { + deps.showMpvOsd('Opening YouTube video'); + const tokenizationWarmupPromise = deps.startTokenizationWarmups().catch((error) => { + deps.warn( + `Failed to warm subtitle tokenization prerequisites: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + }); + const probe = await deps.probeYoutubeTracks(input.url); + const defaults = chooseDefaultYoutubeTrackIds(probe.tracks); + const sessionId = createSessionId(); + const outputDir = normalizeOutputPath(deps.getYoutubeOutputDir()); + + deps.pauseMpv(); + + const openPayload: YoutubePickerOpenPayload = { + sessionId, + url: input.url, + mode: input.mode, + tracks: probe.tracks, + defaultPrimaryTrackId: defaults.primaryTrackId, + defaultSecondaryTrackId: defaults.secondaryTrackId, + hasTracks: probe.tracks.length > 0, + }; + + if (input.mode === 'download') { + await deps.waitForPlaybackWindowReady(); + await deps.waitForOverlayGeometryReady(); + await deps.wait(YOUTUBE_PICKER_SETTLE_DELAY_MS); + deps.showMpvOsd('Getting subtitles...'); + const pickerSelection = createPickerSelectionPromise(sessionId); + void pickerSelection.catch(() => undefined); + const opened = await deps.openPicker(openPayload); + if (!opened) { + activeSession?.reject(new Error('Unable to open YouTube subtitle picker.')); + activeSession = null; + deps.warn('Unable to open YouTube subtitle picker; continuing without subtitles.'); + releasePlaybackGate(deps); + restoreOverlayInputFocus(deps); + return; + } + + const request = await pickerSelection; + if (request.action === 'continue-without-subtitles') { + releasePlaybackGate(deps); + restoreOverlayInputFocus(deps); + return; + } + const osdProgress = createYoutubeFlowOsdProgress(deps.showMpvOsd); + osdProgress.setMessage('Downloading subtitles...'); + try { + const primaryTrack = getTrackById(probe.tracks, request.primaryTrackId); + if (!primaryTrack) { + deps.warn('No primary YouTube subtitle track selected; continuing without subtitles.'); + return; + } + + const selected = normalizeYoutubeTrackSelection({ + primaryTrackId: primaryTrack.id, + secondaryTrackId: request.secondaryTrackId, + }); + const secondaryTrack = getTrackById(probe.tracks, selected.secondaryTrackId); + + const acquired = await acquireSelectedTracks({ + targetUrl: input.url, + outputDir, + primaryTrack, + secondaryTrack, + mode: input.mode, + secondaryFailureLabel: 'Failed to download secondary YouTube subtitle track', + }); + const resolvedPrimaryPath = await deps.retimeYoutubePrimaryTrack({ + targetUrl: input.url, + primaryTrack, + primaryPath: acquired.primaryPath, + secondaryTrack, + secondaryPath: acquired.secondaryPath, + }); + osdProgress.setMessage('Loading subtitles...'); + const refreshedActiveSubtitle = await injectDownloadedSubtitles( + deps, + primaryTrack, + resolvedPrimaryPath, + secondaryTrack, + acquired.secondaryPath, + ); + await tokenizationWarmupPromise; + if (refreshedActiveSubtitle) { + await deps.waitForTokenizationReady(); + } + await deps.waitForAnkiReady(); + } catch (error) { + deps.warn( + `Failed to download primary YouTube subtitle track: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } finally { + osdProgress.stop(); + releasePlaybackGate(deps); + restoreOverlayInputFocus(deps); + } + return; + } + + const primaryTrack = getTrackById(probe.tracks, defaults.primaryTrackId); + const secondaryTrack = getTrackById(probe.tracks, defaults.secondaryTrackId); + if (!primaryTrack) { + deps.showMpvOsd('No usable YouTube subtitles found.'); + releasePlaybackGate(deps); + restoreOverlayInputFocus(deps); + return; + } + + try { + deps.showMpvOsd('Getting subtitles...'); + const acquired = await acquireSelectedTracks({ + targetUrl: input.url, + outputDir, + primaryTrack, + secondaryTrack, + mode: input.mode, + secondaryFailureLabel: 'Failed to generate secondary YouTube subtitle track', + }); + const resolvedPrimaryPath = await deps.retimeYoutubePrimaryTrack({ + targetUrl: input.url, + primaryTrack, + primaryPath: acquired.primaryPath, + secondaryTrack, + secondaryPath: acquired.secondaryPath, + }); + deps.showMpvOsd('Loading subtitles...'); + const refreshedActiveSubtitle = await injectDownloadedSubtitles( + deps, + primaryTrack, + resolvedPrimaryPath, + secondaryTrack, + acquired.secondaryPath, + ); + await tokenizationWarmupPromise; + if (refreshedActiveSubtitle) { + await deps.waitForTokenizationReady(); + } + await deps.waitForAnkiReady(); + } catch (error) { + deps.warn( + `Failed to generate primary YouTube subtitle track: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } finally { + releasePlaybackGate(deps); + restoreOverlayInputFocus(deps); + } + } + + return { + runYoutubePlaybackFlow, + resolveActivePicker, + cancelActivePicker, + hasActiveSession: () => Boolean(activeSession), + }; +} diff --git a/src/renderer/handlers/keyboard.test.ts b/src/renderer/handlers/keyboard.test.ts index 2104eef..dfeb8f7 100644 --- a/src/renderer/handlers/keyboard.test.ts +++ b/src/renderer/handlers/keyboard.test.ts @@ -332,6 +332,7 @@ function createKeyboardHandlerHarness() { return true; }, handleControllerDebugKeydown: () => false, + handleYoutubePickerKeydown: () => false, handleSessionHelpKeydown: () => false, openSessionHelpModal: () => {}, appendClipboardVideoToQueue: () => {}, @@ -489,6 +490,34 @@ test('keyboard mode: repeated popup navigation keys are forwarded while popup is } }); +test('popup-visible mpv keybindings still fire for bound keys', async () => { + const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness(); + + try { + await handlers.setupMpvInputForwarding(); + handlers.updateKeybindings([ + { + key: 'Space', + command: ['cycle', 'pause'], + }, + { + key: 'KeyQ', + command: ['quit'], + }, + ] as never); + + ctx.state.yomitanPopupVisible = true; + testGlobals.setPopupVisible(true); + + testGlobals.dispatchKeydown({ key: ' ', code: 'Space' }); + testGlobals.dispatchKeydown({ key: 'q', code: 'KeyQ' }); + + assert.deepEqual(testGlobals.mpvCommands.slice(-2), [['cycle', 'pause'], ['quit']]); + } finally { + testGlobals.restore(); + } +}); + test('keyboard mode: controller helpers dispatch popup audio play/cycle and scroll bridge commands', async () => { const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness(); @@ -817,6 +846,22 @@ test('keyboard mode: closing lookup clears yomitan active text source so same to } }); +test('subtitle refresh outside keyboard mode clears yomitan active text source', async () => { + const { handlers, testGlobals } = createKeyboardHandlerHarness(); + + try { + handlers.handleSubtitleContentUpdated(); + await wait(0); + + const clearCommands = testGlobals.commandEvents.filter( + (event) => event.type === 'clearActiveTextSource', + ); + assert.deepEqual(clearCommands, [{ type: 'clearActiveTextSource' }]); + } finally { + testGlobals.restore(); + } +}); + test('keyboard mode: lookup toggle closes popup when DOM visibility is the source of truth', async () => { const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness(); diff --git a/src/renderer/handlers/keyboard.ts b/src/renderer/handlers/keyboard.ts index 0afb5ae..ac8bef6 100644 --- a/src/renderer/handlers/keyboard.ts +++ b/src/renderer/handlers/keyboard.ts @@ -15,6 +15,7 @@ export function createKeyboardHandlers( handleSubsyncKeydown: (e: KeyboardEvent) => boolean; handleKikuKeydown: (e: KeyboardEvent) => boolean; handleJimakuKeydown: (e: KeyboardEvent) => boolean; + handleYoutubePickerKeydown: (e: KeyboardEvent) => boolean; handleControllerSelectKeydown: (e: KeyboardEvent) => boolean; handleControllerDebugKeydown: (e: KeyboardEvent) => boolean; handleSessionHelpKeydown: (e: KeyboardEvent) => boolean; @@ -479,6 +480,8 @@ export function createKeyboardHandlers( function handleSubtitleContentUpdated(): void { if (!ctx.state.keyboardDrivenModeEnabled) { + dispatchYomitanFrontendClearActiveTextSource(); + clearNativeSubtitleSelection(); return; } if (pendingSelectionAnchorAfterSubtitleSeek) { @@ -678,6 +681,11 @@ export function createKeyboardHandlers( ]); if (modifierOnlyCodes.has(e.code)) return false; + const keyString = keyEventToString(e); + if (ctx.state.keybindingsMap.has(keyString)) { + return false; + } + if (!e.ctrlKey && !e.metaKey && !e.altKey && e.code === 'KeyM') { if (e.repeat) return false; dispatchYomitanPopupMineSelected(); @@ -834,6 +842,10 @@ export function createKeyboardHandlers( options.handleJimakuKeydown(e); return; } + if (ctx.state.youtubePickerModalOpen) { + options.handleYoutubePickerKeydown(e); + return; + } if (ctx.state.controllerSelectModalOpen) { options.handleControllerSelectKeydown(e); return; @@ -871,8 +883,8 @@ export function createKeyboardHandlers( ) { if (handleYomitanPopupKeybind(e)) { e.preventDefault(); + return; } - return; } if (ctx.state.keyboardDrivenModeEnabled && handleKeyboardDrivenModeNavigation(e)) { diff --git a/src/renderer/handlers/mouse.test.ts b/src/renderer/handlers/mouse.test.ts index 1966a08..94ae796 100644 --- a/src/renderer/handlers/mouse.test.ts +++ b/src/renderer/handlers/mouse.test.ts @@ -3,7 +3,10 @@ import test from 'node:test'; import type { SubtitleSidebarConfig } from '../../types'; import { createMouseHandlers } from './mouse.js'; -import { YOMITAN_POPUP_HIDDEN_EVENT, YOMITAN_POPUP_SHOWN_EVENT } from '../yomitan-popup.js'; +import { + YOMITAN_POPUP_HIDDEN_EVENT, + YOMITAN_POPUP_SHOWN_EVENT, +} from '../yomitan-popup.js'; function createClassList() { const classes = new Set(); @@ -612,3 +615,153 @@ test('popup open pauses and popup close resumes when yomitan popup auto-pause is Object.defineProperty(globalThis, 'Node', { configurable: true, value: previousNode }); } }); + +test('restorePointerInteractionState re-enables subtitle hover when pointer is already over subtitles', () => { + const ctx = createMouseTestContext(); + const originalWindow = globalThis.window; + const originalDocument = globalThis.document; + const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = []; + const documentListeners = new Map void>>(); + ctx.platform.shouldToggleMouseIgnore = true; + + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { + electronAPI: { + setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => { + ignoreCalls.push({ ignore, forward: options?.forward }); + }, + }, + getComputedStyle: () => ({ + visibility: 'hidden', + display: 'none', + opacity: '0', + }), + focus: () => {}, + }, + }); + Object.defineProperty(globalThis, 'document', { + configurable: true, + value: { + addEventListener: (type: string, listener: (event: unknown) => void) => { + const bucket = documentListeners.get(type) ?? []; + bucket.push(listener); + documentListeners.set(type, bucket); + }, + elementFromPoint: () => ctx.dom.subtitleContainer, + querySelectorAll: () => [], + body: {}, + }, + }); + + try { + const handlers = createMouseHandlers(ctx as never, { + modalStateReader: { + isAnySettingsModalOpen: () => false, + isAnyModalOpen: () => false, + }, + applyYPercent: () => {}, + getCurrentYPercent: () => 10, + persistSubtitlePositionPatch: () => {}, + getSubtitleHoverAutoPauseEnabled: () => false, + getYomitanPopupAutoPauseEnabled: () => false, + getPlaybackPaused: async () => false, + sendMpvCommand: () => {}, + }); + + handlers.setupPointerTracking(); + for (const listener of documentListeners.get('mousemove') ?? []) { + listener({ clientX: 120, clientY: 240 }); + } + handlers.restorePointerInteractionState(); + + assert.equal(ctx.state.isOverSubtitle, true); + assert.equal(ctx.dom.overlay.classList.contains('interactive'), true); + assert.deepEqual(ignoreCalls, [{ ignore: false, forward: undefined }]); + } finally { + Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow }); + Object.defineProperty(globalThis, 'document', { configurable: true, value: originalDocument }); + } +}); + +test('restorePointerInteractionState keeps overlay interactive until first real pointer move can resync hover', () => { + const ctx = createMouseTestContext(); + const originalWindow = globalThis.window; + const originalDocument = globalThis.document; + const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = []; + const documentListeners = new Map void>>(); + let hoveredElement: unknown = null; + ctx.platform.shouldToggleMouseIgnore = true; + + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { + electronAPI: { + setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => { + ignoreCalls.push({ ignore, forward: options?.forward }); + }, + }, + getComputedStyle: () => ({ + visibility: 'hidden', + display: 'none', + opacity: '0', + }), + focus: () => {}, + }, + }); + Object.defineProperty(globalThis, 'document', { + configurable: true, + value: { + addEventListener: (type: string, listener: (event: unknown) => void) => { + const bucket = documentListeners.get(type) ?? []; + bucket.push(listener); + documentListeners.set(type, bucket); + }, + elementFromPoint: () => hoveredElement, + querySelectorAll: () => [], + body: {}, + }, + }); + + try { + const handlers = createMouseHandlers(ctx as never, { + modalStateReader: { + isAnySettingsModalOpen: () => false, + isAnyModalOpen: () => false, + }, + applyYPercent: () => {}, + getCurrentYPercent: () => 10, + persistSubtitlePositionPatch: () => {}, + getSubtitleHoverAutoPauseEnabled: () => false, + getYomitanPopupAutoPauseEnabled: () => false, + getPlaybackPaused: async () => false, + sendMpvCommand: () => {}, + }); + + handlers.setupPointerTracking(); + handlers.restorePointerInteractionState(); + + assert.equal(ctx.state.isOverSubtitle, false); + assert.equal(ctx.dom.overlay.classList.contains('interactive'), true); + assert.deepEqual(ignoreCalls, [ + { ignore: true, forward: true }, + { ignore: false, forward: undefined }, + ]); + + hoveredElement = null; + for (const listener of documentListeners.get('mousemove') ?? []) { + listener({ clientX: 24, clientY: 48 }); + } + + assert.equal(ctx.state.isOverSubtitle, false); + assert.equal(ctx.dom.overlay.classList.contains('interactive'), false); + assert.deepEqual(ignoreCalls, [ + { ignore: true, forward: true }, + { ignore: false, forward: undefined }, + { ignore: true, forward: true }, + ]); + } finally { + Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow }); + Object.defineProperty(globalThis, 'document', { configurable: true, value: originalDocument }); + } +}); diff --git a/src/renderer/handlers/mouse.ts b/src/renderer/handlers/mouse.ts index 43462bf..5f44d9c 100644 --- a/src/renderer/handlers/mouse.ts +++ b/src/renderer/handlers/mouse.ts @@ -25,6 +25,73 @@ export function createMouseHandlers( let popupPauseRequestId = 0; let pausedBySubtitleHover = false; let pausedByYomitanPopup = false; + let lastPointerPosition: { clientX: number; clientY: number } | null = null; + let pendingPointerResync = false; + + function isElementWithinContainer(element: Element | null, container: HTMLElement): boolean { + if (!element) { + return false; + } + if (element === container) { + return true; + } + return typeof container.contains === 'function' ? container.contains(element) : false; + } + + function updatePointerPosition(event: MouseEvent | PointerEvent): void { + lastPointerPosition = { + clientX: event.clientX, + clientY: event.clientY, + }; + } + + function syncHoverStateFromPoint(clientX: number, clientY: number): boolean { + const hoveredElement = + typeof document.elementFromPoint === 'function' + ? document.elementFromPoint(clientX, clientY) + : null; + const overPrimarySubtitle = isElementWithinContainer(hoveredElement, ctx.dom.subtitleContainer); + const overSecondarySubtitle = isElementWithinContainer( + hoveredElement, + ctx.dom.secondarySubContainer, + ); + + ctx.state.isOverSubtitle = overPrimarySubtitle || overSecondarySubtitle; + if (!overSecondarySubtitle) { + ctx.dom.secondarySubContainer.classList.remove('secondary-sub-hover-active'); + } + + return ctx.state.isOverSubtitle; + } + + function restorePointerInteractionState(): void { + const pointerPosition = lastPointerPosition; + pendingPointerResync = false; + if (pointerPosition) { + syncHoverStateFromPoint(pointerPosition.clientX, pointerPosition.clientY); + } else { + ctx.state.isOverSubtitle = false; + ctx.dom.secondarySubContainer.classList.remove('secondary-sub-hover-active'); + } + syncOverlayMouseIgnoreState(ctx); + + if (!ctx.platform.shouldToggleMouseIgnore || ctx.state.isOverSubtitle) { + return; + } + + pendingPointerResync = true; + ctx.dom.overlay.classList.add('interactive'); + window.electronAPI.setIgnoreMouseEvents(false); + } + + function maybeResyncPointerHoverState(event: MouseEvent | PointerEvent): void { + if (!pendingPointerResync) { + return; + } + pendingPointerResync = false; + syncHoverStateFromPoint(event.clientX, event.clientY); + syncOverlayMouseIgnoreState(ctx); + } function isWithinOtherSubtitleContainer( relatedTarget: EventTarget | null, @@ -192,6 +259,7 @@ export function createMouseHandlers( }); document.addEventListener('mousemove', (e: MouseEvent) => { + updatePointerPosition(e); if (!ctx.state.isDragging) return; const deltaY = ctx.state.dragStartY - e.clientY; @@ -222,6 +290,17 @@ export function createMouseHandlers( }); } + function setupPointerTracking(): void { + document.addEventListener('mousemove', (event: MouseEvent) => { + updatePointerPosition(event); + maybeResyncPointerHoverState(event); + }); + document.addEventListener('pointermove', (event: PointerEvent) => { + updatePointerPosition(event); + maybeResyncPointerHoverState(event); + }); + } + function setupSelectionObserver(): void { document.addEventListener('selectionchange', () => { const selection = window.getSelection(); @@ -283,7 +362,9 @@ export function createMouseHandlers( handleSecondaryMouseLeave: (event?: MouseEvent) => handleMouseLeave(event, true), handleMouseEnter, handleMouseLeave, + restorePointerInteractionState, setupDragging, + setupPointerTracking, setupResizeHandler, setupSelectionObserver, setupYomitanObserver, diff --git a/src/renderer/modals/youtube-track-picker.test.ts b/src/renderer/modals/youtube-track-picker.test.ts new file mode 100644 index 0000000..0a022a2 --- /dev/null +++ b/src/renderer/modals/youtube-track-picker.test.ts @@ -0,0 +1,174 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createRendererState } from '../state.js'; +import { createYoutubeTrackPickerModal } from './youtube-track-picker.js'; + +function createClassList(initialTokens: string[] = []) { + const tokens = new Set(initialTokens); + return { + add: (...entries: string[]) => { + for (const entry of entries) tokens.add(entry); + }, + remove: (...entries: string[]) => { + for (const entry of entries) tokens.delete(entry); + }, + contains: (entry: string) => tokens.has(entry), + }; +} + +function createFakeElement() { + const attributes = new Map(); + return { + textContent: '', + innerHTML: '', + value: '', + disabled: false, + children: [] as any[], + style: {} as Record, + classList: createClassList(['hidden']), + listeners: new Map void>>(), + appendChild(child: any) { + this.children.push(child); + return child; + }, + append(...children: any[]) { + this.children.push(...children); + }, + addEventListener(type: string, listener: (event?: any) => void) { + const existing = this.listeners.get(type) ?? []; + existing.push(listener); + this.listeners.set(type, existing); + }, + setAttribute(name: string, value: string) { + attributes.set(name, value); + }, + getAttribute(name: string) { + return attributes.get(name) ?? null; + }, + focus: () => {}, + }; +} + +test('youtube track picker close restores focus and mouse-ignore state', () => { + const overlayFocusCalls: number[] = []; + const windowFocusCalls: number[] = []; + const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = []; + const notifications: string[] = []; + const frontendCommands: unknown[] = []; + const syncCalls: string[] = []; + const originalWindow = globalThis.window; + const originalDocument = globalThis.document; + const originalCustomEvent = globalThis.CustomEvent; + + class TestCustomEvent extends Event { + detail: unknown; + + constructor(type: string, init?: { detail?: unknown }) { + super(type); + this.detail = init?.detail; + } + } + + Object.defineProperty(globalThis, 'document', { + configurable: true, + value: { + createElement: () => createFakeElement(), + }, + }); + + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { + dispatchEvent: (event: Event & { detail?: unknown }) => { + frontendCommands.push(event.detail ?? null); + return true; + }, + focus: () => { + windowFocusCalls.push(1); + }, + electronAPI: { + notifyOverlayModalOpened: () => {}, + notifyOverlayModalClosed: (modal: string) => { + notifications.push(modal); + }, + youtubePickerResolve: async () => ({ ok: true, message: '' }), + setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => { + ignoreCalls.push({ ignore, forward: options?.forward }); + }, + }, + }, + }); + + Object.defineProperty(globalThis, 'CustomEvent', { + configurable: true, + value: TestCustomEvent, + }); + + try { + const state = createRendererState(); + const overlay = { + classList: createClassList(), + focus: () => { + overlayFocusCalls.push(1); + }, + }; + const dom = { + overlay, + youtubePickerModal: createFakeElement(), + youtubePickerTitle: createFakeElement(), + youtubePickerPrimarySelect: createFakeElement(), + youtubePickerSecondarySelect: createFakeElement(), + youtubePickerTracks: createFakeElement(), + youtubePickerStatus: createFakeElement(), + youtubePickerContinueButton: createFakeElement(), + youtubePickerCloseButton: createFakeElement(), + }; + + const modal = createYoutubeTrackPickerModal( + { + state, + dom, + platform: { + shouldToggleMouseIgnore: true, + }, + } as never, + { + modalStateReader: { isAnyModalOpen: () => false }, + restorePointerInteractionState: () => { + syncCalls.push('restore-pointer'); + }, + syncSettingsModalSubtitleSuppression: () => { + syncCalls.push('sync'); + }, + }, + ); + + modal.openYoutubePickerModal({ + sessionId: 'yt-1', + url: 'https://example.com', + mode: 'download', + tracks: [], + defaultPrimaryTrackId: null, + defaultSecondaryTrackId: null, + hasTracks: false, + }); + modal.closeYoutubePickerModal(); + + assert.equal(state.youtubePickerModalOpen, false); + assert.deepEqual(syncCalls, ['sync', 'sync', 'restore-pointer']); + assert.deepEqual(notifications, ['youtube-track-picker']); + assert.deepEqual(frontendCommands, [{ type: 'refreshOptions' }]); + assert.equal(overlay.classList.contains('interactive'), false); + assert.equal(overlayFocusCalls.length > 0, true); + assert.equal(windowFocusCalls.length > 0, true); + assert.deepEqual(ignoreCalls, [{ ignore: true, forward: true }]); + } finally { + Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow }); + Object.defineProperty(globalThis, 'document', { configurable: true, value: originalDocument }); + Object.defineProperty(globalThis, 'CustomEvent', { + configurable: true, + value: originalCustomEvent, + }); + } +}); diff --git a/src/renderer/modals/youtube-track-picker.ts b/src/renderer/modals/youtube-track-picker.ts new file mode 100644 index 0000000..0a5d799 --- /dev/null +++ b/src/renderer/modals/youtube-track-picker.ts @@ -0,0 +1,235 @@ +import type { YoutubePickerOpenPayload } from '../../types'; +import type { ModalStateReader, RendererContext } from '../context'; +import { YOMITAN_POPUP_COMMAND_EVENT } from '../yomitan-popup.js'; + +function createOption(value: string, label: string): HTMLOptionElement { + const option = document.createElement('option'); + option.value = value; + option.textContent = label; + return option; +} + +export function createYoutubeTrackPickerModal( + ctx: RendererContext, + options: { + modalStateReader: Pick; + restorePointerInteractionState: () => void; + syncSettingsModalSubtitleSuppression: () => void; + }, +) { + function setStatus(message: string, isError = false): void { + ctx.state.youtubePickerStatus = message; + ctx.dom.youtubePickerStatus.textContent = message; + ctx.dom.youtubePickerStatus.style.color = isError + ? '#ed8796' + : '#a5adcb'; + } + + function getTrackLabel(trackId: string): string { + return ctx.state.youtubePickerPayload?.tracks.find((track) => track.id === trackId)?.label ?? ''; + } + + function renderTrackList(): void { + ctx.dom.youtubePickerTracks.innerHTML = ''; + const payload = ctx.state.youtubePickerPayload; + if (!payload || payload.tracks.length === 0) { + const li = document.createElement('li'); + li.innerHTML = 'No subtitle tracks foundContinue without subtitles'; + ctx.dom.youtubePickerTracks.appendChild(li); + return; + } + + for (const track of payload.tracks) { + const li = document.createElement('li'); + const left = document.createElement('span'); + left.textContent = track.label; + const right = document.createElement('span'); + right.className = 'youtube-picker-track-meta'; + right.textContent = `${track.kind} · ${track.language}`; + li.append(left, right); + ctx.dom.youtubePickerTracks.appendChild(li); + } + } + + function syncSecondaryOptions(): void { + const payload = ctx.state.youtubePickerPayload; + const primaryTrackId = ctx.dom.youtubePickerPrimarySelect.value || null; + ctx.dom.youtubePickerSecondarySelect.innerHTML = ''; + ctx.dom.youtubePickerSecondarySelect.appendChild(createOption('', 'None')); + if (!payload) return; + + for (const track of payload.tracks) { + if (track.id === primaryTrackId) continue; + ctx.dom.youtubePickerSecondarySelect.appendChild(createOption(track.id, track.label)); + } + if ( + primaryTrackId && + ctx.dom.youtubePickerSecondarySelect.value === primaryTrackId + ) { + ctx.dom.youtubePickerSecondarySelect.value = ''; + } + } + + function setSelection(primaryTrackId: string | null, secondaryTrackId: string | null): void { + ctx.state.youtubePickerPrimaryTrackId = primaryTrackId; + ctx.state.youtubePickerSecondaryTrackId = secondaryTrackId; + ctx.dom.youtubePickerPrimarySelect.value = primaryTrackId ?? ''; + syncSecondaryOptions(); + ctx.dom.youtubePickerSecondarySelect.value = secondaryTrackId ?? ''; + } + + function applyPayload(payload: YoutubePickerOpenPayload): void { + ctx.state.youtubePickerPayload = payload; + ctx.dom.youtubePickerTitle.textContent = `${payload.mode === 'generate' ? 'Generate' : 'Download'} subtitles for ${payload.url}`; + ctx.dom.youtubePickerPrimarySelect.innerHTML = ''; + ctx.dom.youtubePickerSecondarySelect.innerHTML = ''; + + if (payload.tracks.length === 0) { + ctx.dom.youtubePickerPrimarySelect.appendChild(createOption('', 'No tracks available')); + ctx.dom.youtubePickerPrimarySelect.disabled = true; + ctx.dom.youtubePickerSecondarySelect.disabled = true; + ctx.dom.youtubePickerContinueButton.textContent = 'Continue without subtitles'; + setSelection(null, null); + setStatus('No subtitle tracks were found. Playback will continue without subtitles.'); + renderTrackList(); + return; + } + + ctx.dom.youtubePickerPrimarySelect.disabled = false; + ctx.dom.youtubePickerSecondarySelect.disabled = false; + ctx.dom.youtubePickerContinueButton.textContent = 'Use selected subtitles'; + for (const track of payload.tracks) { + ctx.dom.youtubePickerPrimarySelect.appendChild(createOption(track.id, track.label)); + } + setSelection(payload.defaultPrimaryTrackId, payload.defaultSecondaryTrackId); + renderTrackList(); + setStatus('Select the subtitle tracks to download.'); + } + + async function resolveSelection(action: 'use-selected' | 'continue-without-subtitles'): Promise { + const payload = ctx.state.youtubePickerPayload; + if (!payload) return; + if (action === 'use-selected' && payload.hasTracks && !ctx.dom.youtubePickerPrimarySelect.value) { + setStatus('Primary subtitle selection is required.', true); + return; + } + + const response = await window.electronAPI.youtubePickerResolve({ + sessionId: payload.sessionId, + action, + primaryTrackId: action === 'use-selected' ? ctx.dom.youtubePickerPrimarySelect.value || null : null, + secondaryTrackId: + action === 'use-selected' ? ctx.dom.youtubePickerSecondarySelect.value || null : null, + }); + if (!response.ok) { + setStatus(response.message, true); + return; + } + closeYoutubePickerModal(); + } + + function openYoutubePickerModal(payload: YoutubePickerOpenPayload): void { + if (ctx.state.youtubePickerModalOpen) return; + ctx.state.youtubePickerModalOpen = true; + options.syncSettingsModalSubtitleSuppression(); + applyPayload(payload); + ctx.dom.overlay.classList.add('interactive'); + ctx.dom.youtubePickerModal.classList.remove('hidden'); + ctx.dom.youtubePickerModal.setAttribute('aria-hidden', 'false'); + window.electronAPI.notifyOverlayModalOpened('youtube-track-picker'); + } + + function closeYoutubePickerModal(): void { + if (!ctx.state.youtubePickerModalOpen) return; + ctx.state.youtubePickerModalOpen = false; + options.syncSettingsModalSubtitleSuppression(); + ctx.state.youtubePickerPayload = null; + ctx.state.youtubePickerPrimaryTrackId = null; + ctx.state.youtubePickerSecondaryTrackId = null; + ctx.state.youtubePickerStatus = ''; + ctx.dom.youtubePickerModal.classList.add('hidden'); + ctx.dom.youtubePickerModal.setAttribute('aria-hidden', 'true'); + window.electronAPI.notifyOverlayModalClosed('youtube-track-picker'); + window.dispatchEvent( + new CustomEvent(YOMITAN_POPUP_COMMAND_EVENT, { + detail: { + type: 'refreshOptions', + }, + }), + ); + if (!options.modalStateReader.isAnyModalOpen()) { + ctx.dom.overlay.classList.remove('interactive'); + } + options.restorePointerInteractionState(); + if (typeof ctx.dom.overlay.focus === 'function') { + ctx.dom.overlay.focus({ preventScroll: true }); + } + if (ctx.platform.shouldToggleMouseIgnore) { + if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) { + window.electronAPI.setIgnoreMouseEvents(true, { forward: true }); + } else { + window.electronAPI.setIgnoreMouseEvents(false); + } + } + window.focus(); + } + + function handleYoutubePickerKeydown(e: KeyboardEvent): boolean { + if (!ctx.state.youtubePickerModalOpen) return false; + + if (e.key === 'Escape') { + e.preventDefault(); + void resolveSelection('continue-without-subtitles'); + return true; + } + + if (e.key === 'Enter') { + e.preventDefault(); + void resolveSelection( + ctx.state.youtubePickerPayload?.hasTracks ? 'use-selected' : 'continue-without-subtitles', + ); + return true; + } + + return true; + } + + function wireDomEvents(): void { + ctx.dom.youtubePickerPrimarySelect.addEventListener('change', () => { + const primaryTrackId = ctx.dom.youtubePickerPrimarySelect.value || null; + if (ctx.dom.youtubePickerSecondarySelect.value === primaryTrackId) { + ctx.dom.youtubePickerSecondarySelect.value = ''; + } + setSelection(primaryTrackId, ctx.dom.youtubePickerSecondarySelect.value || null); + }); + + ctx.dom.youtubePickerSecondarySelect.addEventListener('change', () => { + const primaryTrackId = ctx.dom.youtubePickerPrimarySelect.value || null; + const secondaryTrackId = ctx.dom.youtubePickerSecondarySelect.value || null; + if (primaryTrackId && secondaryTrackId === primaryTrackId) { + ctx.dom.youtubePickerSecondarySelect.value = ''; + setStatus('Primary and secondary subtitles must be different.', true); + return; + } + setSelection(primaryTrackId, secondaryTrackId); + setStatus('Select the subtitle tracks to download.'); + }); + + ctx.dom.youtubePickerContinueButton.addEventListener('click', () => { + void resolveSelection( + ctx.state.youtubePickerPayload?.hasTracks ? 'use-selected' : 'continue-without-subtitles', + ); + }); + + ctx.dom.youtubePickerCloseButton.addEventListener('click', () => { + void resolveSelection('continue-without-subtitles'); + }); + } + + return { + closeYoutubePickerModal, + handleYoutubePickerKeydown, + openYoutubePickerModal, + wireDomEvents, + }; +} diff --git a/src/renderer/overlay-mouse-ignore.test.ts b/src/renderer/overlay-mouse-ignore.test.ts new file mode 100644 index 0000000..e191a8a --- /dev/null +++ b/src/renderer/overlay-mouse-ignore.test.ts @@ -0,0 +1,63 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { syncOverlayMouseIgnoreState } from './overlay-mouse-ignore.js'; + +function createClassList() { + const classes = new Set(); + return { + add: (...tokens: string[]) => { + for (const token of tokens) classes.add(token); + }, + remove: (...tokens: string[]) => { + for (const token of tokens) classes.delete(token); + }, + contains: (token: string) => classes.has(token), + }; +} + +test('youtube picker keeps overlay interactive even when subtitle hover is inactive', () => { + const classList = createClassList(); + const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = []; + const originalWindow = globalThis.window; + + Object.assign(globalThis, { + window: { + electronAPI: { + setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => { + ignoreCalls.push({ ignore, forward: options?.forward }); + }, + }, + }, + }); + + try { + syncOverlayMouseIgnoreState({ + dom: { + overlay: { classList }, + }, + platform: { + shouldToggleMouseIgnore: true, + }, + state: { + isOverSubtitle: false, + isOverSubtitleSidebar: false, + yomitanPopupVisible: false, + controllerSelectModalOpen: false, + controllerDebugModalOpen: false, + jimakuModalOpen: false, + youtubePickerModalOpen: true, + kikuModalOpen: false, + runtimeOptionsModalOpen: false, + subsyncModalOpen: false, + sessionHelpModalOpen: false, + subtitleSidebarModalOpen: false, + subtitleSidebarConfig: null, + }, + } as never); + + assert.equal(classList.contains('interactive'), true); + assert.deepEqual(ignoreCalls, [{ ignore: false, forward: undefined }]); + } finally { + Object.assign(globalThis, { window: originalWindow }); + } +}); diff --git a/src/renderer/overlay-mouse-ignore.ts b/src/renderer/overlay-mouse-ignore.ts index dcd798b..683392a 100644 --- a/src/renderer/overlay-mouse-ignore.ts +++ b/src/renderer/overlay-mouse-ignore.ts @@ -9,6 +9,7 @@ function isBlockingOverlayModalOpen(state: RendererState): boolean { state.controllerSelectModalOpen || state.controllerDebugModalOpen || state.jimakuModalOpen || + state.youtubePickerModalOpen || state.kikuModalOpen || state.runtimeOptionsModalOpen || state.subsyncModalOpen || diff --git a/src/renderer/renderer.ts b/src/renderer/renderer.ts index ebfc07c..4bfc019 100644 --- a/src/renderer/renderer.ts +++ b/src/renderer/renderer.ts @@ -37,6 +37,7 @@ import { createSessionHelpModal } from './modals/session-help.js'; import { createSubtitleSidebarModal } from './modals/subtitle-sidebar.js'; import { createRuntimeOptionsModal } from './modals/runtime-options.js'; import { createSubsyncModal } from './modals/subsync.js'; +import { createYoutubeTrackPickerModal } from './modals/youtube-track-picker.js'; import { createPositioningController } from './positioning.js'; import { createOverlayContentMeasurementReporter } from './overlay-content-measurement.js'; import { syncOverlayMouseIgnoreState } from './overlay-mouse-ignore.js'; @@ -68,6 +69,7 @@ function isAnySettingsModalOpen(): boolean { ctx.state.subsyncModalOpen || ctx.state.kikuModalOpen || ctx.state.jimakuModalOpen || + ctx.state.youtubePickerModalOpen || ctx.state.sessionHelpModalOpen ); } @@ -80,6 +82,7 @@ function isAnyModalOpen(): boolean { ctx.state.kikuModalOpen || ctx.state.runtimeOptionsModalOpen || ctx.state.subsyncModalOpen || + ctx.state.youtubePickerModalOpen || ctx.state.sessionHelpModalOpen || ctx.state.subtitleSidebarModalOpen ); @@ -128,11 +131,29 @@ const jimakuModal = createJimakuModal(ctx, { modalStateReader: { isAnyModalOpen }, syncSettingsModalSubtitleSuppression, }); +const mouseHandlers = createMouseHandlers(ctx, { + modalStateReader: { isAnySettingsModalOpen, isAnyModalOpen }, + applyYPercent: positioning.applyYPercent, + getCurrentYPercent: positioning.getCurrentYPercent, + persistSubtitlePositionPatch: positioning.persistSubtitlePositionPatch, + getSubtitleHoverAutoPauseEnabled: () => ctx.state.autoPauseVideoOnSubtitleHover, + getYomitanPopupAutoPauseEnabled: () => ctx.state.autoPauseVideoOnYomitanPopup, + getPlaybackPaused: () => window.electronAPI.getPlaybackPaused(), + sendMpvCommand: (command) => { + window.electronAPI.sendMpvCommand(command); + }, +}); +const youtubePickerModal = createYoutubeTrackPickerModal(ctx, { + modalStateReader: { isAnyModalOpen }, + restorePointerInteractionState: mouseHandlers.restorePointerInteractionState, + syncSettingsModalSubtitleSuppression, +}); const keyboardHandlers = createKeyboardHandlers(ctx, { handleRuntimeOptionsKeydown: runtimeOptionsModal.handleRuntimeOptionsKeydown, handleSubsyncKeydown: subsyncModal.handleSubsyncKeydown, handleKikuKeydown: kikuModal.handleKikuKeydown, handleJimakuKeydown: jimakuModal.handleJimakuKeydown, + handleYoutubePickerKeydown: youtubePickerModal.handleYoutubePickerKeydown, handleControllerSelectKeydown: controllerSelectModal.handleControllerSelectKeydown, handleControllerDebugKeydown: controllerDebugModal.handleControllerDebugKeydown, handleSessionHelpKeydown: sessionHelpModal.handleSessionHelpKeydown, @@ -153,18 +174,6 @@ const keyboardHandlers = createKeyboardHandlers(ctx, { void subtitleSidebarModal.toggleSubtitleSidebarModal(); }, }); -const mouseHandlers = createMouseHandlers(ctx, { - modalStateReader: { isAnySettingsModalOpen, isAnyModalOpen }, - applyYPercent: positioning.applyYPercent, - getCurrentYPercent: positioning.getCurrentYPercent, - persistSubtitlePositionPatch: positioning.persistSubtitlePositionPatch, - getSubtitleHoverAutoPauseEnabled: () => ctx.state.autoPauseVideoOnSubtitleHover, - getYomitanPopupAutoPauseEnabled: () => ctx.state.autoPauseVideoOnYomitanPopup, - getPlaybackPaused: () => window.electronAPI.getPlaybackPaused(), - sendMpvCommand: (command) => { - window.electronAPI.sendMpvCommand(command); - }, -}); let lastSubtitlePreview = ''; let lastSecondarySubtitlePreview = ''; @@ -194,6 +203,7 @@ function getActiveModal(): string | null { if (ctx.state.controllerDebugModalOpen) return 'controller-debug'; if (ctx.state.subtitleSidebarModalOpen) return 'subtitle-sidebar'; if (ctx.state.jimakuModalOpen) return 'jimaku'; + if (ctx.state.youtubePickerModalOpen) return 'youtube-track-picker'; if (ctx.state.kikuModalOpen) return 'kiku'; if (ctx.state.runtimeOptionsModalOpen) return 'runtime-options'; if (ctx.state.subsyncModalOpen) return 'subsync'; @@ -214,6 +224,9 @@ function dismissActiveUiAfterError(): void { if (ctx.state.jimakuModalOpen) { jimakuModal.closeJimakuModal(); } + if (ctx.state.youtubePickerModalOpen) { + youtubePickerModal.closeYoutubePickerModal(); + } if (ctx.state.runtimeOptionsModalOpen) { runtimeOptionsModal.closeRuntimeOptionsModal(); } @@ -416,6 +429,16 @@ function registerModalOpenHandlers(): void { window.electronAPI.notifyOverlayModalOpened('jimaku'); }); }); + window.electronAPI.onOpenYoutubeTrackPicker((payload) => { + runGuarded('youtube:picker-open', () => { + youtubePickerModal.openYoutubePickerModal(payload); + }); + }); + window.electronAPI.onCancelYoutubeTrackPicker(() => { + runGuarded('youtube:picker-cancel', () => { + youtubePickerModal.closeYoutubePickerModal(); + }); + }); window.electronAPI.onSubsyncManualOpen((payload: SubsyncManualPayload) => { runGuarded('subsync:manual-open', () => { subsyncModal.openSubsyncModal(payload); @@ -528,6 +551,7 @@ async function init(): Promise { ctx.dom.secondarySubContainer.addEventListener('mouseleave', mouseHandlers.handleSecondaryMouseLeave); mouseHandlers.setupResizeHandler(); + mouseHandlers.setupPointerTracking(); mouseHandlers.setupSelectionObserver(); mouseHandlers.setupYomitanObserver(); setupDragDropToMpvQueue(); @@ -536,6 +560,7 @@ async function init(): Promise { }); jimakuModal.wireDomEvents(); + youtubePickerModal.wireDomEvents(); kikuModal.wireDomEvents(); runtimeOptionsModal.wireDomEvents(); subsyncModal.wireDomEvents(); diff --git a/src/types.ts b/src/types.ts index 0784bb2..1fed44b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -139,6 +139,7 @@ export interface MpvClient { currentSubStart: number; currentSubEnd: number; currentAudioStreamIndex: number | null; + requestProperty?: (name: string) => Promise; send(command: { command: unknown[]; request_id?: number }): boolean; } @@ -559,6 +560,41 @@ export interface ControllerRuntimeSnapshot { } export type JimakuLanguagePreference = 'ja' | 'en' | 'none'; +export type YoutubeFlowMode = 'download' | 'generate'; +export type YoutubeTrackKind = 'manual' | 'auto'; + +export interface YoutubeTrackOption { + id: string; + language: string; + sourceLanguage: string; + kind: YoutubeTrackKind; + label: string; + title?: string; + downloadUrl?: string; + fileExtension?: string; +} + +export interface YoutubePickerOpenPayload { + sessionId: string; + url: string; + mode: YoutubeFlowMode; + tracks: YoutubeTrackOption[]; + defaultPrimaryTrackId: string | null; + defaultSecondaryTrackId: string | null; + hasTracks: boolean; +} + +export interface YoutubePickerResolveRequest { + sessionId: string; + action: 'use-selected' | 'continue-without-subtitles'; + primaryTrackId: string | null; + secondaryTrackId: string | null; +} + +export interface YoutubePickerResolveResult { + ok: boolean; + message: string; +} export interface JimakuConfig { apiKey?: string; @@ -1166,14 +1202,20 @@ export interface ElectronAPI { onRuntimeOptionsChanged: (callback: (options: RuntimeOptionState[]) => void) => void; onOpenRuntimeOptions: (callback: () => void) => void; onOpenJimaku: (callback: () => void) => void; + onOpenYoutubeTrackPicker: (callback: (payload: YoutubePickerOpenPayload) => void) => void; + onCancelYoutubeTrackPicker: (callback: () => void) => void; onKeyboardModeToggleRequested: (callback: () => void) => void; onLookupWindowToggleRequested: (callback: () => void) => void; appendClipboardVideoToQueue: () => Promise; + youtubePickerResolve: ( + request: YoutubePickerResolveRequest, + ) => Promise; notifyOverlayModalClosed: ( modal: | 'runtime-options' | 'subsync' | 'jimaku' + | 'youtube-track-picker' | 'kiku' | 'controller-select' | 'controller-debug' @@ -1184,6 +1226,7 @@ export interface ElectronAPI { | 'runtime-options' | 'subsync' | 'jimaku' + | 'youtube-track-picker' | 'kiku' | 'controller-select' | 'controller-debug'