From 10ef535f9a072e86378eb2ef4326d7cfce1a4bc9 Mon Sep 17 00:00:00 2001 From: sudacode Date: Tue, 3 Mar 2026 00:26:31 -0800 Subject: [PATCH] feat(subsync): add replace option and deterministic retimed naming --- config.example.jsonc | 1 + docs/configuration.md | 4 +- docs/public/config.example.jsonc | 1 + src/config/definitions/defaults-core.ts | 1 + src/config/definitions/options-core.ts | 6 ++ src/config/resolve/core-domains.ts | 6 ++ src/core/services/subsync.test.ts | 133 ++++++++++++++++++++++++ src/core/services/subsync.ts | 11 +- src/subsync/utils.test.ts | 12 ++- src/subsync/utils.ts | 2 + src/types.ts | 1 + 11 files changed, 171 insertions(+), 7 deletions(-) diff --git a/config.example.jsonc b/config.example.jsonc index c919f30..d954361 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -88,6 +88,7 @@ "alass_path": "", // Alass path setting. "ffsubsync_path": "", // Ffsubsync path setting. "ffmpeg_path": "", // Ffmpeg path setting. + "replace": true, // Replace active subtitle file when synchronization succeeds. }, // Subsync engine and executable paths. // ========================================== diff --git a/docs/configuration.md b/docs/configuration.md index 4caca0d..d910f85 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -771,7 +771,8 @@ Sync the active subtitle track using `alass` (preferred) or `ffsubsync`: "defaultMode": "auto", "alass_path": "", "ffsubsync_path": "", - "ffmpeg_path": "" + "ffmpeg_path": "", + "replace": true } } ``` @@ -782,6 +783,7 @@ Sync the active subtitle track using `alass` (preferred) or `ffsubsync`: | `alass_path` | string path | Path to `alass` executable. Empty or `null` falls back to `/usr/bin/alass`. | | `ffsubsync_path` | string path | Path to `ffsubsync` executable. Empty or `null` falls back to `/usr/bin/ffsubsync`. | | `ffmpeg_path` | string path | Path to `ffmpeg` (used for internal subtitle extraction). Empty or `null` falls back to `/usr/bin/ffmpeg`. | +| `replace` | `true`, `false` | When `true` (default), overwrite the active subtitle file on successful sync. When `false`, write `_retimed.`. | Default trigger is `Ctrl+Alt+S` via `shortcuts.triggerSubsync`. Customize it there, or set it to `null` to disable. diff --git a/docs/public/config.example.jsonc b/docs/public/config.example.jsonc index c770adc..995cd87 100644 --- a/docs/public/config.example.jsonc +++ b/docs/public/config.example.jsonc @@ -88,6 +88,7 @@ "alass_path": "", // Alass path setting. "ffsubsync_path": "", // Ffsubsync path setting. "ffmpeg_path": "", // Ffmpeg path setting. + "replace": true, // Replace active subtitle file when synchronization succeeds. }, // Subsync engine and executable paths. // ========================================== diff --git a/src/config/definitions/defaults-core.ts b/src/config/definitions/defaults-core.ts index c0b434c..61c2b90 100644 --- a/src/config/definitions/defaults-core.ts +++ b/src/config/definitions/defaults-core.ts @@ -50,6 +50,7 @@ export const CORE_DEFAULT_CONFIG: Pick< alass_path: '', ffsubsync_path: '', ffmpeg_path: '', + replace: true, }, startupWarmups: { lowPowerMode: false, diff --git a/src/config/definitions/options-core.ts b/src/config/definitions/options-core.ts index 4b55199..3aad5ae 100644 --- a/src/config/definitions/options-core.ts +++ b/src/config/definitions/options-core.ts @@ -32,6 +32,12 @@ export function buildCoreConfigOptionRegistry( defaultValue: defaultConfig.subsync.defaultMode, description: 'Subsync default mode.', }, + { + path: 'subsync.replace', + kind: 'boolean', + defaultValue: defaultConfig.subsync.replace, + description: 'Replace the active subtitle file when sync completes.', + }, { path: 'startupWarmups.lowPowerMode', kind: 'boolean', diff --git a/src/config/resolve/core-domains.ts b/src/config/resolve/core-domains.ts index 41c6ca9..a39631e 100644 --- a/src/config/resolve/core-domains.ts +++ b/src/config/resolve/core-domains.ts @@ -173,6 +173,12 @@ export function applyCoreDomainConfig(context: ResolveContext): void { if (ffsubsync !== undefined) resolved.subsync.ffsubsync_path = ffsubsync; const ffmpeg = asString(src.subsync.ffmpeg_path); if (ffmpeg !== undefined) resolved.subsync.ffmpeg_path = ffmpeg; + const replace = asBoolean(src.subsync.replace); + if (replace !== undefined) { + resolved.subsync.replace = replace; + } else if (src.subsync.replace !== undefined) { + warn('subsync.replace', src.subsync.replace, resolved.subsync.replace, 'Expected boolean.'); + } } if (isObject(src.subtitlePosition)) { diff --git a/src/core/services/subsync.test.ts b/src/core/services/subsync.test.ts index d33be08..e6cb440 100644 --- a/src/core/services/subsync.test.ts +++ b/src/core/services/subsync.test.ts @@ -209,10 +209,73 @@ test('runSubsyncManual constructs ffsubsync command and returns success', async assert.ok(ffArgs.includes(primaryPath)); assert.ok(ffArgs.includes('--reference-stream')); assert.ok(ffArgs.includes('0:2')); + const ffOutputFlagIndex = ffArgs.indexOf('-o'); + assert.equal(ffOutputFlagIndex >= 0, true); + assert.equal(ffArgs[ffOutputFlagIndex + 1], primaryPath); assert.equal(sentCommands[0]?.[0], 'sub_add'); assert.deepEqual(sentCommands[1], ['set_property', 'sub-delay', 0]); }); +test('runSubsyncManual writes deterministic _retimed filename when replace is false', async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subsync-ffsubsync-no-replace-')); + const ffsubsyncLogPath = path.join(tmpDir, 'ffsubsync-args.log'); + const ffsubsyncPath = path.join(tmpDir, 'ffsubsync.sh'); + const ffmpegPath = path.join(tmpDir, 'ffmpeg.sh'); + const alassPath = path.join(tmpDir, 'alass.sh'); + const videoPath = path.join(tmpDir, 'video.mkv'); + const primaryPath = path.join(tmpDir, 'episode.ja.srt'); + + fs.writeFileSync(videoPath, 'video'); + fs.writeFileSync(primaryPath, 'sub'); + writeExecutableScript(ffmpegPath, '#!/bin/sh\nexit 0\n'); + writeExecutableScript(alassPath, '#!/bin/sh\nexit 0\n'); + writeExecutableScript( + ffsubsyncPath, + `#!/bin/sh\n: > "${ffsubsyncLogPath}"\nfor arg in "$@"; do printf '%s\\n' "$arg" >> "${ffsubsyncLogPath}"; done\nout=\"\"\nprev=\"\"\nfor arg in \"$@\"; do\n if [ \"$prev\" = \"-o\" ]; then out=\"$arg\"; fi\n prev=\"$arg\"\ndone\nif [ -n \"$out\" ]; then : > \"$out\"; fi\nexit 0\n`, + ); + + const deps = makeDeps({ + getMpvClient: () => ({ + connected: true, + currentAudioStreamIndex: null, + send: () => {}, + requestProperty: async (name: string) => { + if (name === 'path') return videoPath; + if (name === 'sid') return 1; + if (name === 'secondary-sid') return null; + if (name === 'track-list') { + return [ + { + id: 1, + type: 'sub', + selected: true, + external: true, + 'external-filename': primaryPath, + }, + ]; + } + return null; + }, + }), + getResolvedConfig: () => ({ + defaultMode: 'manual', + alassPath, + ffsubsyncPath, + ffmpegPath, + replace: false, + }), + }); + + const result = await runSubsyncManual({ engine: 'ffsubsync', sourceTrackId: null }, deps); + + assert.equal(result.ok, true); + const ffArgs = fs.readFileSync(ffsubsyncLogPath, 'utf8').trim().split('\n'); + const ffOutputFlagIndex = ffArgs.indexOf('-o'); + assert.equal(ffOutputFlagIndex >= 0, true); + const outputPath = ffArgs[ffOutputFlagIndex + 1]; + assert.equal(outputPath, path.join(tmpDir, 'episode.ja_retimed.srt')); +}); + test('runSubsyncManual constructs alass command and returns failure on non-zero exit', async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subsync-alass-')); const alassLogPath = path.join(tmpDir, 'alass-args.log'); @@ -281,6 +344,76 @@ test('runSubsyncManual constructs alass command and returns failure on non-zero assert.equal(alassArgs[1], primaryPath); }); +test('runSubsyncManual keeps internal alass source file alive until sync finishes', async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subsync-alass-internal-source-')); + const alassPath = path.join(tmpDir, 'alass.sh'); + const ffmpegPath = path.join(tmpDir, 'ffmpeg.sh'); + const ffsubsyncPath = path.join(tmpDir, 'ffsubsync.sh'); + const videoPath = path.join(tmpDir, 'video.mkv'); + const primaryPath = path.join(tmpDir, 'primary.srt'); + + fs.writeFileSync(videoPath, 'video'); + fs.writeFileSync(primaryPath, 'sub'); + writeExecutableScript(ffsubsyncPath, '#!/bin/sh\nexit 0\n'); + writeExecutableScript( + ffmpegPath, + '#!/bin/sh\nout=""\nfor arg in "$@"; do out="$arg"; done\nif [ -n "$out" ]; then : > "$out"; fi\nexit 0\n', + ); + writeExecutableScript( + alassPath, + '#!/bin/sh\nsleep 0.2\nif [ ! -f "$1" ]; then echo "missing reference subtitle" >&2; exit 1; fi\nif [ ! -f "$2" ]; then echo "missing input subtitle" >&2; exit 1; fi\n: > "$3"\nexit 0\n', + ); + + const sentCommands: Array> = []; + const deps = makeDeps({ + getMpvClient: () => ({ + connected: true, + currentAudioStreamIndex: null, + send: (payload) => { + sentCommands.push(payload.command); + }, + requestProperty: async (name: string) => { + if (name === 'path') return videoPath; + if (name === 'sid') return 1; + if (name === 'secondary-sid') return null; + if (name === 'track-list') { + return [ + { + id: 1, + type: 'sub', + selected: true, + external: true, + 'external-filename': primaryPath, + }, + { + id: 2, + type: 'sub', + selected: false, + external: false, + 'ff-index': 2, + codec: 'ass', + }, + ]; + } + return null; + }, + }), + getResolvedConfig: () => ({ + defaultMode: 'manual', + alassPath, + ffsubsyncPath, + ffmpegPath, + }), + }); + + const result = await runSubsyncManual({ engine: 'alass', sourceTrackId: 2 }, deps); + + assert.equal(result.ok, true); + assert.equal(result.message, 'Subtitle synchronized with alass'); + assert.equal(sentCommands[0]?.[0], 'sub_add'); + assert.deepEqual(sentCommands[1], ['set_property', 'sub-delay', 0]); +}); + test('runSubsyncManual resolves string sid values from mpv stream properties', async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subsync-stream-sid-')); const ffsubsyncPath = path.join(tmpDir, 'ffsubsync.sh'); diff --git a/src/core/services/subsync.ts b/src/core/services/subsync.ts index e878fd2..44dd090 100644 --- a/src/core/services/subsync.ts +++ b/src/core/services/subsync.ts @@ -215,10 +215,10 @@ function cleanupTemporaryFile(extraction: FileExtractionResult): void { } catch {} } -function buildRetimedPath(subPath: string): string { +function buildRetimedPath(subPath: string, replace: boolean): string { + if (replace) return subPath; const parsed = path.parse(subPath); - const suffix = `_retimed_${Date.now()}`; - return path.join(parsed.dir, `${parsed.name}${suffix}${parsed.ext || '.srt'}`); + return path.join(parsed.dir, `${parsed.name}_retimed${parsed.ext || '.srt'}`); } async function runAlassSync( @@ -265,7 +265,8 @@ async function subsyncToReference( context.videoPath, context.primaryTrack, ); - const outputPath = buildRetimedPath(primaryExtraction.path); + const replacePrimary = resolved.replace !== false && !primaryExtraction.temporary; + const outputPath = buildRetimedPath(primaryExtraction.path, replacePrimary); try { let result: CommandResult; @@ -389,7 +390,7 @@ export async function runSubsyncManual( let sourceExtraction: FileExtractionResult | null = null; try { sourceExtraction = await extractSubtitleTrackToFile(ffmpegPath, context.videoPath, sourceTrack); - return subsyncToReference('alass', sourceExtraction.path, context, resolved, client); + return await subsyncToReference('alass', sourceExtraction.path, context, resolved, client); } finally { if (sourceExtraction) { cleanupTemporaryFile(sourceExtraction); diff --git a/src/subsync/utils.test.ts b/src/subsync/utils.test.ts index 9e04412..a0065f3 100644 --- a/src/subsync/utils.test.ts +++ b/src/subsync/utils.test.ts @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { codecToExtension } from './utils'; +import { codecToExtension, getSubsyncConfig } from './utils'; test('codecToExtension maps stream/web formats to ffmpeg extractable extensions', () => { assert.equal(codecToExtension('subrip'), 'srt'); @@ -12,3 +12,13 @@ test('codecToExtension maps stream/web formats to ffmpeg extractable extensions' test('codecToExtension returns null for unsupported codecs', () => { assert.equal(codecToExtension('unsupported-codec'), null); }); + +test('getSubsyncConfig defaults replace to true', () => { + assert.equal(getSubsyncConfig(undefined).replace, true); + assert.equal(getSubsyncConfig({}).replace, true); +}); + +test('getSubsyncConfig respects explicit replace value', () => { + assert.equal(getSubsyncConfig({ replace: false }).replace, false); + assert.equal(getSubsyncConfig({ replace: true }).replace, true); +}); diff --git a/src/subsync/utils.ts b/src/subsync/utils.ts index d94ae22..74635ca 100644 --- a/src/subsync/utils.ts +++ b/src/subsync/utils.ts @@ -20,6 +20,7 @@ export interface SubsyncResolvedConfig { alassPath: string; ffsubsyncPath: string; ffmpegPath: string; + replace?: boolean; } const DEFAULT_SUBSYNC_EXECUTABLE_PATHS = { @@ -55,6 +56,7 @@ export function getSubsyncConfig(config: SubsyncConfig | undefined): SubsyncReso 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), + replace: config?.replace ?? DEFAULT_CONFIG.subsync.replace, }; } diff --git a/src/types.ts b/src/types.ts index bc4d6e1..1a3e293 100644 --- a/src/types.ts +++ b/src/types.ts @@ -97,6 +97,7 @@ export interface SubsyncConfig { alass_path?: string; ffsubsync_path?: string; ffmpeg_path?: string; + replace?: boolean; } export interface StartupWarmupsConfig {