fix(subtitles): validate tools before local generation

- Detect PATH tools and report missing executable settings
- Check output directories before model downloads or audio extraction
This commit is contained in:
2026-09-11 01:46:18 -07:00
parent 89b0ca3c9c
commit 064e4b273f
22 changed files with 422 additions and 29 deletions
@@ -32,6 +32,12 @@ function fixture(argv: string[] = ['generate-subs', '/media/episode.mkv']) {
readConfig: () => ({ subtitleGeneration: { modelPath: '/models/external.bin' } }),
configPath: () => '/settings/SubMiner/config.jsonc',
resolveModel: async () => ({ kind: 'external', path: '/models/external.bin' }),
resolveTools: async () => ({
ffmpeg: { kind: 'found', path: '/usr/bin/ffmpeg' },
ffprobe: { kind: 'found', path: '/usr/bin/ffprobe' },
whisper: { kind: 'found', path: '/usr/bin/whisper-cli' },
vad: null,
}),
downloadModel: async () => {
throw new Error('Unexpected model download');
},
@@ -92,6 +98,22 @@ test('launcher uses the shared core and selected mpv audio then loads the genera
assert.equal(f.detached(), true);
});
test('launcher reports a missing executable before downloading a model', async () => {
const f = fixture(['generate-subs', '/media/episode.mkv', '--download-model']);
f.deps.resolveModel = async () => ({ kind: 'missing', path: '/models/missing.bin' });
f.deps.resolveTools = async () => ({
ffmpeg: { kind: 'found', path: '/usr/bin/ffmpeg' },
ffprobe: { kind: 'found', path: '/usr/bin/ffprobe' },
whisper: { kind: 'missing', message: 'whisper-cli was not found on PATH.' },
vad: null,
});
await assert.rejects(
runGenerateSubtitlesCommand(f.context, f.deps),
/whisper-cli was not found on PATH/,
);
assert.equal(f.generations.length, 0);
});
test('launcher never downloads a model without the explicit option', async () => {
const f = fixture();
f.deps.resolveModel = async () => ({ kind: 'missing', path: '/models/missing.bin' });
@@ -5,7 +5,9 @@ import {
downloadSubtitleGenerationModel,
generateJapaneseSubtitles,
resolveSubtitleGenerationModel,
resolveSubtitleGenerationTools,
} from '../../src/core/services/subtitle-generation.js';
import { requireSubtitleGenerationTools } from '../../src/core/services/subtitle-generation-tools.js';
import {
resolveSubtitleGenerationConfig,
type SubtitleGenerationProgress,
@@ -26,6 +28,7 @@ interface GenerationCommandDeps {
readConfig: typeof readLauncherMainConfigObject;
configPath: typeof resolveLauncherMainConfigPath;
resolveModel: typeof resolveSubtitleGenerationModel;
resolveTools: typeof resolveSubtitleGenerationTools;
downloadModel: typeof downloadSubtitleGenerationModel;
generate: typeof generateJapaneseSubtitles;
mpvCommand: typeof sendMpvCommandWithResponse;
@@ -36,6 +39,7 @@ const defaultDeps: GenerationCommandDeps = {
readConfig: readLauncherMainConfigObject,
configPath: resolveLauncherMainConfigPath,
resolveModel: resolveSubtitleGenerationModel,
resolveTools: resolveSubtitleGenerationTools,
downloadModel: downloadSubtitleGenerationModel,
generate: generateJapaneseSubtitles,
mpvCommand: sendMpvCommandWithResponse,
@@ -162,6 +166,8 @@ export async function runGenerateSubtitlesCommand(
? await readMpvAudioStream(context.mpvSocketPath, deps.mpvCommand)
: undefined);
const onProgress = createGenerationProgressReporter(write);
// Missing executables fail here, before any model download starts.
requireSubtitleGenerationTools(await deps.resolveTools(config));
const model = await deps.resolveModel(config, modelDirectory);
if (model.kind === 'invalid') throw new Error(model.message);
if (model.kind === 'missing') {