import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { SubsyncManualPayload, SubsyncManualRunRequest, SubsyncResult } from '../../types'; import { CommandResult, codecToExtension, fileExists, formatTrackLabel, getTrackById, hasPathSeparators, MpvTrack, runCommand, SubsyncContext, SubsyncResolvedConfig, } from '../../subsync/utils'; import { isRemoteMediaPath } from '../../jimaku/utils'; import { createLogger } from '../../logger'; const logger = createLogger('main:subsync'); interface FileExtractionResult { path: string; temporary: boolean; } function summarizeCommandFailure(command: string, result: CommandResult): string { const parts = [ `code=${result.code ?? 'n/a'}`, result.stderr ? `stderr: ${result.stderr}` : '', result.stdout ? `stdout: ${result.stdout}` : '', result.error ? `error: ${result.error}` : '', ] .map((value) => value.trim()) .filter(Boolean); if (parts.length === 0) return `command failed (${command})`; return `command failed (${command}) ${parts.join(' | ')}`; } interface MpvClientLike { connected: boolean; currentAudioStreamIndex: number | null; send: (payload: { command: (string | number)[] }) => void; requestProperty: (name: string) => Promise; } interface SubsyncCoreDeps { getMpvClient: () => MpvClientLike | null; getResolvedConfig: () => SubsyncResolvedConfig; } function parseTrackId(value: unknown): number | null { if (typeof value === 'number') { return Number.isInteger(value) ? value : null; } if (typeof value === 'string') { const trimmed = value.trim(); if (!trimmed.length) return null; const parsed = Number(trimmed); return Number.isInteger(parsed) && String(parsed) === trimmed ? parsed : null; } return null; } function normalizeTrackIds(tracks: unknown[]): MpvTrack[] { return tracks.map((track) => { if (!track || typeof track !== 'object') return track as MpvTrack; const typed = track as MpvTrack & { id?: unknown }; const parsedId = parseTrackId(typed.id); if (parsedId === null) { const { id: _ignored, ...rest } = typed; return rest as MpvTrack; } return { ...typed, id: parsedId }; }); } export interface TriggerSubsyncFromConfigDeps extends SubsyncCoreDeps { isSubsyncInProgress: () => boolean; setSubsyncInProgress: (inProgress: boolean) => void; showMpvOsd: (text: string) => void; runWithSubsyncSpinner: (task: () => Promise) => Promise; openManualPicker: (payload: SubsyncManualPayload) => void; } function getMpvClientForSubsync(deps: SubsyncCoreDeps): MpvClientLike { const client = deps.getMpvClient(); if (!client || !client.connected) { throw new Error('MPV not connected'); } return client; } async function gatherSubsyncContext(client: MpvClientLike): Promise { const [videoPathRaw, sidRaw, secondarySidRaw, trackListRaw] = await Promise.all([ client.requestProperty('path'), client.requestProperty('sid'), client.requestProperty('secondary-sid'), client.requestProperty('track-list'), ]); const videoPath = typeof videoPathRaw === 'string' ? videoPathRaw : ''; if (!videoPath) { throw new Error('No video is currently loaded'); } const tracks = Array.isArray(trackListRaw) ? normalizeTrackIds(trackListRaw as MpvTrack[]) : []; const subtitleTracks = tracks.filter((track) => track.type === 'sub'); const sid = parseTrackId(sidRaw); const secondarySid = parseTrackId(secondarySidRaw); const primaryTrack = subtitleTracks.find((track) => track.id === sid); if (!primaryTrack) { throw new Error('No active subtitle track found'); } const secondaryTrack = subtitleTracks.find((track) => track.id === secondarySid) ?? null; const sourceTracks = subtitleTracks .filter((track) => track.id !== sid) .filter((track) => { if (!track.external) return true; const filename = track['external-filename']; return typeof filename === 'string' && filename.length > 0; }); return { videoPath, primaryTrack, secondaryTrack, sourceTracks, audioStreamIndex: client.currentAudioStreamIndex, }; } function ensureExecutablePath(pathOrName: string, name: string): string { if (!pathOrName) { throw new Error(`Missing ${name} path in config`); } if (hasPathSeparators(pathOrName) && !fileExists(pathOrName)) { throw new Error(`Configured ${name} executable not found: ${pathOrName}`); } return pathOrName; } async function extractSubtitleTrackToFile( ffmpegPath: string, videoPath: string, track: MpvTrack, ): Promise { if (track.external) { const externalPath = track['external-filename']; if (typeof externalPath !== 'string' || externalPath.length === 0) { throw new Error('External subtitle track has no file path'); } if (!fileExists(externalPath)) { throw new Error(`Subtitle file not found: ${externalPath}`); } return { path: externalPath, temporary: false }; } const ffIndex = track['ff-index']; const extension = codecToExtension(track.codec); if (typeof ffIndex !== 'number' || !Number.isInteger(ffIndex) || ffIndex < 0) { throw new Error('Internal subtitle track has no valid ff-index'); } if (!extension) { throw new Error(`Unsupported subtitle codec: ${track.codec ?? 'unknown'}`); } const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-subsync-')); const outputPath = path.join(tempDir, `track_${ffIndex}.${extension}`); const extraction = await runCommand(ffmpegPath, [ '-hide_banner', '-nostdin', '-y', '-loglevel', 'error', '-an', '-vn', '-i', videoPath, '-map', `0:${ffIndex}`, '-f', extension, outputPath, ]); if (!extraction.ok || !fileExists(outputPath)) { throw new Error( `Failed to extract internal subtitle track with ffmpeg: ${summarizeCommandFailure( 'ffmpeg', extraction, )}`, ); } return { path: outputPath, temporary: true }; } function cleanupTemporaryFile(extraction: FileExtractionResult): void { if (!extraction.temporary) return; try { if (fileExists(extraction.path)) { fs.unlinkSync(extraction.path); } } catch {} try { const dir = path.dirname(extraction.path); if (fs.existsSync(dir)) { fs.rmdirSync(dir); } } catch {} } function buildRetimedPath(subPath: string): string { const parsed = path.parse(subPath); const suffix = `_retimed_${Date.now()}`; return path.join(parsed.dir, `${parsed.name}${suffix}${parsed.ext || '.srt'}`); } async function runAlassSync( alassPath: string, referenceFile: string, inputSubtitlePath: string, outputPath: string, ): Promise { return runCommand(alassPath, [referenceFile, inputSubtitlePath, outputPath]); } async function runFfsubsyncSync( ffsubsyncPath: string, videoPath: string, inputSubtitlePath: string, outputPath: string, audioStreamIndex: number | null, ): Promise { const args = [videoPath, '-i', inputSubtitlePath, '-o', outputPath]; if (audioStreamIndex !== null) { args.push('--reference-stream', `0:${audioStreamIndex}`); } return runCommand(ffsubsyncPath, args); } function loadSyncedSubtitle(client: MpvClientLike, pathToLoad: string): void { if (!client.connected) { throw new Error('MPV disconnected while loading subtitle'); } client.send({ command: ['sub_add', pathToLoad] }); client.send({ command: ['set_property', 'sub-delay', 0] }); } async function subsyncToReference( engine: 'alass' | 'ffsubsync', referenceFilePath: string, context: SubsyncContext, resolved: SubsyncResolvedConfig, client: MpvClientLike, ): Promise { const ffmpegPath = ensureExecutablePath(resolved.ffmpegPath, 'ffmpeg'); const primaryExtraction = await extractSubtitleTrackToFile( ffmpegPath, context.videoPath, context.primaryTrack, ); const outputPath = buildRetimedPath(primaryExtraction.path); try { let result: CommandResult; if (engine === 'alass') { const alassPath = ensureExecutablePath(resolved.alassPath, 'alass'); result = await runAlassSync(alassPath, referenceFilePath, primaryExtraction.path, outputPath); } else { const ffsubsyncPath = ensureExecutablePath(resolved.ffsubsyncPath, 'ffsubsync'); result = await runFfsubsyncSync( ffsubsyncPath, context.videoPath, primaryExtraction.path, outputPath, context.audioStreamIndex, ); } if (!result.ok || !fileExists(outputPath)) { const details = summarizeCommandFailure(engine, result); return { ok: false, message: `${engine} synchronization failed: ${details}`, }; } loadSyncedSubtitle(client, outputPath); return { ok: true, message: `Subtitle synchronized with ${engine}`, }; } finally { cleanupTemporaryFile(primaryExtraction); } } function validateFfsubsyncReference(videoPath: string): void { if (isRemoteMediaPath(videoPath)) { throw new Error( 'FFsubsync cannot reliably sync stream URLs because it needs direct reference media access. Use Alass with a secondary subtitle source or play a local file.', ); } } async function runSubsyncAutoInternal(deps: SubsyncCoreDeps): Promise { const client = getMpvClientForSubsync(deps); const context = await gatherSubsyncContext(client); const resolved = deps.getResolvedConfig(); const ffmpegPath = ensureExecutablePath(resolved.ffmpegPath, 'ffmpeg'); if (context.secondaryTrack) { let secondaryExtraction: FileExtractionResult | null = null; try { secondaryExtraction = await extractSubtitleTrackToFile( ffmpegPath, context.videoPath, context.secondaryTrack, ); const alassResult = await subsyncToReference( 'alass', secondaryExtraction.path, context, resolved, client, ); if (alassResult.ok) { return alassResult; } } catch (error) { logger.warn('Auto alass sync failed, trying ffsubsync fallback:', error); } finally { if (secondaryExtraction) { cleanupTemporaryFile(secondaryExtraction); } } } const ffsubsyncPath = ensureExecutablePath(resolved.ffsubsyncPath, 'ffsubsync'); if (!ffsubsyncPath) { return { ok: false, message: 'No secondary subtitle for alass and ffsubsync not configured', }; } try { validateFfsubsyncReference(context.videoPath); } catch (error) { return { ok: false, message: `ffsubsync synchronization failed: ${(error as Error).message}`, }; } return subsyncToReference('ffsubsync', context.videoPath, context, resolved, client); } export async function runSubsyncManual( request: SubsyncManualRunRequest, deps: SubsyncCoreDeps, ): Promise { const client = getMpvClientForSubsync(deps); const context = await gatherSubsyncContext(client); const resolved = deps.getResolvedConfig(); if (request.engine === 'ffsubsync') { try { validateFfsubsyncReference(context.videoPath); } catch (error) { return { ok: false, message: `ffsubsync synchronization failed: ${(error as Error).message}`, }; } return subsyncToReference('ffsubsync', context.videoPath, context, resolved, client); } const sourceTrack = getTrackById(context.sourceTracks, request.sourceTrackId ?? null); if (!sourceTrack) { return { ok: false, message: 'Select a subtitle source track for alass' }; } const ffmpegPath = ensureExecutablePath(resolved.ffmpegPath, 'ffmpeg'); let sourceExtraction: FileExtractionResult | null = null; try { sourceExtraction = await extractSubtitleTrackToFile(ffmpegPath, context.videoPath, sourceTrack); return subsyncToReference('alass', sourceExtraction.path, context, resolved, client); } finally { if (sourceExtraction) { cleanupTemporaryFile(sourceExtraction); } } } export async function openSubsyncManualPicker(deps: TriggerSubsyncFromConfigDeps): Promise { const client = getMpvClientForSubsync(deps); const context = await gatherSubsyncContext(client); const payload: SubsyncManualPayload = { sourceTracks: context.sourceTracks .filter((track) => typeof track.id === 'number') .map((track) => ({ id: track.id as number, label: formatTrackLabel(track), })), }; deps.openManualPicker(payload); } export async function triggerSubsyncFromConfig(deps: TriggerSubsyncFromConfigDeps): Promise { if (deps.isSubsyncInProgress()) { deps.showMpvOsd('Subsync already running'); return; } const resolved = deps.getResolvedConfig(); try { if (resolved.defaultMode === 'manual') { await openSubsyncManualPicker(deps); deps.showMpvOsd('Subsync: choose engine and source'); return; } deps.setSubsyncInProgress(true); const result = await deps.runWithSubsyncSpinner(() => runSubsyncAutoInternal(deps)); deps.showMpvOsd(result.message); } catch (error) { deps.showMpvOsd(`Subsync failed: ${(error as Error).message}`); } finally { deps.setSubsyncInProgress(false); } }