import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { codecToExtension, fileExists, MpvTrack, resolveLocalMediaPath, runCommand, summarizeCommandFailure, } from '../../subsync/utils'; import { downloadToFile, isRemoteMediaPath } from '../../jimaku/utils'; import { createLogger } from '../../logger'; import { ResolvedMpvHttpHeaders, toFfmpegInputHttpArgs, toRequestHeaders, } from './mpv-http-headers'; const logger = createLogger('subsync'); export interface FileExtractionResult { path: string; temporary: boolean; } export interface SubtitleExtractionInput { /** * Resolved lazily: an external track never shells out to ffmpeg, so a stream * whose subtitles arrive by URL must not fail just because ffmpeg is absent. */ resolveFfmpegPath: () => string; videoPath: string; track: MpvTrack; /** mpv's request context, so stream-hosted tracks stay reachable. */ httpHeaders: ResolvedMpvHttpHeaders | null; } function extensionForUrl(url: string, fallback: string): string { try { const parsed = new URL(url); const ext = path.extname(parsed.pathname).replace(/^\./, '').toLowerCase(); return /^[a-z0-9]{1,5}$/.test(ext) ? ext : fallback; } catch { return fallback; } } /** * Drop a scratch directory whose extraction never completed. * * Removal is recursive here — unlike `cleanupTemporaryFile`, nothing in a failed * directory is worth keeping, and a half-written download would otherwise leave * the directory behind for the life of the machine. */ function discardTemporaryDirectory(tempDir: string): void { try { fs.rmSync(tempDir, { recursive: true, force: true }); } catch {} } /** * Pull a subtitle track mpv loaded from a URL down to disk. * * Extension-backed and Jellyfin streams add their subtitles by URL, and neither * alass nor ffmpeg can read one — the whole subsync path used to reject these * with "Subtitle file not found: https://…". */ async function downloadRemoteSubtitleTrack( url: string, httpHeaders: ResolvedMpvHttpHeaders | null, ): Promise { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-subsync-')); try { const outputPath = path.join(tempDir, `remote_track.${extensionForUrl(url, 'srt')}`); const result = await downloadToFile(url, outputPath, toRequestHeaders(httpHeaders)); if (!result.ok) { throw new Error( `Failed to download subtitle track: ${result.error?.error ?? 'unknown error'}`, ); } if (!fileExists(outputPath)) { throw new Error(`Downloaded subtitle track is missing: ${url}`); } logger.info(`Downloaded remote subtitle track to ${outputPath}`); return { path: outputPath, temporary: true }; } catch (error) { discardTemporaryDirectory(tempDir); throw error; } } async function extractInternalTrack(input: SubtitleExtractionInput): Promise { const ffIndex = input.track['ff-index']; const extension = codecToExtension(input.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: ${input.track.codec ?? 'unknown'}`); } const ffmpegMuxer = extension === 'vtt' ? 'webvtt' : extension; const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-subsync-')); try { const outputPath = path.join(tempDir, `track_${ffIndex}.${extension}`); // Header args configure the HTTP demuxer, so they belong before `-i`. const httpArgs = isRemoteMediaPath(input.videoPath) ? toFfmpegInputHttpArgs(input.httpHeaders) : []; const extraction = await runCommand(input.resolveFfmpegPath(), [ '-hide_banner', '-nostdin', '-y', '-loglevel', 'error', ...httpArgs, '-an', '-vn', '-i', input.videoPath, '-map', `0:${ffIndex}`, '-f', ffmpegMuxer, 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 }; } catch (error) { discardTemporaryDirectory(tempDir); throw error; } } export async function extractSubtitleTrackToFile( input: SubtitleExtractionInput, ): Promise { if (input.track.external) { const externalFilename = input.track['external-filename']; if (typeof externalFilename !== 'string' || externalFilename.length === 0) { throw new Error('External subtitle track has no file path'); } // A dropped subtitle arrives as a `file://` URL, which is local, not remote. const externalPath = resolveLocalMediaPath(externalFilename); if (isRemoteMediaPath(externalPath)) { return downloadRemoteSubtitleTrack(externalPath, input.httpHeaders); } if (!fileExists(externalPath)) { throw new Error(`Subtitle file not found: ${externalPath}`); } return { path: externalPath, temporary: false }; } return extractInternalTrack(input); } /** * Drop a temporary extraction once alass/ffsubsync is done with it. * * `preservePath` is the retimed subtitle mpv is about to load. With * `replace: false` it lands in this same temp directory, so the removal has to * skip it — and the directory removal must stay non-recursive, since that is * what keeps the retimed file alive for mpv. */ export function cleanupTemporaryFile( extraction: FileExtractionResult, preservePath?: string, ): void { if (!extraction.temporary) return; if (preservePath && path.resolve(preservePath) === path.resolve(extraction.path)) 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 {} }