mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-16 01:55:51 -07:00
035ff8e7bf
Stream subtitles arrive as URLs and are downloaded, never piped through ffmpeg, so demanding an ffmpeg install up front broke stream syncs (and CI, which has no ffmpeg). Resolution is now lazy, scoped to embedded tracks.
203 lines
5.7 KiB
TypeScript
203 lines
5.7 KiB
TypeScript
import * as fs from 'fs';
|
|
import * as childProcess from 'child_process';
|
|
import * as path from 'path';
|
|
import { DEFAULT_CONFIG } from '../config';
|
|
import { SubsyncConfig } from '../types';
|
|
|
|
export interface MpvTrack {
|
|
id?: number;
|
|
type?: string;
|
|
selected?: boolean;
|
|
external?: boolean;
|
|
lang?: string;
|
|
title?: string;
|
|
codec?: string;
|
|
'ff-index'?: number;
|
|
'external-filename'?: string;
|
|
}
|
|
|
|
export interface SubsyncResolvedConfig {
|
|
alassPath: string;
|
|
ffsubsyncPath: string;
|
|
ffmpegPath: string;
|
|
replace?: boolean;
|
|
}
|
|
|
|
export interface SubsyncContext {
|
|
videoPath: string;
|
|
primaryTrack: MpvTrack;
|
|
secondaryTrack: MpvTrack | null;
|
|
/** Every usable subtitle track, including the primary one. */
|
|
subtitleTracks: MpvTrack[];
|
|
audioStreamIndex: number | null;
|
|
}
|
|
|
|
export interface CommandResult {
|
|
ok: boolean;
|
|
code: number | null;
|
|
stderr: string;
|
|
stdout: string;
|
|
error?: string;
|
|
}
|
|
|
|
function resolveCommandInvocation(
|
|
executable: string,
|
|
args: string[],
|
|
): { command: string; args: string[] } {
|
|
if (process.platform !== 'win32') {
|
|
return { command: executable, args };
|
|
}
|
|
|
|
const normalizeBashArg = (value: string): string => {
|
|
const normalized = value.replace(/\\/g, '/');
|
|
const driveMatch = normalized.match(/^([A-Za-z]):\/(.*)$/);
|
|
if (!driveMatch) {
|
|
return normalized;
|
|
}
|
|
|
|
const [, driveLetter, remainder] = driveMatch;
|
|
return `/mnt/${driveLetter!.toLowerCase()}/${remainder}`;
|
|
};
|
|
const extension = path.extname(executable).toLowerCase();
|
|
if (extension === '.ps1') {
|
|
return {
|
|
command: 'powershell.exe',
|
|
args: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', executable, ...args],
|
|
};
|
|
}
|
|
|
|
if (extension === '.sh') {
|
|
return {
|
|
command: 'bash',
|
|
args: [normalizeBashArg(executable), ...args.map(normalizeBashArg)],
|
|
};
|
|
}
|
|
|
|
return { command: executable, args };
|
|
}
|
|
|
|
/**
|
|
* An unset path stays empty here on purpose: hard-coding `/usr/bin/<tool>` made
|
|
* the documented "leave empty to auto-discover from PATH" a lie and broke every
|
|
* default-config macOS install, where none of these live in /usr/bin.
|
|
* Discovery happens at run time in `resolveSubsyncExecutable`.
|
|
*/
|
|
export function getSubsyncConfig(config: SubsyncConfig | undefined): SubsyncResolvedConfig {
|
|
const trim = (value: string | undefined): string => value?.trim() ?? '';
|
|
|
|
return {
|
|
alassPath: trim(config?.alass_path),
|
|
ffsubsyncPath: trim(config?.ffsubsync_path),
|
|
ffmpegPath: trim(config?.ffmpeg_path),
|
|
replace: config?.replace ?? DEFAULT_CONFIG.subsync.replace,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* ffsubsync streams a progress bar to stderr, so an unbounded summary reaches
|
|
* the OSD (and the error log) as tens of kilobytes of carriage returns. The tail
|
|
* is the part that says what went wrong.
|
|
*/
|
|
const COMMAND_OUTPUT_SUMMARY_LIMIT = 2000;
|
|
|
|
function tailForSummary(value: string): string {
|
|
if (value.length <= COMMAND_OUTPUT_SUMMARY_LIMIT) return value;
|
|
return `…${value.slice(-COMMAND_OUTPUT_SUMMARY_LIMIT)}`;
|
|
}
|
|
|
|
export function summarizeCommandFailure(command: string, result: CommandResult): string {
|
|
const parts = [
|
|
`code=${result.code ?? 'n/a'}`,
|
|
result.stderr ? `stderr: ${tailForSummary(result.stderr)}` : '',
|
|
result.stdout ? `stdout: ${tailForSummary(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(' | ')}`;
|
|
}
|
|
|
|
export function fileExists(pathOrEmpty: string): boolean {
|
|
if (!pathOrEmpty) return false;
|
|
try {
|
|
return fs.existsSync(pathOrEmpty);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export function formatTrackLabel(track: MpvTrack): string {
|
|
const trackId = typeof track.id === 'number' ? track.id : -1;
|
|
const source = track.external ? 'External' : 'Internal';
|
|
const lang = track.lang || track.title || 'unknown';
|
|
const active = track.selected ? ' (active)' : '';
|
|
return `${source} #${trackId} - ${lang}${active}`;
|
|
}
|
|
|
|
export function getTrackById(tracks: MpvTrack[], trackId: number | null): MpvTrack | null {
|
|
if (trackId === null) return null;
|
|
return tracks.find((track) => track.id === trackId) ?? null;
|
|
}
|
|
|
|
export function codecToExtension(codec: string | undefined): string | null {
|
|
if (!codec) return null;
|
|
const normalized = codec.toLowerCase();
|
|
if (
|
|
normalized === 'subrip' ||
|
|
normalized === 'srt' ||
|
|
normalized === 'text' ||
|
|
normalized === 'mov_text'
|
|
)
|
|
return 'srt';
|
|
if (normalized === 'ass' || normalized === 'ssa') return 'ass';
|
|
if (normalized === 'webvtt' || normalized === 'vtt') return 'vtt';
|
|
if (normalized === 'ttml') return 'ttml';
|
|
return null;
|
|
}
|
|
|
|
export function runCommand(
|
|
executable: string,
|
|
args: string[],
|
|
timeoutMs = 120000,
|
|
): Promise<CommandResult> {
|
|
return new Promise((resolve) => {
|
|
const invocation = resolveCommandInvocation(executable, args);
|
|
const child = childProcess.spawn(invocation.command, invocation.args, {
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
let stdout = '';
|
|
let stderr = '';
|
|
const timeout = setTimeout(() => {
|
|
child.kill('SIGKILL');
|
|
}, timeoutMs);
|
|
|
|
child.stdout.on('data', (chunk: Buffer) => {
|
|
stdout += chunk.toString();
|
|
});
|
|
child.stderr.on('data', (chunk: Buffer) => {
|
|
stderr += chunk.toString();
|
|
});
|
|
child.on('error', (error: Error) => {
|
|
clearTimeout(timeout);
|
|
resolve({
|
|
ok: false,
|
|
code: null,
|
|
stderr,
|
|
stdout,
|
|
error: error.message,
|
|
});
|
|
});
|
|
child.on('close', (code: number | null) => {
|
|
clearTimeout(timeout);
|
|
resolve({
|
|
ok: code === 0,
|
|
code,
|
|
stderr,
|
|
stdout,
|
|
});
|
|
});
|
|
});
|
|
}
|