feat(core): add Electron runtime, services, and app composition

This commit is contained in:
2026-02-22 21:43:43 -08:00
parent 448ce03fd4
commit d3fd47f0ec
562 changed files with 69719 additions and 0 deletions

79
src/subsync/engines.ts Normal file
View File

@@ -0,0 +1,79 @@
export type SubsyncEngine = 'alass' | 'ffsubsync';
export interface SubsyncCommandResult {
ok: boolean;
code: number | null;
stderr: string;
stdout: string;
error?: string;
}
export interface SubsyncEngineExecutionContext {
referenceFilePath: string;
videoPath: string;
inputSubtitlePath: string;
outputPath: string;
audioStreamIndex: number | null;
resolveExecutablePath: (configuredPath: string, commandName: string) => string;
resolvedPaths: {
alassPath: string;
ffsubsyncPath: string;
};
runCommand: (command: string, args: string[]) => Promise<SubsyncCommandResult>;
}
export interface SubsyncEngineProvider {
engine: SubsyncEngine;
execute: (context: SubsyncEngineExecutionContext) => Promise<SubsyncCommandResult>;
}
type SubsyncEngineProviderFactory = () => SubsyncEngineProvider;
const subsyncEngineProviderFactories = new Map<SubsyncEngine, SubsyncEngineProviderFactory>();
export function registerSubsyncEngineProvider(
engine: SubsyncEngine,
factory: SubsyncEngineProviderFactory,
): void {
if (subsyncEngineProviderFactories.has(engine)) {
return;
}
subsyncEngineProviderFactories.set(engine, factory);
}
export function createSubsyncEngineProvider(engine: SubsyncEngine): SubsyncEngineProvider | null {
const factory = subsyncEngineProviderFactories.get(engine);
if (!factory) return null;
return factory();
}
function registerDefaultSubsyncEngineProviders(): void {
registerSubsyncEngineProvider('alass', () => ({
engine: 'alass',
execute: async (context: SubsyncEngineExecutionContext) => {
const alassPath = context.resolveExecutablePath(context.resolvedPaths.alassPath, 'alass');
return context.runCommand(alassPath, [
context.referenceFilePath,
context.inputSubtitlePath,
context.outputPath,
]);
},
}));
registerSubsyncEngineProvider('ffsubsync', () => ({
engine: 'ffsubsync',
execute: async (context: SubsyncEngineExecutionContext) => {
const ffsubsyncPath = context.resolveExecutablePath(
context.resolvedPaths.ffsubsyncPath,
'ffsubsync',
);
const args = [context.videoPath, '-i', context.inputSubtitlePath, '-o', context.outputPath];
if (context.audioStreamIndex !== null) {
args.push('--reference-stream', `0:${context.audioStreamIndex}`);
}
return context.runCommand(ffsubsyncPath, args);
},
}));
}
registerDefaultSubsyncEngineProviders();

14
src/subsync/utils.test.ts Normal file
View File

@@ -0,0 +1,14 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { codecToExtension } from './utils';
test('codecToExtension maps stream/web formats to ffmpeg extractable extensions', () => {
assert.equal(codecToExtension('subrip'), 'srt');
assert.equal(codecToExtension('webvtt'), 'vtt');
assert.equal(codecToExtension('vtt'), 'vtt');
assert.equal(codecToExtension('ttml'), 'ttml');
});
test('codecToExtension returns null for unsupported codecs', () => {
assert.equal(codecToExtension('unsupported-codec'), null);
});

144
src/subsync/utils.ts Normal file
View File

@@ -0,0 +1,144 @@
import * as fs from 'fs';
import * as childProcess from 'child_process';
import { DEFAULT_CONFIG } from '../config';
import { SubsyncConfig, SubsyncMode } 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 {
defaultMode: SubsyncMode;
alassPath: string;
ffsubsyncPath: string;
ffmpegPath: string;
}
const DEFAULT_SUBSYNC_EXECUTABLE_PATHS = {
alass: '/usr/bin/alass',
ffsubsync: '/usr/bin/ffsubsync',
ffmpeg: '/usr/bin/ffmpeg',
} as const;
export interface SubsyncContext {
videoPath: string;
primaryTrack: MpvTrack;
secondaryTrack: MpvTrack | null;
sourceTracks: MpvTrack[];
audioStreamIndex: number | null;
}
export interface CommandResult {
ok: boolean;
code: number | null;
stderr: string;
stdout: string;
error?: string;
}
export function getSubsyncConfig(config: SubsyncConfig | undefined): SubsyncResolvedConfig {
const resolvePath = (value: string | undefined, fallback: string): string => {
const trimmed = value?.trim();
return trimmed && trimmed.length > 0 ? trimmed : fallback;
};
return {
defaultMode: config?.defaultMode ?? DEFAULT_CONFIG.subsync.defaultMode,
alassPath: resolvePath(config?.alass_path, DEFAULT_SUBSYNC_EXECUTABLE_PATHS.alass),
ffsubsyncPath: resolvePath(config?.ffsubsync_path, DEFAULT_SUBSYNC_EXECUTABLE_PATHS.ffsubsync),
ffmpegPath: resolvePath(config?.ffmpeg_path, DEFAULT_SUBSYNC_EXECUTABLE_PATHS.ffmpeg),
};
}
export function hasPathSeparators(value: string): boolean {
return value.includes('/') || value.includes('\\');
}
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 child = childProcess.spawn(executable, 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,
});
});
});
}