Restore multi-copy digit capture and add AniList selection (#56)

This commit is contained in:
2026-04-25 21:44:55 -07:00
committed by GitHub
parent 7ac51cd5e9
commit d8934647a9
140 changed files with 4097 additions and 326 deletions

View File

@@ -190,7 +190,43 @@ test('dictionary command forwards --dictionary and target path to app binary', (
});
assert.equal(handled, true);
assert.deepEqual(forwarded, [['--dictionary', '--dictionary-target', '/tmp/anime']]);
assert.deepEqual(forwarded, [['--start', '--dictionary', '--dictionary-target', '/tmp/anime']]);
});
test('dictionary command forwards candidate and selection modes to app binary', () => {
const candidatesContext = createContext();
candidatesContext.args.dictionary = true;
candidatesContext.args.dictionaryCandidates = true;
candidatesContext.args.dictionaryTarget = '/tmp/anime.mkv';
const selectContext = createContext();
selectContext.args.dictionary = true;
selectContext.args.dictionarySelect = true;
selectContext.args.dictionaryAnilistId = 21355;
selectContext.args.dictionaryTarget = '/tmp/anime.mkv';
const forwarded: string[][] = [];
runDictionaryCommand(candidatesContext, {
runAppCommandWithInherit: (_appPath, appArgs) => {
forwarded.push(appArgs);
},
});
runDictionaryCommand(selectContext, {
runAppCommandWithInherit: (_appPath, appArgs) => {
forwarded.push(appArgs);
},
});
assert.deepEqual(forwarded, [
['--start', '--dictionary-candidates', '--dictionary-target', '/tmp/anime.mkv'],
[
'--start',
'--dictionary-select',
'--dictionary-anilist-id',
'21355',
'--dictionary-target',
'/tmp/anime.mkv',
],
]);
});
test('dictionary command returns after app handoff starts', () => {

View File

@@ -18,7 +18,20 @@ export function runDictionaryCommand(
return false;
}
const forwarded = ['--dictionary'];
const forwarded = [
'--start',
args.dictionaryCandidates
? '--dictionary-candidates'
: args.dictionarySelect
? '--dictionary-select'
: '--dictionary',
];
if (args.dictionarySelect) {
if (!args.dictionaryAnilistId) {
throw new Error('Dictionary selection requires an AniList media ID.');
}
forwarded.push('--dictionary-anilist-id', String(args.dictionaryAnilistId));
}
if (typeof args.dictionaryTarget === 'string' && args.dictionaryTarget.trim()) {
forwarded.push('--dictionary-target', args.dictionaryTarget);
}

View File

@@ -44,6 +44,8 @@ function createContext(): LauncherCommandContext {
jellyfinPlay: false,
jellyfinDiscovery: false,
dictionary: false,
dictionaryCandidates: false,
dictionarySelect: false,
stats: false,
doctor: false,
doctorRefreshKnownWords: false,

View File

@@ -129,6 +129,9 @@ test('applyInvocationsToArgs maps config and jellyfin invocation state', () => {
dictionaryTriggered: false,
dictionaryTarget: null,
dictionaryLogLevel: null,
dictionaryCandidates: false,
dictionarySelect: false,
dictionaryAnilistId: null,
statsTriggered: false,
statsBackground: false,
statsStop: false,

View File

@@ -89,6 +89,14 @@ function parseDictionaryTarget(value: string): string {
return resolved;
}
function parseDictionaryAnilistId(value: string): number {
const id = Number.parseInt(value, 10);
if (!Number.isSafeInteger(id) || id <= 0 || String(id) !== value.trim()) {
fail(`Invalid AniList media ID: ${value}`);
}
return id;
}
export function createDefaultArgs(
launcherConfig: LauncherYoutubeSubgenConfig,
mpvConfig: LauncherMpvConfig = {},
@@ -138,6 +146,8 @@ export function createDefaultArgs(
jellyfinPlay: false,
jellyfinDiscovery: false,
dictionary: false,
dictionaryCandidates: false,
dictionarySelect: false,
stats: false,
statsBackground: false,
statsStop: false,
@@ -214,6 +224,11 @@ export function applyRootOptionsToArgs(
export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations): void {
if (invocations.dictionaryTriggered) parsed.dictionary = true;
if (invocations.dictionaryCandidates) parsed.dictionaryCandidates = true;
if (invocations.dictionarySelect) parsed.dictionarySelect = true;
if (invocations.dictionaryAnilistId) {
parsed.dictionaryAnilistId = parseDictionaryAnilistId(invocations.dictionaryAnilistId);
}
if (invocations.statsTriggered) parsed.stats = true;
if (invocations.statsBackground) parsed.statsBackground = true;
if (invocations.statsStop) parsed.statsStop = true;
@@ -222,6 +237,12 @@ export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations
if (invocations.statsCleanupLifetime) parsed.statsCleanupLifetime = true;
if (invocations.dictionaryTarget) {
parsed.dictionaryTarget = parseDictionaryTarget(invocations.dictionaryTarget);
} else if (
invocations.dictionaryTriggered &&
!invocations.dictionaryCandidates &&
!invocations.dictionarySelect
) {
fail('Dictionary target path is required.');
}
if (invocations.doctorTriggered) parsed.doctor = true;
if (invocations.doctorRefreshKnownWords) parsed.doctorRefreshKnownWords = true;

View File

@@ -27,6 +27,9 @@ export interface CliInvocations {
dictionaryTriggered: boolean;
dictionaryTarget: string | null;
dictionaryLogLevel: string | null;
dictionaryCandidates: boolean;
dictionarySelect: boolean;
dictionaryAnilistId: string | null;
statsTriggered: boolean;
statsBackground: boolean;
statsStop: boolean;
@@ -136,6 +139,9 @@ export function parseCliPrograms(
let dictionaryTriggered = false;
let dictionaryTarget: string | null = null;
let dictionaryLogLevel: string | null = null;
let dictionaryCandidates = false;
let dictionarySelect = false;
let dictionaryAnilistId: string | null = null;
let statsTriggered = false;
let statsBackground = false;
let statsStop = false;
@@ -207,13 +213,23 @@ export function parseCliPrograms(
commandProgram
.command('dictionary')
.alias('dict')
.description('Generate character dictionary ZIP from a file or directory target')
.argument('<target>', 'Video file path or anime directory path')
.description('Generate or correct character dictionary AniList matches')
.argument('[target]', 'Video file path or anime directory path')
.option('--candidates', 'List AniList candidates for a character dictionary target')
.option('--select <id>', 'Pin an AniList media ID for the target series')
.option('--log-level <level>', 'Log level')
.action((target: string, options: Record<string, unknown>) => {
.action((target: string | undefined, options: Record<string, unknown>) => {
const selectValue = typeof options.select === 'string' ? options.select.trim() : '';
const hasSelect = selectValue.length > 0;
if (options.candidates === true && hasSelect) {
throw new Error('Dictionary --candidates and --select cannot be combined.');
}
dictionaryTriggered = true;
dictionaryTarget = target;
dictionaryTarget = target ?? null;
dictionaryLogLevel = typeof options.logLevel === 'string' ? options.logLevel : null;
dictionaryCandidates = options.candidates === true;
dictionarySelect = hasSelect;
dictionaryAnilistId = hasSelect ? selectValue : null;
});
commandProgram
@@ -338,6 +354,9 @@ export function parseCliPrograms(
dictionaryTriggered,
dictionaryTarget,
dictionaryLogLevel,
dictionaryCandidates,
dictionarySelect,
dictionaryAnilistId,
statsTriggered,
statsBackground,
statsStop,

View File

@@ -464,7 +464,40 @@ test('dictionary command forwards --dictionary and --dictionary-target to app co
assert.equal(result.status, 0);
assert.equal(
fs.readFileSync(capturePath, 'utf8'),
`--dictionary\n--dictionary-target\n${targetPath}\n`,
`--start\n--dictionary\n--dictionary-target\n${targetPath}\n`,
);
});
});
test('dictionary command forwards manual AniList selection modes to app command path', () => {
withTempDir((root) => {
const homeDir = path.join(root, 'home');
const xdgConfigHome = path.join(root, 'xdg');
const appPath = path.join(root, 'fake-subminer.sh');
const capturePath = path.join(root, 'captured-args.txt');
fs.writeFileSync(
appPath,
'#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n',
);
fs.chmodSync(appPath, 0o755);
const env = {
...makeTestEnv(homeDir, xdgConfigHome),
SUBMINER_APPIMAGE_PATH: appPath,
SUBMINER_TEST_CAPTURE: capturePath,
};
const targetPath = path.join(root, 'anime.mkv');
fs.writeFileSync(targetPath, '');
assert.equal(runLauncher(['dictionary', '--candidates', targetPath], env).status, 0);
assert.equal(
fs.readFileSync(capturePath, 'utf8'),
`--start\n--dictionary-candidates\n--dictionary-target\n${targetPath}\n`,
);
assert.equal(runLauncher(['dictionary', '--select', '21355', targetPath], env).status, 0);
assert.equal(
fs.readFileSync(capturePath, 'utf8'),
`--start\n--dictionary-select\n--dictionary-anilist-id\n21355\n--dictionary-target\n${targetPath}\n`,
);
});
});

View File

@@ -415,6 +415,8 @@ function makeArgs(overrides: Partial<Args> = {}): Args {
jellyfinPlay: false,
jellyfinDiscovery: false,
dictionary: false,
dictionaryCandidates: false,
dictionarySelect: false,
stats: false,
doctor: false,
doctorRefreshKnownWords: false,

View File

@@ -99,6 +99,25 @@ test('parseArgs maps dictionary command and log-level override', () => {
assert.equal(parsed.logLevel, 'debug');
});
test('parseArgs maps dictionary candidate lookup and manual selection', () => {
const candidateParsed = parseArgs(['dictionary', '--candidates', '.'], 'subminer', {});
assert.equal(candidateParsed.dictionaryCandidates, true);
assert.equal(candidateParsed.dictionaryTarget, process.cwd());
const selectParsed = parseArgs(['dictionary', '--select', '21355', '.'], 'subminer', {});
assert.equal(selectParsed.dictionarySelect, true);
assert.equal(selectParsed.dictionaryAnilistId, 21355);
assert.equal(selectParsed.dictionaryTarget, process.cwd());
});
test('parseArgs rejects conflicting dictionary candidate and selection modes', () => {
const exit = withProcessExitIntercept(() => {
parseArgs(['dictionary', '--candidates', '--select', '21355', '.'], 'subminer', {});
});
assert.equal(exit.code, 1);
});
test('parseArgs maps stats command and log-level override', () => {
const parsed = parseArgs(['stats', '--log-level', 'debug'], 'subminer', {});

View File

@@ -121,6 +121,9 @@ export interface Args {
jellyfinPlay: boolean;
jellyfinDiscovery: boolean;
dictionary: boolean;
dictionaryCandidates: boolean;
dictionarySelect: boolean;
dictionaryAnilistId?: number;
stats: boolean;
statsBackground?: boolean;
statsStop?: boolean;