feat: add subtitle generation and bundle Bun launcher runtime

- Add local subtitle generation and card timing review workflows
- Package cross-platform Bun runtimes, launchers, licenses, and source
- Consolidate release packaging and refresh v0.19.6 documentation
This commit is contained in:
2026-09-15 21:59:10 -07:00
279 changed files with 23054 additions and 1603 deletions
+1
View File
@@ -249,6 +249,7 @@ export function applyRootOptionsToArgs(
}
export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations): void {
if (invocations.generateSubtitles) parsed.generateSubtitles = invocations.generateSubtitles;
if (invocations.dictionaryTriggered) parsed.dictionary = true;
if (invocations.dictionaryCandidates) parsed.dictionaryCandidates = true;
if (invocations.dictionarySelect) parsed.dictionarySelect = true;
@@ -1,6 +1,61 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { parseCliPrograms, resolveTopLevelCommand } from './cli-parser-builder.js';
import { SUBTITLE_GENERATION_MODELS } from '../../src/shared/subtitle-generation-model-catalog.js';
test('generate-subs accepts all downloadable multilingual model variants', () => {
for (const { id } of SUBTITLE_GENERATION_MODELS) {
const { invocations } = parseCliPrograms(['generate-subs', '--model', id], 'subminer');
assert.equal(invocations.generateSubtitles?.managedModel, id);
}
});
test('generate-subs parses local generation options separately from YouTube options', () => {
const result = parseCliPrograms(
[
'generate-subs',
'episode.mkv',
'--download-model',
'--model',
'medium',
'--output',
'episode.ja.srt',
'--audio-stream',
'2',
],
'subminer',
);
assert.deepEqual(result.invocations.generateSubtitles, {
mediaPath: 'episode.mkv',
downloadModel: true,
managedModel: 'medium',
modelPath: undefined,
outputPath: 'episode.ja.srt',
audioStreamIndex: 2,
});
assert.equal(
parseCliPrograms(['generate-subs'], 'subminer').invocations.generateSubtitles?.mediaPath,
undefined,
);
assert.equal(
parseCliPrograms(['generate-subs', '--model-path', '/models/ggml.bin'], 'subminer').invocations
.generateSubtitles?.modelPath,
'/models/ggml.bin',
);
});
test('generate-subs rejects conflicting models and malformed audio stream indices', () => {
for (const flags of [
['--model', 'tiny.en'],
['--model', 'small.en-q5_1'],
['--model', 'toString'],
['--audio-stream', '-1'],
['--audio-stream', '1.5'],
['--model-path', '/model.bin', '--download-model'],
['--model-path', '/model.bin', '--model', 'small'],
])
assert.throws(() => parseCliPrograms(['generate-subs', ...flags], 'subminer'), /Generation/);
});
test('resolveTopLevelCommand skips root options and finds the first command', () => {
assert.deepEqual(resolveTopLevelCommand(['--backend', 'macos', 'config', 'show']), {
@@ -94,6 +149,23 @@ test('parseCliPrograms lowers sync options into app-owned CLI tokens', () => {
assert.deepEqual(removeTemp.invocations.syncCliTokens, ['--remove-temp', '/tmp/subminer-sync-x']);
});
test('parseCliPrograms forwards transfer cache keys with both sync temp helpers', () => {
const key = 'a'.repeat(64);
for (const helper of [['--make-temp'], ['--remove-temp', '/tmp/subminer-sync-x']]) {
const tokens = [...helper, '--transfer-cache', key];
const result = parseCliPrograms(['sync', ...tokens], 'subminer');
assert.equal(result.invocations.syncTriggered, true);
assert.deepEqual(result.invocations.syncCliTokens, tokens);
}
});
test('parseCliPrograms rejects sync --ui with --transfer-cache', () => {
assert.throws(
() => parseCliPrograms(['sync', '--ui', '--transfer-cache', 'a'.repeat(64)], 'subminer'),
{ message: 'Sync --ui cannot be combined with other sync options.' },
);
});
test('parseCliPrograms leaves sync validation to the app parser', () => {
// Invalid combinations are forwarded; the app's parseSyncCliTokens rejects them.
const invalid = parseCliPrograms(['sync', 'media-box', '--push', '--pull'], 'subminer');
+57
View File
@@ -1,4 +1,9 @@
import { Command } from 'commander';
import type { Args } from '../types.js';
import {
isSubtitleGenerationModelId,
SUBTITLE_GENERATION_MODELS,
} from '../../src/shared/subtitle-generation-model-catalog.js';
export interface JellyfinInvocation {
action?: string;
@@ -20,6 +25,7 @@ export interface CommandActionInvocation {
}
export interface CliInvocations {
generateSubtitles?: Args['generateSubtitles'];
jellyfinInvocation: JellyfinInvocation | null;
configInvocation: CommandActionInvocation | null;
settingsInvocation: CommandActionInvocation | null;
@@ -120,6 +126,7 @@ function getTopLevelCommand(argv: string[]): { name: string; index: number } | n
'mpv',
'logs',
'dictionary',
'generate-subs',
'dict',
'stats',
'sync',
@@ -202,6 +209,7 @@ export function parseCliPrograms(
let texthookerOpenBrowser = false;
let doctorTriggered = false;
let texthookerTriggered = false;
let generateSubtitles: Args['generateSubtitles'];
const commandProgram = new Command();
commandProgram
@@ -226,6 +234,50 @@ export function parseCliPrograms(
.argument('[target]', 'file, directory, or URL');
applyRootOptions(rootProgram);
commandProgram
.command('generate-subs')
.description('Generate Japanese subtitles locally with whisper.cpp')
.argument('[video]', 'Local media file, or the current mpv file if omitted')
.option('--download-model', 'Download the selected managed model if missing')
.option('--model-path <path>', 'Use an existing whisper.cpp model file')
.option(
'--model <name>',
`Managed model: ${SUBTITLE_GENERATION_MODELS.map((model) => model.id).join(', ')}`,
)
.option('--output <path>', 'Save subtitles to this SRT path')
.option('--audio-stream <index>', 'Absolute audio stream index from ffprobe')
.action((mediaPath: string | undefined, options: Record<string, unknown>) => {
const model = options.model;
if (model !== undefined && !isSubtitleGenerationModelId(model)) {
throw new Error(
`Generation --model must be one of: ${SUBTITLE_GENERATION_MODELS.map((entry) => entry.id).join(', ')}.`,
);
}
if (
options.modelPath !== undefined &&
(model !== undefined || options.downloadModel === true)
) {
throw new Error(
'Generation --model-path cannot be combined with --model or --download-model.',
);
}
let audioStreamIndex: number | undefined;
if (typeof options.audioStream === 'string') {
audioStreamIndex = Number(options.audioStream);
if (!/^\d+$/.test(options.audioStream) || !Number.isSafeInteger(audioStreamIndex)) {
throw new Error('Generation --audio-stream must be a non-negative integer stream index.');
}
}
generateSubtitles = {
mediaPath,
downloadModel: options.downloadModel === true,
modelPath: typeof options.modelPath === 'string' ? options.modelPath : undefined,
managedModel: model,
outputPath: typeof options.output === 'string' ? options.output : undefined,
audioStreamIndex,
};
});
commandProgram
.command('jellyfin')
.alias('jf')
@@ -363,6 +415,7 @@ export function parseCliPrograms(
.option('--json', 'Emit machine-readable NDJSON progress output')
.option('--make-temp', 'Create a sync temp directory and print its path (used over SSH)')
.option('--remove-temp <dir>', 'Remove a sync temp directory created by --make-temp')
.option('--transfer-cache <key>', 'Reuse/save a received snapshot with temp helpers (internal)')
.option('--ui', 'Open the SubMiner sync window')
.option('--log-level <level>', 'Log level')
.action((rawHost: string | undefined, options: Record<string, unknown>) => {
@@ -384,6 +437,7 @@ export function parseCliPrograms(
check ||
makeTemp ||
removeTemp ||
options.transferCache !== undefined ||
options.remoteCmd !== undefined ||
options.db !== undefined ||
options.json === true ||
@@ -405,6 +459,8 @@ export function parseCliPrograms(
if (merge) tokens.push('--merge', merge);
if (makeTemp) tokens.push('--make-temp');
if (removeTemp) tokens.push('--remove-temp', removeTemp);
if (typeof options.transferCache === 'string')
tokens.push('--transfer-cache', options.transferCache);
if (push) tokens.push('--push');
if (pull) tokens.push('--pull');
if (check) tokens.push('--check');
@@ -520,6 +576,7 @@ export function parseCliPrograms(
options: selectedProgram.opts<Record<string, unknown>>(),
rootTarget: rootProgram.processedArgs[0],
invocations: {
generateSubtitles,
jellyfinInvocation,
configInvocation,
settingsInvocation,