feat: add AniList character dictionary sync

This commit is contained in:
2026-03-05 22:43:19 -08:00
parent 2f07c3407a
commit 33ded3c1bf
117 changed files with 3579 additions and 6443 deletions

View File

@@ -121,6 +121,14 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
assert.equal(hasExplicitCommand(anilistRetryQueue), true);
assert.equal(shouldStartApp(anilistRetryQueue), false);
const dictionary = parseArgs(['--dictionary']);
assert.equal(dictionary.dictionary, true);
assert.equal(hasExplicitCommand(dictionary), true);
assert.equal(shouldStartApp(dictionary), true);
const dictionaryTarget = parseArgs(['--dictionary', '--dictionary-target', '/tmp/example.mkv']);
assert.equal(dictionaryTarget.dictionary, true);
assert.equal(dictionaryTarget.dictionaryTarget, '/tmp/example.mkv');
const jellyfinLibraries = parseArgs(['--jellyfin-libraries']);
assert.equal(jellyfinLibraries.jellyfinLibraries, true);
assert.equal(hasExplicitCommand(jellyfinLibraries), true);

View File

@@ -24,6 +24,8 @@ export interface CliArgs {
anilistLogout: boolean;
anilistSetup: boolean;
anilistRetryQueue: boolean;
dictionary: boolean;
dictionaryTarget?: string;
jellyfin: boolean;
jellyfinLogin: boolean;
jellyfinLogout: boolean;
@@ -88,6 +90,7 @@ export function parseArgs(argv: string[]): CliArgs {
anilistLogout: false,
anilistSetup: false,
anilistRetryQueue: false,
dictionary: false,
jellyfin: false,
jellyfinLogin: false,
jellyfinLogout: false,
@@ -141,7 +144,14 @@ export function parseArgs(argv: string[]): CliArgs {
else if (arg === '--anilist-logout') args.anilistLogout = true;
else if (arg === '--anilist-setup') args.anilistSetup = true;
else if (arg === '--anilist-retry-queue') args.anilistRetryQueue = true;
else if (arg === '--jellyfin') args.jellyfin = true;
else if (arg === '--dictionary') args.dictionary = true;
else if (arg.startsWith('--dictionary-target=')) {
const value = arg.split('=', 2)[1];
if (value) args.dictionaryTarget = value;
} else if (arg === '--dictionary-target') {
const value = readValue(argv[i + 1]);
if (value) args.dictionaryTarget = value;
} else if (arg === '--jellyfin') args.jellyfin = true;
else if (arg === '--jellyfin-login') args.jellyfinLogin = true;
else if (arg === '--jellyfin-logout') args.jellyfinLogout = true;
else if (arg === '--jellyfin-libraries') args.jellyfinLibraries = true;
@@ -307,6 +317,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
args.anilistLogout ||
args.anilistSetup ||
args.anilistRetryQueue ||
args.dictionary ||
args.jellyfin ||
args.jellyfinLogin ||
args.jellyfinLogout ||
@@ -340,6 +351,7 @@ export function shouldStartApp(args: CliArgs): boolean {
args.triggerSubsync ||
args.markAudioCard ||
args.openRuntimeOptions ||
args.dictionary ||
args.jellyfin ||
args.jellyfinPlay ||
args.texthooker
@@ -376,6 +388,7 @@ export function shouldRunSettingsOnlyStartup(args: CliArgs): boolean {
!args.anilistLogout &&
!args.anilistSetup &&
!args.anilistRetryQueue &&
!args.dictionary &&
!args.jellyfin &&
!args.jellyfinLogin &&
!args.jellyfinLogout &&

View File

@@ -20,6 +20,8 @@ test('printHelp includes configured texthooker port', () => {
assert.match(output, /--refresh-known-words/);
assert.match(output, /--anilist-status/);
assert.match(output, /--anilist-retry-queue/);
assert.match(output, /--dictionary/);
assert.match(output, /--dictionary-target/);
assert.match(output, /--jellyfin\s+Open Jellyfin setup window/);
assert.match(output, /--jellyfin-login/);
assert.match(output, /--jellyfin-subtitles/);

View File

@@ -40,6 +40,8 @@ ${B}AniList${R}
--anilist-status Show token and retry queue status
--anilist-logout Clear stored AniList token
--anilist-retry-queue Retry next queued update
--dictionary Generate character dictionary ZIP for current anime
--dictionary-target ${D}PATH${R} Override dictionary source path (file or directory)
${B}Jellyfin${R}
--jellyfin Open Jellyfin setup window

View File

@@ -19,6 +19,11 @@ test('loads defaults when config is missing', () => {
assert.equal(config.ankiConnect.behavior.autoUpdateNewCards, true);
assert.deepEqual(config.ankiConnect.tags, ['SubMiner']);
assert.equal(config.anilist.enabled, false);
assert.equal(config.anilist.characterDictionary.enabled, false);
assert.equal(config.anilist.characterDictionary.refreshTtlHours, 168);
assert.equal(config.anilist.characterDictionary.maxLoaded, 3);
assert.equal(config.anilist.characterDictionary.evictionPolicy, 'delete');
assert.equal(config.anilist.characterDictionary.profileScope, 'all');
assert.equal(config.jellyfin.remoteControlEnabled, true);
assert.equal(config.jellyfin.remoteControlAutoConnect, true);
assert.equal(config.jellyfin.autoAnnounce, false);
@@ -298,6 +303,39 @@ test('parses anilist.enabled and warns for invalid value', () => {
assert.equal(service.getConfig().anilist.enabled, true);
});
test('parses anilist.characterDictionary config with clamping and enum validation', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"anilist": {
"characterDictionary": {
"enabled": true,
"refreshTtlHours": 0,
"maxLoaded": 1000,
"evictionPolicy": "remove",
"profileScope": "everywhere"
}
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
const config = service.getConfig();
const warnings = service.getWarnings();
assert.equal(config.anilist.characterDictionary.enabled, true);
assert.equal(config.anilist.characterDictionary.refreshTtlHours, 1);
assert.equal(config.anilist.characterDictionary.maxLoaded, 20);
assert.equal(config.anilist.characterDictionary.evictionPolicy, 'delete');
assert.equal(config.anilist.characterDictionary.profileScope, 'all');
assert.ok(warnings.some((warning) => warning.path === 'anilist.characterDictionary.refreshTtlHours'));
assert.ok(warnings.some((warning) => warning.path === 'anilist.characterDictionary.maxLoaded'));
assert.ok(warnings.some((warning) => warning.path === 'anilist.characterDictionary.evictionPolicy'));
assert.ok(warnings.some((warning) => warning.path === 'anilist.characterDictionary.profileScope'));
});
test('parses jellyfin remote control fields', () => {
const dir = makeTempDir();
fs.writeFileSync(
@@ -1292,6 +1330,7 @@ test('template generator includes known keys', () => {
assert.match(output, /"discordPresence":/);
assert.match(output, /"startupWarmups":/);
assert.match(output, /"youtubeSubgen":/);
assert.match(output, /"characterDictionary":\s*\{/);
assert.match(output, /"preserveLineBreaks": false/);
assert.match(output, /"nPlusOne"\s*:\s*\{/);
assert.match(output, /"nPlusOne": "#c6a0f6"/);

View File

@@ -86,6 +86,13 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
anilist: {
enabled: false,
accessToken: '',
characterDictionary: {
enabled: false,
refreshTtlHours: 168,
maxLoaded: 3,
evictionPolicy: 'delete',
profileScope: 'all',
},
},
jellyfin: {
enabled: false,

View File

@@ -22,6 +22,7 @@ test('config option registry includes critical paths and has unique entries', ()
'subtitleStyle.enableJlpt',
'subtitleStyle.autoPauseVideoOnYomitanPopup',
'ankiConnect.enabled',
'anilist.characterDictionary.enabled',
'immersionTracking.enabled',
]) {
assert.ok(paths.includes(requiredPath), `missing config path: ${requiredPath}`);

View File

@@ -135,6 +135,39 @@ export function buildIntegrationConfigOptionRegistry(
description:
'Optional explicit AniList access token override; leave empty to use locally stored token from setup.',
},
{
path: 'anilist.characterDictionary.enabled',
kind: 'boolean',
defaultValue: defaultConfig.anilist.characterDictionary.enabled,
description:
'Enable automatic Yomitan character dictionary sync for currently watched AniList media.',
},
{
path: 'anilist.characterDictionary.refreshTtlHours',
kind: 'number',
defaultValue: defaultConfig.anilist.characterDictionary.refreshTtlHours,
description: 'TTL in hours before refreshing the currently watched media dictionary.',
},
{
path: 'anilist.characterDictionary.maxLoaded',
kind: 'number',
defaultValue: defaultConfig.anilist.characterDictionary.maxLoaded,
description: 'Maximum number of auto-synced AniList dictionaries kept loaded at once.',
},
{
path: 'anilist.characterDictionary.evictionPolicy',
kind: 'enum',
enumValues: ['disable', 'delete'],
defaultValue: defaultConfig.anilist.characterDictionary.evictionPolicy,
description: 'Eviction behavior when maxLoaded is exceeded.',
},
{
path: 'anilist.characterDictionary.profileScope',
kind: 'enum',
enumValues: ['all', 'active'],
defaultValue: defaultConfig.anilist.characterDictionary.profileScope,
description: 'Yomitan profile scope for dictionary enable/disable updates.',
},
{
path: 'jellyfin.enabled',
kind: 'boolean',

View File

@@ -104,7 +104,11 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
},
{
title: 'Anilist',
description: ['Anilist API credentials and update behavior.'],
description: [
'Anilist API credentials and update behavior.',
'Includes optional auto-sync for per-media character dictionaries in bundled Yomitan.',
'Character dictionaries are keyed by AniList media ID (no season/franchise merge).',
],
key: 'anilist',
},
{

View File

@@ -23,6 +23,115 @@ export function applyIntegrationConfig(context: ResolveContext): void {
'Expected string.',
);
}
if (isObject(src.anilist.characterDictionary)) {
const characterDictionary = src.anilist.characterDictionary;
const dictionaryEnabled = asBoolean(characterDictionary.enabled);
if (dictionaryEnabled !== undefined) {
resolved.anilist.characterDictionary.enabled = dictionaryEnabled;
} else if (characterDictionary.enabled !== undefined) {
warn(
'anilist.characterDictionary.enabled',
characterDictionary.enabled,
resolved.anilist.characterDictionary.enabled,
'Expected boolean.',
);
}
const refreshTtlHours = asNumber(characterDictionary.refreshTtlHours);
if (refreshTtlHours !== undefined) {
const normalized = Math.min(24 * 365, Math.max(1, Math.floor(refreshTtlHours)));
if (normalized !== refreshTtlHours) {
warn(
'anilist.characterDictionary.refreshTtlHours',
characterDictionary.refreshTtlHours,
normalized,
'Out of range; clamped to 1..8760 hours.',
);
}
resolved.anilist.characterDictionary.refreshTtlHours = normalized;
} else if (characterDictionary.refreshTtlHours !== undefined) {
warn(
'anilist.characterDictionary.refreshTtlHours',
characterDictionary.refreshTtlHours,
resolved.anilist.characterDictionary.refreshTtlHours,
'Expected number.',
);
}
const maxLoaded = asNumber(characterDictionary.maxLoaded);
if (maxLoaded !== undefined) {
const normalized = Math.min(20, Math.max(1, Math.floor(maxLoaded)));
if (normalized !== maxLoaded) {
warn(
'anilist.characterDictionary.maxLoaded',
characterDictionary.maxLoaded,
normalized,
'Out of range; clamped to 1..20.',
);
}
resolved.anilist.characterDictionary.maxLoaded = normalized;
} else if (characterDictionary.maxLoaded !== undefined) {
warn(
'anilist.characterDictionary.maxLoaded',
characterDictionary.maxLoaded,
resolved.anilist.characterDictionary.maxLoaded,
'Expected number.',
);
}
const evictionPolicyRaw = asString(characterDictionary.evictionPolicy);
if (evictionPolicyRaw !== undefined) {
const evictionPolicy = evictionPolicyRaw.trim().toLowerCase();
if (evictionPolicy === 'disable' || evictionPolicy === 'delete') {
resolved.anilist.characterDictionary.evictionPolicy = evictionPolicy;
} else {
warn(
'anilist.characterDictionary.evictionPolicy',
characterDictionary.evictionPolicy,
resolved.anilist.characterDictionary.evictionPolicy,
"Expected one of: 'disable', 'delete'.",
);
}
} else if (characterDictionary.evictionPolicy !== undefined) {
warn(
'anilist.characterDictionary.evictionPolicy',
characterDictionary.evictionPolicy,
resolved.anilist.characterDictionary.evictionPolicy,
'Expected string.',
);
}
const profileScopeRaw = asString(characterDictionary.profileScope);
if (profileScopeRaw !== undefined) {
const profileScope = profileScopeRaw.trim().toLowerCase();
if (profileScope === 'all' || profileScope === 'active') {
resolved.anilist.characterDictionary.profileScope = profileScope;
} else {
warn(
'anilist.characterDictionary.profileScope',
characterDictionary.profileScope,
resolved.anilist.characterDictionary.profileScope,
"Expected one of: 'all', 'active'.",
);
}
} else if (characterDictionary.profileScope !== undefined) {
warn(
'anilist.characterDictionary.profileScope',
characterDictionary.profileScope,
resolved.anilist.characterDictionary.profileScope,
'Expected string.',
);
}
} else if (src.anilist.characterDictionary !== undefined) {
warn(
'anilist.characterDictionary',
src.anilist.characterDictionary,
resolved.anilist.characterDictionary,
'Expected object.',
);
}
}
if (isObject(src.jellyfin)) {

View File

@@ -62,3 +62,31 @@ test('discordPresence invalid values warn and keep defaults', () => {
assert.ok(warnedPaths.includes('discordPresence.updateIntervalMs'));
assert.ok(warnedPaths.includes('discordPresence.debounceMs'));
});
test('anilist character dictionary fields are parsed, clamped, and enum-validated', () => {
const { context, warnings } = createResolveContext({
anilist: {
characterDictionary: {
enabled: true,
refreshTtlHours: 0,
maxLoaded: 99,
evictionPolicy: 'purge' as never,
profileScope: 'global' as never,
},
},
});
applyIntegrationConfig(context);
assert.equal(context.resolved.anilist.characterDictionary.enabled, true);
assert.equal(context.resolved.anilist.characterDictionary.refreshTtlHours, 1);
assert.equal(context.resolved.anilist.characterDictionary.maxLoaded, 20);
assert.equal(context.resolved.anilist.characterDictionary.evictionPolicy, 'delete');
assert.equal(context.resolved.anilist.characterDictionary.profileScope, 'all');
const warnedPaths = warnings.map((warning) => warning.path);
assert.ok(warnedPaths.includes('anilist.characterDictionary.refreshTtlHours'));
assert.ok(warnedPaths.includes('anilist.characterDictionary.maxLoaded'));
assert.ok(warnedPaths.includes('anilist.characterDictionary.evictionPolicy'));
assert.ok(warnedPaths.includes('anilist.characterDictionary.profileScope'));
});

View File

@@ -30,6 +30,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
anilistLogout: false,
anilistSetup: false,
anilistRetryQueue: false,
dictionary: false,
jellyfin: false,
jellyfinLogin: false,
jellyfinLogout: false,

View File

@@ -30,6 +30,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
anilistLogout: false,
anilistSetup: false,
anilistRetryQueue: false,
dictionary: false,
jellyfin: false,
jellyfinLogin: false,
jellyfinLogout: false,
@@ -163,6 +164,13 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
calls.push('retryAnilistQueue');
return { ok: true, message: 'AniList retry processed.' };
},
generateCharacterDictionary: async () => ({
zipPath: '/tmp/anilist-1.zip',
fromCache: false,
mediaId: 1,
mediaTitle: 'Test',
entryCount: 10,
}),
runJellyfinCommand: async () => {
calls.push('runJellyfinCommand');
},
@@ -396,6 +404,52 @@ test('handleCliCommand runs AniList retry command', async () => {
assert.ok(calls.includes('log:AniList retry processed.'));
});
test('handleCliCommand runs dictionary generation command', async () => {
const { deps, calls } = createDeps({
hasMainWindow: () => false,
generateCharacterDictionary: async () => ({
zipPath: '/tmp/anilist-9253.zip',
fromCache: true,
mediaId: 9253,
mediaTitle: 'STEINS;GATE',
entryCount: 314,
}),
});
handleCliCommand(makeArgs({ dictionary: true }), 'initial', deps);
await new Promise((resolve) => setImmediate(resolve));
assert.ok(calls.includes('log:Generating character dictionary for current anime...'));
assert.ok(
calls.includes('log:Character dictionary cache hit: AniList 9253 (STEINS;GATE), entries=314'),
);
assert.ok(calls.includes('log:Dictionary ZIP: /tmp/anilist-9253.zip'));
assert.ok(calls.includes('stopApp'));
});
test('handleCliCommand forwards --dictionary-target to dictionary runtime', async () => {
let receivedTarget: string | undefined;
const { deps } = createDeps({
generateCharacterDictionary: async (targetPath?: string) => {
receivedTarget = targetPath;
return {
zipPath: '/tmp/anilist-100.zip',
fromCache: false,
mediaId: 100,
mediaTitle: 'Test',
entryCount: 1,
};
},
});
handleCliCommand(
makeArgs({ dictionary: true, dictionaryTarget: '/tmp/example-video.mkv' }),
'initial',
deps,
);
await new Promise((resolve) => setImmediate(resolve));
assert.equal(receivedTarget, '/tmp/example-video.mkv');
});
test('handleCliCommand does not dispatch runJellyfinCommand for non-Jellyfin commands', () => {
const nonJellyfinArgs: Array<Partial<CliArgs>> = [
{ start: true },

View File

@@ -53,6 +53,13 @@ export interface CliCommandServiceDeps {
lastError: string | null;
};
retryAnilistQueue: () => Promise<{ ok: boolean; message: string }>;
generateCharacterDictionary: (targetPath?: string) => Promise<{
zipPath: string;
fromCache: boolean;
mediaId: number;
mediaTitle: string;
entryCount: number;
}>;
runJellyfinCommand: (args: CliArgs) => Promise<void>;
printHelp: () => void;
hasMainWindow: () => boolean;
@@ -134,6 +141,15 @@ export interface CliCommandDepsRuntimeOptions {
overlay: OverlayCliRuntime;
mining: MiningCliRuntime;
anilist: AnilistCliRuntime;
dictionary: {
generate: (targetPath?: string) => Promise<{
zipPath: string;
fromCache: boolean;
mediaId: number;
mediaTitle: string;
entryCount: number;
}>;
};
jellyfin: {
openSetup: () => void;
runCommand: (args: CliArgs) => Promise<void>;
@@ -202,6 +218,7 @@ export function createCliCommandDepsRuntime(
openJellyfinSetup: options.jellyfin.openSetup,
getAnilistQueueStatus: options.anilist.getQueueStatus,
retryAnilistQueue: options.anilist.retryQueueNow,
generateCharacterDictionary: options.dictionary.generate,
runJellyfinCommand: options.jellyfin.runCommand,
printHelp: options.ui.printHelp,
hasMainWindow: options.app.hasMainWindow,
@@ -239,50 +256,6 @@ export function handleCliCommand(
deps.setLogLevel?.(args.logLevel);
}
const hasNonStartAction =
args.stop ||
args.toggle ||
args.toggleVisibleOverlay ||
args.settings ||
args.show ||
args.hide ||
args.showVisibleOverlay ||
args.hideVisibleOverlay ||
args.copySubtitle ||
args.copySubtitleMultiple ||
args.mineSentence ||
args.mineSentenceMultiple ||
args.updateLastCardFromClipboard ||
args.refreshKnownWords ||
args.toggleSecondarySub ||
args.triggerFieldGrouping ||
args.triggerSubsync ||
args.markAudioCard ||
args.openRuntimeOptions ||
args.anilistStatus ||
args.anilistLogout ||
args.anilistSetup ||
args.anilistRetryQueue ||
args.jellyfin ||
args.jellyfinLogin ||
args.jellyfinLogout ||
args.jellyfinLibraries ||
args.jellyfinItems ||
args.jellyfinSubtitles ||
args.jellyfinPlay ||
args.jellyfinRemoteAnnounce ||
args.texthooker ||
args.help;
const ignoreStartOnly =
source === 'second-instance' &&
args.start &&
!hasNonStartAction &&
deps.isOverlayRuntimeInitialized();
if (ignoreStartOnly) {
deps.log('Ignoring --start because SubMiner is already running.');
return;
}
const shouldStart = args.start || args.toggle || args.toggleVisibleOverlay;
const needsOverlayRuntime = commandNeedsOverlayRuntime(args);
const shouldInitializeOverlayRuntime = needsOverlayRuntime || args.start;
@@ -402,6 +375,29 @@ export function handleCliCommand(
} else if (args.jellyfin) {
deps.openJellyfinSetup();
deps.log('Opened Jellyfin setup flow.');
} else if (args.dictionary) {
const shouldStopAfterRun = source === 'initial' && !deps.hasMainWindow();
deps.log('Generating character dictionary for current anime...');
deps
.generateCharacterDictionary(args.dictionaryTarget)
.then((result) => {
const cacheLabel = result.fromCache ? 'cache hit' : 'generated';
deps.log(
`Character dictionary ${cacheLabel}: AniList ${result.mediaId} (${result.mediaTitle}), entries=${result.entryCount}`,
);
deps.log(`Dictionary ZIP: ${result.zipPath}`);
})
.catch((error) => {
deps.error('generateCharacterDictionary failed:', error);
deps.warn(
`Dictionary generation failed: ${error instanceof Error ? error.message : String(error)}`,
);
})
.finally(() => {
if (shouldStopAfterRun) {
deps.stopApp();
}
});
} else if (args.anilistRetryQueue) {
const queueStatus = deps.getAnilistQueueStatus();
deps.log(

View File

@@ -30,6 +30,15 @@ export {
export { openYomitanSettingsWindow } from './yomitan-settings';
export { createTokenizerDepsRuntime, tokenizeSubtitle } from './tokenizer';
export { clearYomitanParserCachesForWindow } from './tokenizer/yomitan-parser-runtime';
export {
deleteYomitanDictionaryByTitle,
getYomitanDictionaryInfo,
getYomitanSettingsFull,
importYomitanDictionaryFromZip,
removeYomitanDictionarySettings,
setYomitanSettingsFull,
upsertYomitanDictionarySettings,
} from './tokenizer/yomitan-parser-runtime';
export { syncYomitanDefaultAnkiServer } from './tokenizer/yomitan-parser-runtime';
export { createSubtitleProcessingController } from './subtitle-processing-controller';
export { createFrequencyDictionaryLookup } from './frequency-dictionary';

View File

@@ -30,6 +30,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
anilistLogout: false,
anilistSetup: false,
anilistRetryQueue: false,
dictionary: false,
jellyfin: false,
jellyfinLogin: false,
jellyfinLogout: false,

View File

@@ -1,12 +1,25 @@
import assert from 'node:assert/strict';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import test from 'node:test';
import {
getYomitanDictionaryInfo,
importYomitanDictionaryFromZip,
deleteYomitanDictionaryByTitle,
removeYomitanDictionarySettings,
requestYomitanParseResults,
requestYomitanTermFrequencies,
syncYomitanDefaultAnkiServer,
upsertYomitanDictionarySettings,
} from './yomitan-parser-runtime';
function createDeps(executeJavaScript: (script: string) => Promise<unknown>) {
function createDeps(
executeJavaScript: (script: string) => Promise<unknown>,
options?: {
createYomitanExtensionWindow?: (pageName: string) => Promise<unknown>;
},
) {
const parserWindow = {
isDestroyed: () => false,
webContents: {
@@ -22,6 +35,7 @@ function createDeps(executeJavaScript: (script: string) => Promise<unknown>) {
setYomitanParserReadyPromise: () => undefined,
getYomitanParserInitPromise: () => null,
setYomitanParserInitPromise: () => undefined,
createYomitanExtensionWindow: options?.createYomitanExtensionWindow as never,
};
}
@@ -417,3 +431,126 @@ test('requestYomitanParseResults disables Yomitan MeCab parser path', async () =
assert.ok(parseScript, 'expected parseText request script');
assert.match(parseScript ?? '', /useMecabParser:\s*false/);
});
test('getYomitanDictionaryInfo requests dictionary info via backend action', async () => {
let scriptValue = '';
const deps = createDeps(async (script) => {
scriptValue = script;
return [{ title: 'SubMiner Character Dictionary (AniList 130298)', revision: '1' }];
});
const dictionaries = await getYomitanDictionaryInfo(deps, { error: () => undefined });
assert.equal(dictionaries.length, 1);
assert.equal(dictionaries[0]?.title, 'SubMiner Character Dictionary (AniList 130298)');
assert.match(scriptValue, /getDictionaryInfo/);
});
test('dictionary settings helpers upsert and remove dictionary entries', async () => {
const scripts: string[] = [];
const optionsFull = {
profileCurrent: 0,
profiles: [
{
options: {
dictionaries: [
{
name: 'SubMiner Character Dictionary (AniList 1)',
alias: 'SubMiner Character Dictionary (AniList 1)',
enabled: false,
},
],
},
},
],
};
const deps = createDeps(async (script) => {
scripts.push(script);
if (script.includes('optionsGetFull')) {
return JSON.parse(JSON.stringify(optionsFull));
}
if (script.includes('setAllSettings')) {
return true;
}
return null;
});
const title = 'SubMiner Character Dictionary (AniList 1)';
const upserted = await upsertYomitanDictionarySettings(title, 'all', deps, {
error: () => undefined,
});
const removed = await removeYomitanDictionarySettings(title, 'all', 'delete', deps, {
error: () => undefined,
});
assert.equal(upserted, true);
assert.equal(removed, true);
const setCalls = scripts.filter((script) => script.includes('setAllSettings')).length;
assert.equal(setCalls, 2);
});
test('importYomitanDictionaryFromZip uses settings automation bridge instead of custom backend action', async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-yomitan-import-'));
const zipPath = path.join(tempDir, 'dict.zip');
fs.writeFileSync(zipPath, Buffer.from('zip-bytes'));
const scripts: string[] = [];
const settingsWindow = {
isDestroyed: () => false,
destroy: () => undefined,
webContents: {
executeJavaScript: async (script: string) => {
scripts.push(script);
return true;
},
},
};
const deps = createDeps(async () => true, {
createYomitanExtensionWindow: async (pageName: string) => {
assert.equal(pageName, 'settings.html');
return settingsWindow;
},
});
const imported = await importYomitanDictionaryFromZip(zipPath, deps, {
error: () => undefined,
});
assert.equal(imported, true);
assert.equal(scripts.some((script) => script.includes('__subminerYomitanSettingsAutomation')), true);
assert.equal(scripts.some((script) => script.includes('importDictionaryArchiveBase64')), true);
assert.equal(scripts.some((script) => script.includes('subminerImportDictionary')), false);
});
test('deleteYomitanDictionaryByTitle uses settings automation bridge instead of custom backend action', async () => {
const scripts: string[] = [];
const settingsWindow = {
isDestroyed: () => false,
destroy: () => undefined,
webContents: {
executeJavaScript: async (script: string) => {
scripts.push(script);
return true;
},
},
};
const deps = createDeps(async () => true, {
createYomitanExtensionWindow: async (pageName: string) => {
assert.equal(pageName, 'settings.html');
return settingsWindow;
},
});
const deleted = await deleteYomitanDictionaryByTitle(
'SubMiner Character Dictionary (AniList 130298)',
deps,
{ error: () => undefined },
);
assert.equal(deleted, true);
assert.equal(scripts.some((script) => script.includes('__subminerYomitanSettingsAutomation')), true);
assert.equal(scripts.some((script) => script.includes('deleteDictionary')), true);
assert.equal(scripts.some((script) => script.includes('subminerDeleteDictionary')), false);
});

View File

@@ -1,4 +1,6 @@
import type { BrowserWindow, Extension } from 'electron';
import * as fs from 'fs';
import * as path from 'path';
interface LoggerLike {
error: (message: string, ...args: unknown[]) => void;
@@ -13,6 +15,12 @@ interface YomitanParserRuntimeDeps {
setYomitanParserReadyPromise: (promise: Promise<void> | null) => void;
getYomitanParserInitPromise: () => Promise<boolean> | null;
setYomitanParserInitPromise: (promise: Promise<boolean> | null) => void;
createYomitanExtensionWindow?: (pageName: string) => Promise<BrowserWindow | null>;
}
export interface YomitanDictionaryInfo {
title: string;
revision?: string | number;
}
export interface YomitanTermFrequency {
@@ -489,6 +497,93 @@ async function ensureYomitanParserWindow(
return initPromise;
}
async function createYomitanExtensionWindow(
pageName: string,
deps: YomitanParserRuntimeDeps,
logger: LoggerLike,
): Promise<BrowserWindow | null> {
if (typeof deps.createYomitanExtensionWindow === 'function') {
return await deps.createYomitanExtensionWindow(pageName);
}
const electron = await import('electron');
const yomitanExt = deps.getYomitanExt();
if (!yomitanExt) {
return null;
}
const { BrowserWindow, session } = electron;
const window = new BrowserWindow({
show: false,
width: 1200,
height: 800,
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
session: session.defaultSession,
},
});
try {
await new Promise<void>((resolve, reject) => {
window.webContents.once('did-finish-load', () => resolve());
window.webContents.once('did-fail-load', (_event, _errorCode, errorDescription) => {
reject(new Error(errorDescription));
});
void window
.loadURL(`chrome-extension://${yomitanExt.id}/${pageName}`)
.catch((error: Error) => reject(error));
});
return window;
} catch (err) {
logger.error(
`Failed to create hidden Yomitan ${pageName} window: ${(err as Error).message}`,
);
if (!window.isDestroyed()) {
window.destroy();
}
return null;
}
}
async function invokeYomitanSettingsAutomation<T>(
script: string,
deps: YomitanParserRuntimeDeps,
logger: LoggerLike,
): Promise<T | null> {
const settingsWindow = await createYomitanExtensionWindow('settings.html', deps, logger);
if (!settingsWindow || settingsWindow.isDestroyed()) {
return null;
}
try {
await settingsWindow.webContents.executeJavaScript(
`
(async () => {
const deadline = Date.now() + 10000;
while (Date.now() < deadline) {
if (globalThis.__subminerYomitanSettingsAutomation?.ready === true) {
return true;
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
throw new Error("Yomitan settings automation bridge did not become ready");
})();
`,
true,
);
return (await settingsWindow.webContents.executeJavaScript(script, true)) as T;
} catch (err) {
logger.error('Failed to drive Yomitan settings automation:', (err as Error).message);
return null;
} finally {
if (!settingsWindow.isDestroyed()) {
settingsWindow.destroy();
}
}
}
export async function requestYomitanParseResults(
text: string,
deps: YomitanParserRuntimeDeps,
@@ -963,3 +1058,320 @@ export async function syncYomitanDefaultAnkiServer(
return false;
}
}
function buildYomitanInvokeScript(actionLiteral: string, paramsLiteral: string): string {
return `
(async () => {
const invoke = (action, params) =>
new Promise((resolve, reject) => {
chrome.runtime.sendMessage({ action, params }, (response) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
return;
}
if (!response || typeof response !== "object") {
reject(new Error("Invalid response from Yomitan backend"));
return;
}
if (response.error) {
reject(new Error(response.error.message || "Yomitan backend error"));
return;
}
resolve(response.result);
});
});
return await invoke(${actionLiteral}, ${paramsLiteral});
})();
`;
}
async function invokeYomitanBackendAction<T>(
action: string,
params: unknown,
deps: YomitanParserRuntimeDeps,
logger: LoggerLike,
): Promise<T | null> {
const isReady = await ensureYomitanParserWindow(deps, logger);
const parserWindow = deps.getYomitanParserWindow();
if (!isReady || !parserWindow || parserWindow.isDestroyed()) {
return null;
}
const script = buildYomitanInvokeScript(
JSON.stringify(action),
params === undefined ? 'undefined' : JSON.stringify(params),
);
try {
return (await parserWindow.webContents.executeJavaScript(script, true)) as T;
} catch (err) {
logger.error(`Yomitan backend action failed (${action}):`, (err as Error).message);
return null;
}
}
function createDefaultDictionarySettings(name: string, enabled: boolean): Record<string, unknown> {
return {
name,
alias: name,
enabled,
allowSecondarySearches: false,
definitionsCollapsible: 'not-collapsible',
partsOfSpeechFilter: true,
useDeinflections: true,
styles: '',
};
}
function getTargetProfileIndices(
optionsFull: Record<string, unknown>,
profileScope: 'all' | 'active',
): number[] {
const profiles = Array.isArray(optionsFull.profiles) ? optionsFull.profiles : [];
if (profileScope === 'active') {
const profileCurrent =
typeof optionsFull.profileCurrent === 'number' && Number.isFinite(optionsFull.profileCurrent)
? Math.max(0, Math.floor(optionsFull.profileCurrent))
: 0;
return profileCurrent < profiles.length ? [profileCurrent] : [];
}
return profiles.map((_profile, index) => index);
}
export async function getYomitanDictionaryInfo(
deps: YomitanParserRuntimeDeps,
logger: LoggerLike,
): Promise<YomitanDictionaryInfo[]> {
const result = await invokeYomitanBackendAction<unknown>('getDictionaryInfo', undefined, deps, logger);
if (!Array.isArray(result)) {
return [];
}
return result
.filter((entry): entry is Record<string, unknown> => isObject(entry))
.map((entry) => {
const title = typeof entry.title === 'string' ? entry.title.trim() : '';
const revision = entry.revision;
return {
title,
revision:
typeof revision === 'string' || typeof revision === 'number' ? revision : undefined,
};
})
.filter((entry) => entry.title.length > 0);
}
export async function getYomitanSettingsFull(
deps: YomitanParserRuntimeDeps,
logger: LoggerLike,
): Promise<Record<string, unknown> | null> {
const result = await invokeYomitanBackendAction<unknown>('optionsGetFull', undefined, deps, logger);
return isObject(result) ? result : null;
}
export async function setYomitanSettingsFull(
value: Record<string, unknown>,
deps: YomitanParserRuntimeDeps,
logger: LoggerLike,
source = 'subminer',
): Promise<boolean> {
const result = await invokeYomitanBackendAction<unknown>(
'setAllSettings',
{ value, source },
deps,
logger,
);
return result !== null;
}
export async function importYomitanDictionaryFromZip(
zipPath: string,
deps: YomitanParserRuntimeDeps,
logger: LoggerLike,
): Promise<boolean> {
const normalizedZipPath = zipPath.trim();
if (!normalizedZipPath || !fs.existsSync(normalizedZipPath)) {
logger.error(`Dictionary ZIP not found: ${zipPath}`);
return false;
}
const archiveBase64 = fs.readFileSync(normalizedZipPath).toString('base64');
const script = `
(async () => {
await globalThis.__subminerYomitanSettingsAutomation.importDictionaryArchiveBase64(
${JSON.stringify(archiveBase64)},
${JSON.stringify(path.basename(normalizedZipPath))}
);
return true;
})();
`;
const result = await invokeYomitanSettingsAutomation<boolean>(script, deps, logger);
return result === true;
}
export async function deleteYomitanDictionaryByTitle(
dictionaryTitle: string,
deps: YomitanParserRuntimeDeps,
logger: LoggerLike,
): Promise<boolean> {
const normalizedTitle = dictionaryTitle.trim();
if (!normalizedTitle) {
return false;
}
const result = await invokeYomitanSettingsAutomation<boolean>(
`
(async () => {
await globalThis.__subminerYomitanSettingsAutomation.deleteDictionary(
${JSON.stringify(normalizedTitle)}
);
return true;
})();
`,
deps,
logger,
);
return result === true;
}
export async function upsertYomitanDictionarySettings(
dictionaryTitle: string,
profileScope: 'all' | 'active',
deps: YomitanParserRuntimeDeps,
logger: LoggerLike,
): Promise<boolean> {
const normalizedTitle = dictionaryTitle.trim();
if (!normalizedTitle) {
return false;
}
const optionsFull = await getYomitanSettingsFull(deps, logger);
if (!optionsFull) {
return false;
}
const profiles = Array.isArray(optionsFull.profiles) ? optionsFull.profiles : [];
const indices = getTargetProfileIndices(optionsFull, profileScope);
let changed = false;
for (const index of indices) {
const profile = profiles[index];
if (!isObject(profile)) {
continue;
}
if (!isObject(profile.options)) {
profile.options = {};
}
const profileOptions = profile.options as Record<string, unknown>;
if (!Array.isArray(profileOptions.dictionaries)) {
profileOptions.dictionaries = [];
}
const dictionaries = profileOptions.dictionaries as unknown[];
const existingIndex = dictionaries.findIndex(
(entry) =>
isObject(entry) &&
typeof (entry as { name?: unknown }).name === 'string' &&
((entry as { name: string }).name.trim() === normalizedTitle),
);
if (existingIndex >= 0) {
const existing = dictionaries[existingIndex] as Record<string, unknown>;
if (existing.enabled !== true) {
existing.enabled = true;
changed = true;
}
if (typeof existing.alias !== 'string' || existing.alias.trim().length === 0) {
existing.alias = normalizedTitle;
changed = true;
}
if (existingIndex > 0) {
dictionaries.splice(existingIndex, 1);
dictionaries.unshift(existing);
changed = true;
}
continue;
}
dictionaries.unshift(createDefaultDictionarySettings(normalizedTitle, true));
changed = true;
}
if (!changed) {
return false;
}
return await setYomitanSettingsFull(optionsFull, deps, logger);
}
export async function removeYomitanDictionarySettings(
dictionaryTitle: string,
profileScope: 'all' | 'active',
mode: 'delete' | 'disable',
deps: YomitanParserRuntimeDeps,
logger: LoggerLike,
): Promise<boolean> {
const normalizedTitle = dictionaryTitle.trim();
if (!normalizedTitle) {
return false;
}
const optionsFull = await getYomitanSettingsFull(deps, logger);
if (!optionsFull) {
return false;
}
const profiles = Array.isArray(optionsFull.profiles) ? optionsFull.profiles : [];
const indices = getTargetProfileIndices(optionsFull, profileScope);
let changed = false;
for (const index of indices) {
const profile = profiles[index];
if (!isObject(profile) || !isObject(profile.options)) {
continue;
}
const profileOptions = profile.options as Record<string, unknown>;
if (!Array.isArray(profileOptions.dictionaries)) {
continue;
}
const dictionaries = profileOptions.dictionaries as unknown[];
if (mode === 'delete') {
const before = dictionaries.length;
profileOptions.dictionaries = dictionaries.filter(
(entry) =>
!(
isObject(entry) &&
typeof (entry as { name?: unknown }).name === 'string' &&
(entry as { name: string }).name.trim() === normalizedTitle
),
);
if ((profileOptions.dictionaries as unknown[]).length !== before) {
changed = true;
}
continue;
}
for (const entry of dictionaries) {
if (
!isObject(entry) ||
typeof (entry as { name?: unknown }).name !== 'string' ||
(entry as { name: string }).name.trim() !== normalizedTitle
) {
continue;
}
const dictionaryEntry = entry as Record<string, unknown>;
if (dictionaryEntry.enabled !== false) {
dictionaryEntry.enabled = false;
changed = true;
}
}
}
if (!changed) {
return false;
}
return await setYomitanSettingsFull(optionsFull, deps, logger);
}

View File

@@ -1,3 +1,4 @@
import { createHash } from 'node:crypto';
import * as fs from 'fs';
import * as path from 'path';
@@ -17,6 +18,41 @@ function readManifestVersion(manifestPath: string): string | null {
}
}
export function hashDirectoryContents(dirPath: string): string | null {
try {
if (!fs.existsSync(dirPath) || !fs.statSync(dirPath).isDirectory()) {
return null;
}
const hash = createHash('sha256');
const queue = [''];
while (queue.length > 0) {
const relativeDir = queue.shift()!;
const absoluteDir = path.join(dirPath, relativeDir);
const entries = fs.readdirSync(absoluteDir, { withFileTypes: true });
entries.sort((a, b) => a.name.localeCompare(b.name));
for (const entry of entries) {
const relativePath = path.join(relativeDir, entry.name);
const normalizedRelativePath = relativePath.split(path.sep).join('/');
hash.update(normalizedRelativePath);
if (entry.isDirectory()) {
queue.push(relativePath);
continue;
}
if (!entry.isFile()) {
continue;
}
hash.update(fs.readFileSync(path.join(dirPath, relativePath)));
}
}
return hash.digest('hex');
} catch {
return null;
}
}
function areFilesEqual(sourcePath: string, targetPath: string): boolean {
if (!fs.existsSync(sourcePath) || !fs.existsSync(targetPath)) return false;
try {
@@ -49,5 +85,32 @@ export function shouldCopyYomitanExtension(sourceDir: string, targetDir: string)
}
}
return false;
const sourceHash = hashDirectoryContents(sourceDir);
const targetHash = hashDirectoryContents(targetDir);
return sourceHash === null || targetHash === null || sourceHash !== targetHash;
}
export function ensureExtensionCopy(sourceDir: string, userDataPath: string): {
targetDir: string;
copied: boolean;
} {
if (process.platform === 'win32') {
return { targetDir: sourceDir, copied: false };
}
const extensionsRoot = path.join(userDataPath, 'extensions');
const targetDir = path.join(extensionsRoot, 'yomitan');
let shouldCopy = !fs.existsSync(targetDir);
if (!shouldCopy) {
shouldCopy = hashDirectoryContents(sourceDir) !== hashDirectoryContents(targetDir);
}
if (shouldCopy) {
fs.mkdirSync(extensionsRoot, { recursive: true });
fs.rmSync(targetDir, { recursive: true, force: true });
fs.cpSync(sourceDir, targetDir, { recursive: true });
}
return { targetDir, copied: shouldCopy };
}

View File

@@ -4,7 +4,11 @@ import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { shouldCopyYomitanExtension } from './yomitan-extension-copy';
import { ensureExtensionCopy, shouldCopyYomitanExtension } from './yomitan-extension-copy';
function makeTempDir(prefix: string): string {
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
}
function writeFile(filePath: string, content: string): void {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
@@ -12,41 +16,66 @@ function writeFile(filePath: string, content: string): void {
}
test('shouldCopyYomitanExtension detects popup runtime script drift', () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'yomitan-copy-test-'));
const tempRoot = makeTempDir('subminer-yomitan-copy-');
const sourceDir = path.join(tempRoot, 'source');
const targetDir = path.join(tempRoot, 'target');
writeFile(path.join(sourceDir, 'manifest.json'), JSON.stringify({ version: '1.0.0' }));
writeFile(path.join(targetDir, 'manifest.json'), JSON.stringify({ version: '1.0.0' }));
writeFile(path.join(sourceDir, 'js', 'app', 'popup.js'), 'same-popup-script');
writeFile(path.join(targetDir, 'js', 'app', 'popup.js'), 'same-popup-script');
writeFile(path.join(sourceDir, 'js', 'display', 'popup-main.js'), 'source-popup-main');
writeFile(path.join(targetDir, 'js', 'display', 'popup-main.js'), 'target-popup-main');
assert.equal(shouldCopyYomitanExtension(sourceDir, targetDir), true);
});
test('shouldCopyYomitanExtension skips copy when versions and watched scripts match', () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'yomitan-copy-test-'));
test('shouldCopyYomitanExtension skips copy when extension contents match', () => {
const tempRoot = makeTempDir('subminer-yomitan-copy-');
const sourceDir = path.join(tempRoot, 'source');
const targetDir = path.join(tempRoot, 'target');
writeFile(path.join(sourceDir, 'manifest.json'), JSON.stringify({ version: '1.0.0' }));
writeFile(path.join(targetDir, 'manifest.json'), JSON.stringify({ version: '1.0.0' }));
writeFile(path.join(sourceDir, 'js', 'app', 'popup.js'), 'same-popup-script');
writeFile(path.join(targetDir, 'js', 'app', 'popup.js'), 'same-popup-script');
writeFile(path.join(sourceDir, 'js', 'display', 'popup-main.js'), 'same-popup-main');
writeFile(path.join(targetDir, 'js', 'display', 'popup-main.js'), 'same-popup-main');
writeFile(path.join(sourceDir, 'js', 'display', 'display.js'), 'same-display');
writeFile(path.join(targetDir, 'js', 'display', 'display.js'), 'same-display');
writeFile(path.join(sourceDir, 'js', 'display', 'display-audio.js'), 'same-display-audio');
writeFile(path.join(targetDir, 'js', 'display', 'display-audio.js'), 'same-display-audio');
assert.equal(shouldCopyYomitanExtension(sourceDir, targetDir), false);
});
test('ensureExtensionCopy refreshes copied extension when display files change', () => {
const sourceRoot = makeTempDir('subminer-yomitan-src-');
const userDataRoot = makeTempDir('subminer-yomitan-user-');
const sourceDir = path.join(sourceRoot, 'yomitan');
const targetDir = path.join(userDataRoot, 'extensions', 'yomitan');
fs.mkdirSync(path.join(sourceDir, 'js', 'display'), { recursive: true });
fs.mkdirSync(path.join(targetDir, 'js', 'display'), { recursive: true });
fs.writeFileSync(path.join(sourceDir, 'manifest.json'), JSON.stringify({ version: '1.0.0' }));
fs.writeFileSync(path.join(targetDir, 'manifest.json'), JSON.stringify({ version: '1.0.0' }));
fs.writeFileSync(
path.join(sourceDir, 'js', 'display', 'structured-content-generator.js'),
'new display code',
);
fs.writeFileSync(
path.join(targetDir, 'js', 'display', 'structured-content-generator.js'),
'old display code',
);
const result = ensureExtensionCopy(sourceDir, userDataRoot);
assert.equal(result.targetDir, targetDir);
assert.equal(result.copied, true);
assert.equal(
fs.readFileSync(path.join(targetDir, 'js', 'display', 'structured-content-generator.js'), 'utf8'),
'new display code',
);
});

View File

@@ -2,7 +2,7 @@ import { BrowserWindow, Extension, session } from 'electron';
import * as fs from 'fs';
import * as path from 'path';
import { createLogger } from '../../logger';
import { shouldCopyYomitanExtension } from './yomitan-extension-copy';
import { ensureExtensionCopy } from './yomitan-extension-copy';
const logger = createLogger('main:yomitan-extension-loader');
@@ -15,26 +15,6 @@ export interface YomitanExtensionLoaderDeps {
setYomitanExtension: (extension: Extension | null) => void;
}
function ensureExtensionCopy(sourceDir: string, userDataPath: string): string {
if (process.platform === 'win32') {
return sourceDir;
}
const extensionsRoot = path.join(userDataPath, 'extensions');
const targetDir = path.join(extensionsRoot, 'yomitan');
const shouldCopy = shouldCopyYomitanExtension(sourceDir, targetDir);
if (shouldCopy) {
fs.mkdirSync(extensionsRoot, { recursive: true });
fs.rmSync(targetDir, { recursive: true, force: true });
fs.cpSync(sourceDir, targetDir, { recursive: true });
logger.info(`Copied yomitan extension to ${targetDir}`);
}
return targetDir;
}
export async function loadYomitanExtension(
deps: YomitanExtensionLoaderDeps,
): Promise<Extension | null> {
@@ -60,7 +40,11 @@ export async function loadYomitanExtension(
return null;
}
extPath = ensureExtensionCopy(extPath, deps.userDataPath);
const extensionCopy = ensureExtensionCopy(extPath, deps.userDataPath);
if (extensionCopy.copied) {
logger.info(`Copied yomitan extension to ${extensionCopy.targetDir}`);
}
extPath = extensionCopy.targetDir;
const parserWindow = deps.getYomitanParserWindow();
if (parserWindow && !parserWindow.isDestroyed()) {

View File

@@ -0,0 +1,239 @@
import assert from 'node:assert/strict';
import path from 'node:path';
import test from 'node:test';
import { pathToFileURL } from 'node:url';
class FakeStyle {
private values = new Map<string, string>();
set width(value: string) {
this.values.set('width', value);
}
get width(): string {
return this.values.get('width') ?? '';
}
set height(value: string) {
this.values.set('height', value);
}
get height(): string {
return this.values.get('height') ?? '';
}
set border(value: string) {
this.values.set('border', value);
}
set borderRadius(value: string) {
this.values.set('borderRadius', value);
}
set paddingTop(value: string) {
this.values.set('paddingTop', value);
}
setProperty(name: string, value: string): void {
this.values.set(name, value);
}
removeProperty(name: string): void {
this.values.delete(name);
}
}
class FakeNode {
public childNodes: Array<FakeNode | FakeTextNode> = [];
public className = '';
public dataset: Record<string, string> = {};
public style = new FakeStyle();
public textContent: string | null = null;
public title = '';
public href = '';
public rel = '';
public target = '';
public width = 0;
public height = 0;
public parentNode: FakeNode | null = null;
constructor(public readonly tagName: string) {}
appendChild(node: FakeNode | FakeTextNode): FakeNode | FakeTextNode {
if (node instanceof FakeNode) {
node.parentNode = this;
}
this.childNodes.push(node);
return node;
}
addEventListener(): void {}
closest(selector: string): FakeNode | null {
if (!selector.startsWith('.')) {
return null;
}
const className = selector.slice(1);
let current: FakeNode | null = this;
while (current) {
if (current.className === className) {
return current;
}
current = current.parentNode;
}
return null;
}
removeAttribute(name: string): void {
if (name === 'src') {
return;
}
if (name === 'href') {
this.href = '';
}
}
}
class FakeImageElement extends FakeNode {
public onload: (() => void) | null = null;
public onerror: ((error: unknown) => void) | null = null;
private _src = '';
constructor() {
super('img');
}
set src(value: string) {
this._src = value;
this.onload?.();
}
get src(): string {
return this._src;
}
}
class FakeCanvasElement extends FakeNode {
constructor() {
super('canvas');
}
}
class FakeTextNode {
constructor(public readonly data: string) {}
}
class FakeDocument {
createElement(tagName: string): FakeNode {
if (tagName === 'img') {
return new FakeImageElement();
}
if (tagName === 'canvas') {
return new FakeCanvasElement();
}
return new FakeNode(tagName);
}
createTextNode(data: string): FakeTextNode {
return new FakeTextNode(data);
}
}
function findFirstByClass(node: FakeNode, className: string): FakeNode | null {
if (node.className === className) {
return node;
}
for (const child of node.childNodes) {
if (child instanceof FakeNode) {
const result = findFirstByClass(child, className);
if (result) {
return result;
}
}
}
return null;
}
test('StructuredContentGenerator uses direct img loading for popup glossary images', async () => {
const { DisplayContentManager } = await import(
pathToFileURL(
path.join(process.cwd(), 'vendor/yomitan/js/display/display-content-manager.js'),
).href
);
const { StructuredContentGenerator } = await import(
pathToFileURL(
path.join(process.cwd(), 'vendor/yomitan/js/display/structured-content-generator.js'),
).href
);
const createObjectURLCalls: string[] = [];
const revokeObjectURLCalls: string[] = [];
const originalHtmlImageElement = globalThis.HTMLImageElement;
const originalHtmlCanvasElement = globalThis.HTMLCanvasElement;
const originalCreateObjectURL = URL.createObjectURL;
const originalRevokeObjectURL = URL.revokeObjectURL;
globalThis.HTMLImageElement = FakeImageElement as unknown as typeof HTMLImageElement;
globalThis.HTMLCanvasElement = FakeCanvasElement as unknown as typeof HTMLCanvasElement;
URL.createObjectURL = (_blob: Blob) => {
const value = 'blob:test-image';
createObjectURLCalls.push(value);
return value;
};
URL.revokeObjectURL = (value: string) => {
revokeObjectURLCalls.push(value);
};
try {
const manager = new DisplayContentManager({
application: {
api: {
getMedia: async () => [
{
content: Buffer.from('png-bytes').toString('base64'),
mediaType: 'image/png',
},
],
},
},
});
const generator = new StructuredContentGenerator(
manager,
new FakeDocument(),
{
devicePixelRatio: 1,
navigator: { userAgent: 'Mozilla/5.0' },
},
);
const node = generator.createDefinitionImage(
{
tag: 'img',
path: 'img/test.png',
width: 8,
height: 11,
title: 'Alpha',
background: true,
},
'SubMiner Character Dictionary',
) as FakeNode;
await manager.executeMediaRequests();
const imageNode = findFirstByClass(node, 'gloss-image');
assert.ok(imageNode);
assert.equal(imageNode.tagName, 'img');
assert.equal((imageNode as FakeImageElement).src, 'blob:test-image');
assert.equal(node.dataset.imageLoadState, 'loaded');
assert.equal(node.dataset.hasImage, 'true');
assert.deepEqual(createObjectURLCalls, ['blob:test-image']);
manager.unloadAll();
assert.deepEqual(revokeObjectURLCalls, ['blob:test-image']);
} finally {
globalThis.HTMLImageElement = originalHtmlImageElement;
globalThis.HTMLCanvasElement = originalHtmlCanvasElement;
URL.createObjectURL = originalCreateObjectURL;
URL.revokeObjectURL = originalRevokeObjectURL;
}
});

View File

@@ -339,11 +339,14 @@ import {
createSubtitleProcessingController,
createTokenizerDepsRuntime,
cycleSecondarySubMode as cycleSecondarySubModeCore,
deleteYomitanDictionaryByTitle,
enforceOverlayLayerOrder as enforceOverlayLayerOrderCore,
ensureOverlayWindowLevel as ensureOverlayWindowLevelCore,
getYomitanDictionaryInfo,
handleMineSentenceDigit as handleMineSentenceDigitCore,
handleMultiCopyDigit as handleMultiCopyDigitCore,
hasMpvWebsocketPlugin,
importYomitanDictionaryFromZip,
initializeOverlayRuntime as initializeOverlayRuntimeCore,
jellyfinTicksToSecondsRuntime,
listJellyfinItemsRuntime,
@@ -358,6 +361,7 @@ import {
registerGlobalShortcuts as registerGlobalShortcutsCore,
replayCurrentSubtitleRuntime,
resolveJellyfinPlaybackPlanRuntime,
removeYomitanDictionarySettings,
runStartupBootstrapRuntime,
saveSubtitlePosition as saveSubtitlePositionCore,
clearYomitanParserCachesForWindow,
@@ -370,6 +374,7 @@ import {
showMpvOsdRuntime,
tokenizeSubtitle as tokenizeSubtitleCore,
triggerFieldGrouping as triggerFieldGroupingCore,
upsertYomitanDictionarySettings,
updateLastCardFromClipboard as updateLastCardFromClipboardCore,
} from './core/services';
import { createImmersionTrackerStartupHandler } from './main/runtime/immersion-startup';
@@ -416,6 +421,8 @@ import {
} from './main/jlpt-runtime';
import { createMediaRuntimeService } from './main/media-runtime';
import { createOverlayVisibilityRuntimeService } from './main/overlay-visibility-runtime';
import { createCharacterDictionaryRuntimeService } from './main/character-dictionary-runtime';
import { createCharacterDictionaryAutoSyncRuntimeService } from './main/runtime/character-dictionary-auto-sync';
import {
type AnilistMediaGuessRuntimeState,
type AppState,
@@ -1216,6 +1223,75 @@ const mediaRuntime = createMediaRuntimeService(
})(),
);
const characterDictionaryRuntime = createCharacterDictionaryRuntimeService({
userDataPath: USER_DATA_PATH,
getCurrentMediaPath: () => appState.currentMediaPath,
getCurrentMediaTitle: () => appState.currentMediaTitle,
resolveMediaPathForJimaku: (mediaPath) => mediaRuntime.resolveMediaPathForJimaku(mediaPath),
guessAnilistMediaInfo: (mediaPath, mediaTitle) => guessAnilistMediaInfo(mediaPath, mediaTitle),
now: () => Date.now(),
logInfo: (message) => logger.info(message),
logWarn: (message) => logger.warn(message),
});
const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRuntimeService({
userDataPath: USER_DATA_PATH,
getConfig: () => getResolvedConfig().anilist.characterDictionary,
generateCharacterDictionary: (options) =>
characterDictionaryRuntime.generateForCurrentMedia(undefined, options),
getYomitanDictionaryInfo: async () => {
await ensureYomitanExtensionLoaded();
return await getYomitanDictionaryInfo(getYomitanParserRuntimeDeps(), {
error: (message, ...args) => logger.error(message, ...args),
info: (message, ...args) => logger.info(message, ...args),
});
},
importYomitanDictionary: async (zipPath) => {
await ensureYomitanExtensionLoaded();
return await importYomitanDictionaryFromZip(zipPath, getYomitanParserRuntimeDeps(), {
error: (message, ...args) => logger.error(message, ...args),
info: (message, ...args) => logger.info(message, ...args),
});
},
deleteYomitanDictionary: async (dictionaryTitle) => {
await ensureYomitanExtensionLoaded();
return await deleteYomitanDictionaryByTitle(dictionaryTitle, getYomitanParserRuntimeDeps(), {
error: (message, ...args) => logger.error(message, ...args),
info: (message, ...args) => logger.info(message, ...args),
});
},
upsertYomitanDictionarySettings: async (dictionaryTitle, profileScope) => {
await ensureYomitanExtensionLoaded();
return await upsertYomitanDictionarySettings(
dictionaryTitle,
profileScope,
getYomitanParserRuntimeDeps(),
{
error: (message, ...args) => logger.error(message, ...args),
info: (message, ...args) => logger.info(message, ...args),
},
);
},
removeYomitanDictionarySettings: async (dictionaryTitle, profileScope, mode) => {
await ensureYomitanExtensionLoaded();
return await removeYomitanDictionarySettings(
dictionaryTitle,
profileScope,
mode,
getYomitanParserRuntimeDeps(),
{
error: (message, ...args) => logger.error(message, ...args),
info: (message, ...args) => logger.info(message, ...args),
},
);
},
now: () => Date.now(),
schedule: (fn, delayMs) => setTimeout(fn, delayMs),
clearSchedule: (timer) => clearTimeout(timer),
logInfo: (message) => logger.info(message),
logWarn: (message) => logger.warn(message),
});
const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService(
createBuildOverlayVisibilityRuntimeMainDepsHandler({
getMainWindow: () => overlayManager.getMainWindow(),
@@ -2204,7 +2280,10 @@ const { reloadConfig: reloadConfigHandler, appReadyRuntimeRunner } = composeAppR
initializeOverlayRuntime: () => initializeOverlayRuntime(),
handleInitialArgs: () => handleInitialArgs(),
shouldSkipHeavyStartup: () =>
Boolean(appState.initialArgs && shouldRunSettingsOnlyStartup(appState.initialArgs)),
Boolean(
appState.initialArgs &&
(shouldRunSettingsOnlyStartup(appState.initialArgs) || appState.initialArgs.dictionary),
),
createImmersionTracker: () => {
ensureImmersionTrackerStarted();
},
@@ -2373,6 +2452,9 @@ const {
syncImmersionMediaState: () => {
immersionMediaRuntime.syncFromCurrentMediaState();
},
scheduleCharacterDictionarySync: () => {
characterDictionaryAutoSyncRuntime.scheduleSync();
},
updateCurrentMediaTitle: (title) => {
mediaRuntime.updateCurrentMediaTitle(title);
},
@@ -2638,6 +2720,24 @@ function getPreferredYomitanAnkiServerUrl(): string {
return config.url;
}
function getYomitanParserRuntimeDeps() {
return {
getYomitanExt: () => appState.yomitanExt,
getYomitanParserWindow: () => appState.yomitanParserWindow,
setYomitanParserWindow: (window: BrowserWindow | null) => {
appState.yomitanParserWindow = window;
},
getYomitanParserReadyPromise: () => appState.yomitanParserReadyPromise,
setYomitanParserReadyPromise: (promise: Promise<void> | null) => {
appState.yomitanParserReadyPromise = promise;
},
getYomitanParserInitPromise: () => appState.yomitanParserInitPromise,
setYomitanParserInitPromise: (promise: Promise<boolean> | null) => {
appState.yomitanParserInitPromise = promise;
},
};
}
async function syncYomitanDefaultProfileAnkiServer(): Promise<void> {
const targetUrl = getPreferredYomitanAnkiServerUrl().trim();
if (!targetUrl || targetUrl === lastSyncedYomitanAnkiServer) {
@@ -2646,21 +2746,7 @@ async function syncYomitanDefaultProfileAnkiServer(): Promise<void> {
const synced = await syncYomitanDefaultAnkiServerCore(
targetUrl,
{
getYomitanExt: () => appState.yomitanExt,
getYomitanParserWindow: () => appState.yomitanParserWindow,
setYomitanParserWindow: (window) => {
appState.yomitanParserWindow = window;
},
getYomitanParserReadyPromise: () => appState.yomitanParserReadyPromise,
setYomitanParserReadyPromise: (promise) => {
appState.yomitanParserReadyPromise = promise;
},
getYomitanParserInitPromise: () => appState.yomitanParserInitPromise,
setYomitanParserInitPromise: (promise) => {
appState.yomitanParserInitPromise = promise;
},
},
getYomitanParserRuntimeDeps(),
{
error: (message, ...args) => {
logger.error(message, ...args);
@@ -3130,6 +3216,8 @@ const createCliCommandContextHandler = createCliCommandContextFactory({
openJellyfinSetupWindow: () => openJellyfinSetupWindow(),
getAnilistQueueStatus: () => anilistStateRuntime.getQueueStatusSnapshot(),
processNextAnilistRetryUpdate: () => processNextAnilistRetryUpdate(),
generateCharacterDictionary: (targetPath?: string) =>
characterDictionaryRuntime.generateForCurrentMedia(targetPath),
runJellyfinCommand: (argsFromCommand: CliArgs) => runJellyfinCommand(argsFromCommand),
openYomitanSettings: () => openYomitanSettings(),
cycleSecondarySubMode: () => handleCycleSecondarySubMode(),

View File

@@ -0,0 +1,346 @@
import assert from 'node:assert/strict';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import test from 'node:test';
import { createCharacterDictionaryRuntimeService } from './character-dictionary-runtime';
const GRAPHQL_URL = 'https://graphql.anilist.co';
const LOCAL_FILE_HEADER_SIGNATURE = 0x04034b50;
const CENTRAL_DIRECTORY_SIGNATURE = 0x02014b50;
const END_OF_CENTRAL_DIRECTORY_SIGNATURE = 0x06054b50;
const PNG_1X1 = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+nmX8AAAAASUVORK5CYII=',
'base64',
);
function makeTempDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-character-dictionary-'));
}
function readStoredZipEntry(zipPath: string, entryName: string): Buffer {
const archive = fs.readFileSync(zipPath);
let offset = 0;
while (offset + 4 <= archive.length) {
const signature = archive.readUInt32LE(offset);
if (
signature === CENTRAL_DIRECTORY_SIGNATURE ||
signature === END_OF_CENTRAL_DIRECTORY_SIGNATURE
) {
break;
}
if (signature !== LOCAL_FILE_HEADER_SIGNATURE) {
throw new Error(`Unexpected ZIP signature 0x${signature.toString(16)} at offset ${offset}`);
}
const compressionMethod = archive.readUInt16LE(offset + 8);
assert.equal(compressionMethod, 0, 'expected stored ZIP entry');
const compressedSize = archive.readUInt32LE(offset + 18);
const fileNameLength = archive.readUInt16LE(offset + 26);
const extraFieldLength = archive.readUInt16LE(offset + 28);
const fileNameStart = offset + 30;
const fileNameEnd = fileNameStart + fileNameLength;
const fileName = archive.subarray(fileNameStart, fileNameEnd).toString('utf8');
const dataStart = fileNameEnd + extraFieldLength;
const dataEnd = dataStart + compressedSize;
if (fileName === entryName) {
return archive.subarray(dataStart, dataEnd);
}
offset = dataEnd;
}
throw new Error(`ZIP entry not found: ${entryName}`);
}
test('generateForCurrentMedia emits structured-content glossary so image stays with text', async () => {
const userDataPath = makeTempDir();
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
if (url === GRAPHQL_URL) {
const body = JSON.parse(String(init?.body ?? '{}')) as {
query?: string;
variables?: Record<string, unknown>;
};
if (body.query?.includes('Page(perPage: 10)')) {
return new Response(
JSON.stringify({
data: {
Page: {
media: [
{
id: 130298,
episodes: 20,
title: {
romaji: 'Kage no Jitsuryokusha ni Naritakute!',
english: 'The Eminence in Shadow',
native: '陰の実力者になりたくて!',
},
},
],
},
},
}),
{
status: 200,
headers: { 'content-type': 'application/json' },
},
);
}
if (body.query?.includes('characters(page: $page')) {
return new Response(
JSON.stringify({
data: {
Media: {
title: {
romaji: 'Kage no Jitsuryokusha ni Naritakute!',
english: 'The Eminence in Shadow',
native: '陰の実力者になりたくて!',
},
characters: {
pageInfo: { hasNextPage: false },
edges: [
{
role: 'SUPPORTING',
node: {
id: 123,
description:
'__Race:__ Human Alexia Midgar is the second princess of the Kingdom of Midgar.',
image: {
large: 'https://example.com/alexia.png',
medium: null,
},
name: {
full: 'Alexia Midgar',
native: 'アレクシア・ミドガル',
},
},
},
],
},
},
},
}),
{
status: 200,
headers: { 'content-type': 'application/json' },
},
);
}
}
if (url === 'https://example.com/alexia.png') {
return new Response(PNG_1X1, {
status: 200,
headers: { 'content-type': 'image/png' },
});
}
throw new Error(`Unexpected fetch URL: ${url}`);
}) as typeof globalThis.fetch;
try {
const runtime = createCharacterDictionaryRuntimeService({
userDataPath,
getCurrentMediaPath: () => '/tmp/eminence-s01e05.mkv',
getCurrentMediaTitle: () => 'The Eminence in Shadow - S01E05',
resolveMediaPathForJimaku: (mediaPath) => mediaPath,
guessAnilistMediaInfo: async () => ({
title: 'The Eminence in Shadow',
episode: 5,
source: 'fallback',
}),
now: () => 1_700_000_000_000,
});
const result = await runtime.generateForCurrentMedia();
const termBank = JSON.parse(readStoredZipEntry(result.zipPath, 'term_bank_1.json').toString('utf8')) as Array<
[string, string, string, string, number, Array<string | Record<string, unknown>>, number, string]
>;
const alexia = termBank.find(([term]) => term === 'アレクシア');
assert.ok(alexia, 'expected compact native-name variant for character');
const glossary = alexia[5];
assert.equal(glossary.length, 1);
const entry = glossary[0] as {
type: string;
content: unknown[];
};
assert.equal(entry.type, 'structured-content');
assert.equal(Array.isArray(entry.content), true);
const image = entry.content[0] as Record<string, unknown>;
assert.equal(image.tag, 'img');
assert.equal(image.path, 'img/c123.png');
assert.equal(image.sizeUnits, 'em');
const descriptionLine = entry.content[5];
assert.equal(
descriptionLine,
'Race: Human Alexia Midgar is the second princess of the Kingdom of Midgar.',
);
const topLevelImageGlossaryEntry = glossary.find(
(item) => typeof item === 'object' && item !== null && (item as { type?: string }).type === 'image',
);
assert.equal(topLevelImageGlossaryEntry, undefined);
} finally {
globalThis.fetch = originalFetch;
}
});
test('generateForCurrentMedia regenerates dictionary when cached format version is stale', async () => {
const userDataPath = makeTempDir();
const dictionariesDir = path.join(userDataPath, 'character-dictionaries');
fs.mkdirSync(dictionariesDir, { recursive: true });
const staleZipPath = path.join(dictionariesDir, 'anilist-130298.zip');
fs.writeFileSync(staleZipPath, Buffer.from('not-a-real-zip'));
fs.writeFileSync(
path.join(dictionariesDir, 'cache.json'),
JSON.stringify(
{
anilistById: {
'130298': {
mediaId: 130298,
mediaTitle: 'The Eminence in Shadow',
entryCount: 1,
zipPath: staleZipPath,
updatedAt: 1_700_000_000_000,
formatVersion: 6,
dictionaryTitle: 'SubMiner Character Dictionary (AniList 130298)',
revision: 'stale-revision',
},
},
},
null,
2,
),
'utf8',
);
const originalFetch = globalThis.fetch;
let characterQueryCount = 0;
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
if (url === GRAPHQL_URL) {
const body = JSON.parse(String(init?.body ?? '{}')) as {
query?: string;
};
if (body.query?.includes('Page(perPage: 10)')) {
return new Response(
JSON.stringify({
data: {
Page: {
media: [
{
id: 130298,
episodes: 20,
title: {
romaji: 'Kage no Jitsuryokusha ni Naritakute!',
english: 'The Eminence in Shadow',
native: '陰の実力者になりたくて!',
},
},
],
},
},
}),
{
status: 200,
headers: { 'content-type': 'application/json' },
},
);
}
if (body.query?.includes('characters(page: $page')) {
characterQueryCount += 1;
return new Response(
JSON.stringify({
data: {
Media: {
title: {
romaji: 'Kage no Jitsuryokusha ni Naritakute!',
english: 'The Eminence in Shadow',
native: '陰の実力者になりたくて!',
},
characters: {
pageInfo: { hasNextPage: false },
edges: [
{
role: 'MAIN',
node: {
id: 321,
description: 'Alpha is the second-in-command of Shadow Garden.',
image: {
large: 'https://example.com/alpha.png',
medium: null,
},
name: {
full: 'Alpha',
native: 'アルファ',
},
},
},
],
},
},
},
}),
{
status: 200,
headers: { 'content-type': 'application/json' },
},
);
}
}
if (url === 'https://example.com/alpha.png') {
return new Response(PNG_1X1, {
status: 200,
headers: { 'content-type': 'image/png' },
});
}
throw new Error(`Unexpected fetch URL: ${url}`);
}) as typeof globalThis.fetch;
try {
const runtime = createCharacterDictionaryRuntimeService({
userDataPath,
getCurrentMediaPath: () => '/tmp/eminence-s01e05.mkv',
getCurrentMediaTitle: () => 'The Eminence in Shadow - S01E05',
resolveMediaPathForJimaku: (mediaPath) => mediaPath,
guessAnilistMediaInfo: async () => ({
title: 'The Eminence in Shadow',
episode: 5,
source: 'fallback',
}),
now: () => 1_700_000_000_100,
});
const result = await runtime.generateForCurrentMedia(undefined, {
refreshTtlMs: 60 * 60 * 1000,
});
assert.equal(result.fromCache, false);
assert.equal(characterQueryCount, 1);
const termBank = JSON.parse(readStoredZipEntry(result.zipPath, 'term_bank_1.json').toString('utf8')) as Array<
[string, string, string, string, number, Array<string | Record<string, unknown>>, number, string]
>;
const alpha = termBank.find(([term]) => term === 'アルファ');
assert.ok(alpha);
assert.equal((alpha[5][0] as { type?: string }).type, 'structured-content');
} finally {
globalThis.fetch = originalFetch;
}
});

View File

@@ -0,0 +1,955 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import type { AnilistMediaGuess } from '../core/services/anilist/anilist-updater';
const ANILIST_GRAPHQL_URL = 'https://graphql.anilist.co';
const HONORIFIC_SUFFIXES = [
'さん',
'様',
'先生',
'先輩',
'後輩',
'氏',
'君',
'くん',
'ちゃん',
'たん',
'坊',
'殿',
'博士',
'社長',
'部長',
] as const;
const VIDEO_EXTENSIONS = new Set([
'.mkv',
'.mp4',
'.avi',
'.webm',
'.mov',
'.flv',
'.wmv',
'.m4v',
'.ts',
'.m2ts',
]);
type CharacterDictionaryRole = 'main' | 'primary' | 'side' | 'appears';
type CharacterDictionaryCacheEntry = {
mediaId: number;
mediaTitle: string;
entryCount: number;
zipPath: string;
updatedAt: number;
formatVersion?: number;
dictionaryTitle?: string;
revision?: string;
};
type CharacterDictionaryCacheFile = {
anilistById: Record<string, CharacterDictionaryCacheEntry>;
};
const CHARACTER_DICTIONARY_FORMAT_VERSION = 8;
type AniListSearchResponse = {
Page?: {
media?: Array<{
id: number;
episodes?: number | null;
title?: {
romaji?: string | null;
english?: string | null;
native?: string | null;
};
}>;
};
};
type AniListCharacterPageResponse = {
Media?: {
title?: {
romaji?: string | null;
english?: string | null;
native?: string | null;
};
characters?: {
pageInfo?: {
hasNextPage?: boolean | null;
};
edges?: Array<{
role?: string | null;
node?: {
id: number;
description?: string | null;
image?: {
large?: string | null;
medium?: string | null;
} | null;
name?: {
full?: string | null;
native?: string | null;
} | null;
} | null;
} | null>;
} | null;
} | null;
};
type CharacterRecord = {
id: number;
role: CharacterDictionaryRole;
fullName: string;
nativeName: string;
description: string;
imageUrl: string | null;
};
type ZipEntry = {
name: string;
data: Buffer;
crc32: number;
localHeaderOffset: number;
};
export type CharacterDictionaryBuildResult = {
zipPath: string;
fromCache: boolean;
mediaId: number;
mediaTitle: string;
entryCount: number;
dictionaryTitle?: string;
revision?: string;
};
export type CharacterDictionaryGenerateOptions = {
refreshTtlMs?: number;
};
export interface CharacterDictionaryRuntimeDeps {
userDataPath: string;
getCurrentMediaPath: () => string | null;
getCurrentMediaTitle: () => string | null;
resolveMediaPathForJimaku: (mediaPath: string | null) => string | null;
guessAnilistMediaInfo: (
mediaPath: string | null,
mediaTitle: string | null,
) => Promise<AnilistMediaGuess | null>;
now: () => number;
logInfo?: (message: string) => void;
logWarn?: (message: string) => void;
}
type ResolvedAniListMedia = {
id: number;
title: string;
};
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function normalizeTitle(value: string): string {
return value.trim().toLowerCase().replace(/\s+/g, ' ');
}
function pickAniListSearchResult(
title: string,
episode: number | null,
media: Array<{
id: number;
episodes?: number | null;
title?: {
romaji?: string | null;
english?: string | null;
native?: string | null;
};
}>,
): ResolvedAniListMedia | null {
if (media.length === 0) return null;
const episodeFiltered =
typeof episode === 'number' && episode > 0
? media.filter((entry) => entry.episodes == null || entry.episodes >= episode)
: media;
const candidates = episodeFiltered.length > 0 ? episodeFiltered : media;
const normalizedInput = normalizeTitle(title);
const exact = candidates.find((entry) => {
const candidateTitles = [entry.title?.romaji, entry.title?.english, entry.title?.native]
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
.map((value) => normalizeTitle(value));
return candidateTitles.includes(normalizedInput);
});
const selected = exact ?? candidates[0]!;
const selectedTitle =
selected.title?.english?.trim() ||
selected.title?.romaji?.trim() ||
selected.title?.native?.trim() ||
title;
return {
id: selected.id,
title: selectedTitle,
};
}
function hasKanaOnly(value: string): boolean {
return /^[\u3040-\u309f\u30a0-\u30ffー]+$/.test(value);
}
function katakanaToHiragana(value: string): string {
let output = '';
for (const char of value) {
const code = char.charCodeAt(0);
if (code >= 0x30a1 && code <= 0x30f6) {
output += String.fromCharCode(code - 0x60);
continue;
}
output += char;
}
return output;
}
function buildReading(term: string): string {
const compact = term.replace(/\s+/g, '').trim();
if (!compact || !hasKanaOnly(compact)) {
return '';
}
return katakanaToHiragana(compact);
}
function buildNameTerms(character: CharacterRecord): string[] {
const base = new Set<string>();
const rawNames = [character.nativeName, character.fullName];
for (const rawName of rawNames) {
const name = rawName.trim();
if (!name) continue;
base.add(name);
const compact = name.replace(/[\s\u3000]+/g, '');
if (compact && compact !== name) {
base.add(compact);
}
const noMiddleDots = compact.replace(/[・・·•]/g, '');
if (noMiddleDots && noMiddleDots !== compact) {
base.add(noMiddleDots);
}
const split = name.split(/[\s\u3000]+/).filter((part) => part.trim().length > 0);
if (split.length === 2) {
base.add(split[0]!);
base.add(split[1]!);
}
const splitByMiddleDot = name
.split(/[・・·•]/)
.map((part) => part.trim())
.filter((part) => part.length > 0);
if (splitByMiddleDot.length >= 2) {
for (const part of splitByMiddleDot) {
base.add(part);
}
}
}
const withHonorifics = new Set<string>();
for (const entry of base) {
withHonorifics.add(entry);
for (const suffix of HONORIFIC_SUFFIXES) {
withHonorifics.add(`${entry}${suffix}`);
}
}
return [...withHonorifics].filter((entry) => entry.trim().length > 0);
}
function stripDescription(value: string): string {
return value.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
}
function normalizeDescription(value: string): string {
const stripped = stripDescription(value);
if (!stripped) return '';
return stripped
.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '$1')
.replace(/https?:\/\/\S+/g, '')
.replace(/__([^_]+)__/g, '$1')
.replace(/\*\*([^*]+)\*\*/g, '$1')
.replace(/~!/g, '')
.replace(/!~/g, '')
.replace(/\s+/g, ' ')
.trim();
}
function roleInfo(role: CharacterDictionaryRole): { tag: string; score: number } {
if (role === 'main') return { tag: 'main', score: 100 };
if (role === 'primary') return { tag: 'primary', score: 75 };
if (role === 'side') return { tag: 'side', score: 50 };
return { tag: 'appears', score: 25 };
}
function mapRole(input: string | null | undefined): CharacterDictionaryRole {
const value = (input || '').trim().toUpperCase();
if (value === 'MAIN') return 'main';
if (value === 'BACKGROUND') return 'appears';
if (value === 'SUPPORTING') return 'side';
return 'primary';
}
function roleLabel(role: CharacterDictionaryRole): string {
if (role === 'main') return 'Main';
if (role === 'primary') return 'Primary';
if (role === 'side') return 'Side';
return 'Appears';
}
function inferImageExt(contentType: string | null): string {
const normalized = (contentType || '').toLowerCase();
if (normalized.includes('png')) return 'png';
if (normalized.includes('gif')) return 'gif';
if (normalized.includes('webp')) return 'webp';
return 'jpg';
}
function ensureDir(dirPath: string): void {
if (fs.existsSync(dirPath)) return;
fs.mkdirSync(dirPath, { recursive: true });
}
function expandUserPath(input: string): string {
if (input.startsWith('~')) {
return path.join(os.homedir(), input.slice(1));
}
return input;
}
function isVideoFile(filePath: string): boolean {
const ext = path.extname(filePath).toLowerCase();
return VIDEO_EXTENSIONS.has(ext);
}
function findFirstVideoFileInDirectory(directoryPath: string): string | null {
const queue: string[] = [directoryPath];
while (queue.length > 0) {
const current = queue.shift()!;
let entries: fs.Dirent[] = [];
try {
entries = fs.readdirSync(current, { withFileTypes: true });
} catch {
continue;
}
entries.sort((a, b) => a.name.localeCompare(b.name));
for (const entry of entries) {
const fullPath = path.join(current, entry.name);
if (entry.isFile() && isVideoFile(fullPath)) {
return fullPath;
}
if (entry.isDirectory() && !entry.name.startsWith('.')) {
queue.push(fullPath);
}
}
}
return null;
}
function resolveDictionaryGuessInputs(targetPath: string): {
mediaPath: string;
mediaTitle: string | null;
} {
const trimmed = targetPath.trim();
if (!trimmed) {
throw new Error('Dictionary target path is empty.');
}
const resolvedPath = path.resolve(expandUserPath(trimmed));
let stats: fs.Stats;
try {
stats = fs.statSync(resolvedPath);
} catch {
throw new Error(`Dictionary target path not found: ${targetPath}`);
}
if (stats.isFile()) {
return {
mediaPath: resolvedPath,
mediaTitle: path.basename(resolvedPath),
};
}
if (stats.isDirectory()) {
const firstVideo = findFirstVideoFileInDirectory(resolvedPath);
if (firstVideo) {
return {
mediaPath: firstVideo,
mediaTitle: path.basename(firstVideo),
};
}
return {
mediaPath: resolvedPath,
mediaTitle: path.basename(resolvedPath),
};
}
throw new Error(`Dictionary target must be a file or directory path: ${targetPath}`);
}
function readCache(cachePath: string): CharacterDictionaryCacheFile {
try {
const raw = fs.readFileSync(cachePath, 'utf8');
const parsed = JSON.parse(raw) as CharacterDictionaryCacheFile;
if (!parsed || typeof parsed !== 'object' || !parsed.anilistById) {
return { anilistById: {} };
}
return parsed;
} catch {
return { anilistById: {} };
}
}
function writeCache(cachePath: string, cache: CharacterDictionaryCacheFile): void {
ensureDir(path.dirname(cachePath));
fs.writeFileSync(cachePath, JSON.stringify(cache, null, 2), 'utf8');
}
function createDefinitionGlossary(
character: CharacterRecord,
mediaTitle: string,
imagePath: string | null,
): Array<string | Record<string, unknown>> {
const displayName = character.nativeName || character.fullName || `Character ${character.id}`;
const lines: string[] = [`${displayName} [${roleLabel(character.role)}]`, `${mediaTitle} · AniList`];
const description = normalizeDescription(character.description);
if (description) {
lines.push(description);
}
if (!imagePath) {
return [lines.join('\n')];
}
const content: Array<string | Record<string, unknown>> = [
{
tag: 'img',
path: imagePath,
width: 8,
height: 11,
sizeUnits: 'em',
title: displayName,
alt: displayName,
description: `${displayName} · ${mediaTitle}`,
collapsed: false,
collapsible: false,
background: true,
},
];
for (let i = 0; i < lines.length; i += 1) {
if (i > 0) {
content.push({ tag: 'br' });
}
content.push(lines[i]!);
}
return [
{
type: 'structured-content',
content,
},
];
}
function buildTermEntry(
term: string,
reading: string,
role: CharacterDictionaryRole,
glossary: Array<string | Record<string, unknown>>,
): Array<string | number | Array<string | Record<string, unknown>>> {
const { tag, score } = roleInfo(role);
return [term, reading, `name ${tag}`, '', score, glossary, 0, ''];
}
const CRC32_TABLE = (() => {
const table = new Uint32Array(256);
for (let i = 0; i < 256; i += 1) {
let crc = i;
for (let j = 0; j < 8; j += 1) {
crc = (crc & 1) !== 0 ? 0xedb88320 ^ (crc >>> 1) : crc >>> 1;
}
table[i] = crc >>> 0;
}
return table;
})();
function crc32(data: Buffer): number {
let crc = 0xffffffff;
for (const byte of data) {
crc = CRC32_TABLE[(crc ^ byte) & 0xff]! ^ (crc >>> 8);
}
return (crc ^ 0xffffffff) >>> 0;
}
function createStoredZip(files: Array<{ name: string; data: Buffer }>): Buffer {
const chunks: Buffer[] = [];
const entries: ZipEntry[] = [];
let offset = 0;
for (const file of files) {
const fileName = Buffer.from(file.name, 'utf8');
const fileData = file.data;
const fileCrc32 = crc32(fileData);
const local = Buffer.alloc(30 + fileName.length);
let cursor = 0;
local.writeUInt32LE(0x04034b50, cursor);
cursor += 4;
local.writeUInt16LE(20, cursor);
cursor += 2;
local.writeUInt16LE(0, cursor);
cursor += 2;
local.writeUInt16LE(0, cursor);
cursor += 2;
local.writeUInt16LE(0, cursor);
cursor += 2;
local.writeUInt16LE(0, cursor);
cursor += 2;
local.writeUInt32LE(fileCrc32, cursor);
cursor += 4;
local.writeUInt32LE(fileData.length, cursor);
cursor += 4;
local.writeUInt32LE(fileData.length, cursor);
cursor += 4;
local.writeUInt16LE(fileName.length, cursor);
cursor += 2;
local.writeUInt16LE(0, cursor);
cursor += 2;
fileName.copy(local, cursor);
chunks.push(local, fileData);
entries.push({
name: file.name,
data: fileData,
crc32: fileCrc32,
localHeaderOffset: offset,
});
offset += local.length + fileData.length;
}
const centralStart = offset;
const centralChunks: Buffer[] = [];
for (const entry of entries) {
const fileName = Buffer.from(entry.name, 'utf8');
const central = Buffer.alloc(46 + fileName.length);
let cursor = 0;
central.writeUInt32LE(0x02014b50, cursor);
cursor += 4;
central.writeUInt16LE(20, cursor);
cursor += 2;
central.writeUInt16LE(20, cursor);
cursor += 2;
central.writeUInt16LE(0, cursor);
cursor += 2;
central.writeUInt16LE(0, cursor);
cursor += 2;
central.writeUInt16LE(0, cursor);
cursor += 2;
central.writeUInt16LE(0, cursor);
cursor += 2;
central.writeUInt32LE(entry.crc32, cursor);
cursor += 4;
central.writeUInt32LE(entry.data.length, cursor);
cursor += 4;
central.writeUInt32LE(entry.data.length, cursor);
cursor += 4;
central.writeUInt16LE(fileName.length, cursor);
cursor += 2;
central.writeUInt16LE(0, cursor);
cursor += 2;
central.writeUInt16LE(0, cursor);
cursor += 2;
central.writeUInt16LE(0, cursor);
cursor += 2;
central.writeUInt16LE(0, cursor);
cursor += 2;
central.writeUInt32LE(0, cursor);
cursor += 4;
central.writeUInt32LE(entry.localHeaderOffset, cursor);
cursor += 4;
fileName.copy(central, cursor);
centralChunks.push(central);
offset += central.length;
}
const centralSize = offset - centralStart;
const end = Buffer.alloc(22);
let cursor = 0;
end.writeUInt32LE(0x06054b50, cursor);
cursor += 4;
end.writeUInt16LE(0, cursor);
cursor += 2;
end.writeUInt16LE(0, cursor);
cursor += 2;
end.writeUInt16LE(entries.length, cursor);
cursor += 2;
end.writeUInt16LE(entries.length, cursor);
cursor += 2;
end.writeUInt32LE(centralSize, cursor);
cursor += 4;
end.writeUInt32LE(centralStart, cursor);
cursor += 4;
end.writeUInt16LE(0, cursor);
return Buffer.concat([...chunks, ...centralChunks, end]);
}
async function fetchAniList<T>(
query: string,
variables: Record<string, unknown>,
): Promise<T> {
const response = await fetch(ANILIST_GRAPHQL_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
query,
variables,
}),
});
if (!response.ok) {
throw new Error(`AniList request failed (${response.status})`);
}
const payload = (await response.json()) as {
data?: T;
errors?: Array<{ message?: string }>;
};
const firstError = payload.errors?.find((entry) => entry && typeof entry.message === 'string');
if (firstError?.message) {
throw new Error(firstError.message);
}
if (!payload.data) {
throw new Error('AniList response missing data');
}
return payload.data;
}
async function resolveAniListMediaIdFromGuess(
guess: AnilistMediaGuess,
): Promise<ResolvedAniListMedia> {
const data = await fetchAniList<AniListSearchResponse>(
`
query($search: String!) {
Page(perPage: 10) {
media(search: $search, type: ANIME, sort: [SEARCH_MATCH, POPULARITY_DESC]) {
id
episodes
title {
romaji
english
native
}
}
}
}
`,
{
search: guess.title,
},
);
const media = data.Page?.media ?? [];
const resolved = pickAniListSearchResult(guess.title, guess.episode, media);
if (!resolved) {
throw new Error(`No AniList media match found for "${guess.title}".`);
}
return resolved;
}
async function fetchCharactersForMedia(mediaId: number): Promise<{
mediaTitle: string;
characters: CharacterRecord[];
}> {
const characters: CharacterRecord[] = [];
let page = 1;
let mediaTitle = '';
for (;;) {
const data = await fetchAniList<AniListCharacterPageResponse>(
`
query($id: Int!, $page: Int!) {
Media(id: $id, type: ANIME) {
title {
romaji
english
native
}
characters(page: $page, perPage: 50, sort: [ROLE, RELEVANCE, ID]) {
pageInfo {
hasNextPage
}
edges {
role
node {
id
description(asHtml: false)
image {
large
medium
}
name {
full
native
}
}
}
}
}
}
`,
{
id: mediaId,
page,
},
);
const media = data.Media;
if (!media) {
throw new Error(`AniList media ${mediaId} not found.`);
}
if (!mediaTitle) {
mediaTitle =
media.title?.english?.trim() ||
media.title?.romaji?.trim() ||
media.title?.native?.trim() ||
`AniList ${mediaId}`;
}
const edges = media.characters?.edges ?? [];
for (const edge of edges) {
const node = edge?.node;
if (!node || typeof node.id !== 'number') continue;
const fullName = node.name?.full?.trim() || '';
const nativeName = node.name?.native?.trim() || '';
if (!fullName && !nativeName) continue;
characters.push({
id: node.id,
role: mapRole(edge?.role),
fullName,
nativeName,
description: node.description || '',
imageUrl: node.image?.large || node.image?.medium || null,
});
}
const hasNextPage = Boolean(media.characters?.pageInfo?.hasNextPage);
if (!hasNextPage) {
break;
}
page += 1;
await sleep(300);
}
return {
mediaTitle,
characters,
};
}
async function downloadCharacterImage(imageUrl: string, charId: number): Promise<{
filename: string;
bytes: Buffer;
} | null> {
try {
const response = await fetch(imageUrl);
if (!response.ok) return null;
const bytes = Buffer.from(await response.arrayBuffer());
if (bytes.length === 0) return null;
const ext = inferImageExt(response.headers.get('content-type'));
return {
filename: `c${charId}.${ext}`,
bytes,
};
} catch {
return null;
}
}
function buildDictionaryTitle(mediaId: number): string {
return `SubMiner Character Dictionary (AniList ${mediaId})`;
}
function createIndex(mediaId: number, mediaTitle: string, revision: string): Record<string, unknown> {
const dictionaryTitle = buildDictionaryTitle(mediaId);
return {
title: dictionaryTitle,
revision,
format: 3,
author: 'SubMiner',
description: `Character names from ${mediaTitle} [AniList media ID ${mediaId}]`,
};
}
function createTagBank(): Array<[string, string, number, string, number]> {
return [
['name', 'partOfSpeech', 0, 'Character name', 0],
['main', 'name', 0, 'Protagonist', 0],
['primary', 'name', 0, 'Main character', 0],
['side', 'name', 0, 'Side character', 0],
['appears', 'name', 0, 'Minor appearance', 0],
];
}
export function createCharacterDictionaryRuntimeService(deps: CharacterDictionaryRuntimeDeps): {
generateForCurrentMedia: (
targetPath?: string,
options?: CharacterDictionaryGenerateOptions,
) => Promise<CharacterDictionaryBuildResult>;
} {
const outputDir = path.join(deps.userDataPath, 'character-dictionaries');
const cachePath = path.join(outputDir, 'cache.json');
return {
generateForCurrentMedia: async (
targetPath?: string,
options?: CharacterDictionaryGenerateOptions,
) => {
const dictionaryTarget = targetPath?.trim() || '';
const guessInput =
dictionaryTarget.length > 0
? resolveDictionaryGuessInputs(dictionaryTarget)
: {
mediaPath: deps.getCurrentMediaPath(),
mediaTitle: deps.getCurrentMediaTitle(),
};
const mediaPathForGuess = deps.resolveMediaPathForJimaku(guessInput.mediaPath);
const mediaTitle = guessInput.mediaTitle;
const guessed = await deps.guessAnilistMediaInfo(mediaPathForGuess, mediaTitle);
if (!guessed || !guessed.title.trim()) {
throw new Error('Unable to resolve current anime from media path/title.');
}
const resolvedMedia = await resolveAniListMediaIdFromGuess(guessed);
const cache = readCache(cachePath);
const cached = cache.anilistById[String(resolvedMedia.id)];
const refreshTtlMsRaw = options?.refreshTtlMs;
const hasRefreshTtl =
typeof refreshTtlMsRaw === 'number' && Number.isFinite(refreshTtlMsRaw) && refreshTtlMsRaw > 0;
const now = deps.now();
const cacheAgeMs =
cached && typeof cached.updatedAt === 'number' && Number.isFinite(cached.updatedAt)
? Math.max(0, now - cached.updatedAt)
: Number.POSITIVE_INFINITY;
const isCacheFresh = !hasRefreshTtl || cacheAgeMs <= refreshTtlMsRaw;
const isCacheFormatCurrent =
cached?.formatVersion === undefined
? false
: cached.formatVersion >= CHARACTER_DICTIONARY_FORMAT_VERSION;
if (cached?.zipPath && fs.existsSync(cached.zipPath) && isCacheFresh && isCacheFormatCurrent) {
deps.logInfo?.(
`[dictionary] cache hit for AniList ${resolvedMedia.id}: ${path.basename(cached.zipPath)}`,
);
return {
zipPath: cached.zipPath,
fromCache: true,
mediaId: cached.mediaId,
mediaTitle: cached.mediaTitle,
entryCount: cached.entryCount,
dictionaryTitle: cached.dictionaryTitle ?? buildDictionaryTitle(cached.mediaId),
revision: cached.revision,
};
}
const { mediaTitle: fetchedMediaTitle, characters } = await fetchCharactersForMedia(
resolvedMedia.id,
);
if (characters.length === 0) {
throw new Error(`No characters returned for AniList media ${resolvedMedia.id}.`);
}
ensureDir(outputDir);
const zipFiles: Array<{ name: string; data: Buffer }> = [];
const termEntries: Array<Array<string | number | Array<string | Record<string, unknown>>>> =
[];
const seen = new Set<string>();
for (const character of characters) {
let imagePath: string | null = null;
if (character.imageUrl) {
const image = await downloadCharacterImage(character.imageUrl, character.id);
if (image) {
imagePath = `img/${image.filename}`;
zipFiles.push({
name: imagePath,
data: image.bytes,
});
}
}
const glossary = createDefinitionGlossary(character, fetchedMediaTitle, imagePath);
const candidateTerms = buildNameTerms(character);
for (const term of candidateTerms) {
const reading = buildReading(term);
const dedupeKey = `${term}|${reading}|${character.role}`;
if (seen.has(dedupeKey)) continue;
seen.add(dedupeKey);
termEntries.push(buildTermEntry(term, reading, character.role, glossary));
}
}
if (termEntries.length === 0) {
throw new Error('No dictionary entries generated from AniList character data.');
}
const revision = String(now);
const dictionaryTitle = buildDictionaryTitle(resolvedMedia.id);
zipFiles.push({
name: 'index.json',
data: Buffer.from(
JSON.stringify(createIndex(resolvedMedia.id, fetchedMediaTitle, revision), null, 2),
'utf8',
),
});
zipFiles.push({
name: 'tag_bank_1.json',
data: Buffer.from(JSON.stringify(createTagBank()), 'utf8'),
});
const entriesPerBank = 10_000;
for (let i = 0; i < termEntries.length; i += entriesPerBank) {
const chunk = termEntries.slice(i, i + entriesPerBank);
zipFiles.push({
name: `term_bank_${Math.floor(i / entriesPerBank) + 1}.json`,
data: Buffer.from(JSON.stringify(chunk), 'utf8'),
});
}
const zipBuffer = createStoredZip(zipFiles);
const zipPath = path.join(outputDir, `anilist-${resolvedMedia.id}.zip`);
fs.writeFileSync(zipPath, zipBuffer);
const cacheEntry: CharacterDictionaryCacheEntry = {
mediaId: resolvedMedia.id,
mediaTitle: fetchedMediaTitle,
entryCount: termEntries.length,
zipPath,
updatedAt: now,
formatVersion: CHARACTER_DICTIONARY_FORMAT_VERSION,
dictionaryTitle,
revision,
};
cache.anilistById[String(resolvedMedia.id)] = cacheEntry;
writeCache(cachePath, cache);
deps.logInfo?.(
`[dictionary] generated AniList ${resolvedMedia.id}: ${termEntries.length} terms -> ${zipPath}`,
);
return {
zipPath,
fromCache: false,
mediaId: resolvedMedia.id,
mediaTitle: fetchedMediaTitle,
entryCount: termEntries.length,
dictionaryTitle,
revision,
};
},
};
}

View File

@@ -33,6 +33,7 @@ export interface CliCommandRuntimeServiceContext {
openAnilistSetup: CliCommandRuntimeServiceDepsParams['anilist']['openSetup'];
getAnilistQueueStatus: CliCommandRuntimeServiceDepsParams['anilist']['getQueueStatus'];
retryAnilistQueueNow: CliCommandRuntimeServiceDepsParams['anilist']['retryQueueNow'];
generateCharacterDictionary: CliCommandRuntimeServiceDepsParams['dictionary']['generate'];
openJellyfinSetup: CliCommandRuntimeServiceDepsParams['jellyfin']['openSetup'];
runJellyfinCommand: CliCommandRuntimeServiceDepsParams['jellyfin']['runCommand'];
openYomitanSettings: () => void;
@@ -94,6 +95,9 @@ function createCliCommandDepsFromContext(
getQueueStatus: context.getAnilistQueueStatus,
retryQueueNow: context.retryAnilistQueueNow,
},
dictionary: {
generate: context.generateCharacterDictionary,
},
jellyfin: {
openSetup: context.openJellyfinSetup,
runCommand: context.runJellyfinCommand,

View File

@@ -151,6 +151,9 @@ export interface CliCommandRuntimeServiceDepsParams {
getQueueStatus: CliCommandDepsRuntimeOptions['anilist']['getQueueStatus'];
retryQueueNow: CliCommandDepsRuntimeOptions['anilist']['retryQueueNow'];
};
dictionary: {
generate: CliCommandDepsRuntimeOptions['dictionary']['generate'];
};
jellyfin: {
openSetup: CliCommandDepsRuntimeOptions['jellyfin']['openSetup'];
runCommand: CliCommandDepsRuntimeOptions['jellyfin']['runCommand'];
@@ -296,6 +299,9 @@ export function createCliCommandRuntimeServiceDeps(
getQueueStatus: params.anilist.getQueueStatus,
retryQueueNow: params.anilist.retryQueueNow,
},
dictionary: {
generate: params.dictionary.generate,
},
jellyfin: {
openSetup: params.jellyfin.openSetup,
runCommand: params.jellyfin.runCommand,

View File

@@ -0,0 +1,221 @@
import assert from 'node:assert/strict';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import test from 'node:test';
import { createCharacterDictionaryAutoSyncRuntimeService } from './character-dictionary-auto-sync';
function makeTempDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-char-dict-auto-sync-'));
}
test('auto sync imports current dictionary and updates persisted state', async () => {
const userDataPath = makeTempDir();
const imported: string[] = [];
const upserts: Array<{ title: string; scope: 'all' | 'active' }> = [];
const runtime = createCharacterDictionaryAutoSyncRuntimeService({
userDataPath,
getConfig: () => ({
enabled: true,
refreshTtlHours: 168,
maxLoaded: 3,
evictionPolicy: 'delete',
profileScope: 'all',
}),
generateCharacterDictionary: async () => ({
zipPath: '/tmp/anilist-130298.zip',
fromCache: false,
mediaId: 130298,
mediaTitle: 'The Eminence in Shadow',
entryCount: 2544,
dictionaryTitle: 'SubMiner Character Dictionary (AniList 130298)',
revision: '100',
}),
getYomitanDictionaryInfo: async () => [],
importYomitanDictionary: async (zipPath) => {
imported.push(zipPath);
return true;
},
deleteYomitanDictionary: async () => true,
upsertYomitanDictionarySettings: async (dictionaryTitle, profileScope) => {
upserts.push({ title: dictionaryTitle, scope: profileScope });
return true;
},
removeYomitanDictionarySettings: async () => true,
now: () => 1000,
});
await runtime.runSyncNow();
assert.deepEqual(imported, ['/tmp/anilist-130298.zip']);
assert.deepEqual(upserts, [
{ title: 'SubMiner Character Dictionary (AniList 130298)', scope: 'all' },
]);
const statePath = path.join(userDataPath, 'character-dictionaries', 'auto-sync-state.json');
const state = JSON.parse(fs.readFileSync(statePath, 'utf8')) as {
activeMediaIds: number[];
dictionariesByMediaId: Record<string, { lastImportedRevision: string }>;
};
assert.deepEqual(state.activeMediaIds, [130298]);
assert.equal(state.dictionariesByMediaId['130298']?.lastImportedRevision, '100');
});
test('auto sync rotates dictionaries by LRU and deletes overflow when policy=delete', async () => {
const userDataPath = makeTempDir();
const generated = [
{ mediaId: 1, zipPath: '/tmp/anilist-1.zip', title: 'SubMiner Character Dictionary (AniList 1)' },
{ mediaId: 2, zipPath: '/tmp/anilist-2.zip', title: 'SubMiner Character Dictionary (AniList 2)' },
{ mediaId: 3, zipPath: '/tmp/anilist-3.zip', title: 'SubMiner Character Dictionary (AniList 3)' },
{ mediaId: 4, zipPath: '/tmp/anilist-4.zip', title: 'SubMiner Character Dictionary (AniList 4)' },
];
let runIndex = 0;
const deletes: string[] = [];
const removals: Array<{ title: string; mode: 'delete' | 'disable' }> = [];
const runtime = createCharacterDictionaryAutoSyncRuntimeService({
userDataPath,
getConfig: () => ({
enabled: true,
refreshTtlHours: 168,
maxLoaded: 3,
evictionPolicy: 'delete',
profileScope: 'all',
}),
generateCharacterDictionary: async () => {
const current = generated[Math.min(runIndex, generated.length - 1)]!;
runIndex += 1;
return {
zipPath: current.zipPath,
fromCache: false,
mediaId: current.mediaId,
mediaTitle: `Title ${current.mediaId}`,
entryCount: 10,
dictionaryTitle: current.title,
revision: String(current.mediaId),
};
},
getYomitanDictionaryInfo: async () => [],
importYomitanDictionary: async () => true,
deleteYomitanDictionary: async (dictionaryTitle) => {
deletes.push(dictionaryTitle);
return true;
},
upsertYomitanDictionarySettings: async () => true,
removeYomitanDictionarySettings: async (dictionaryTitle, _scope, mode) => {
removals.push({ title: dictionaryTitle, mode });
return true;
},
now: () => Date.now(),
});
await runtime.runSyncNow();
await runtime.runSyncNow();
await runtime.runSyncNow();
await runtime.runSyncNow();
assert.ok(removals.some((entry) => entry.title.includes('(AniList 1)') && entry.mode === 'delete'));
assert.ok(deletes.some((title) => title.includes('(AniList 1)')));
const statePath = path.join(userDataPath, 'character-dictionaries', 'auto-sync-state.json');
const state = JSON.parse(fs.readFileSync(statePath, 'utf8')) as {
activeMediaIds: number[];
dictionariesByMediaId: Record<string, unknown>;
};
assert.deepEqual(state.activeMediaIds, [4, 3, 2]);
assert.equal(state.dictionariesByMediaId['1'], undefined);
});
test('auto sync disable eviction keeps dictionary in DB and only disables settings', async () => {
const userDataPath = makeTempDir();
let runIndex = 0;
const deletes: string[] = [];
const removals: Array<{ title: string; mode: 'delete' | 'disable' }> = [];
const runtime = createCharacterDictionaryAutoSyncRuntimeService({
userDataPath,
getConfig: () => ({
enabled: true,
refreshTtlHours: 168,
maxLoaded: 1,
evictionPolicy: 'disable',
profileScope: 'all',
}),
generateCharacterDictionary: async () => {
runIndex += 1;
return {
zipPath: `/tmp/anilist-${runIndex}.zip`,
fromCache: false,
mediaId: runIndex,
mediaTitle: `Title ${runIndex}`,
entryCount: 10,
dictionaryTitle: `SubMiner Character Dictionary (AniList ${runIndex})`,
revision: String(runIndex),
};
},
getYomitanDictionaryInfo: async () => [],
importYomitanDictionary: async () => true,
deleteYomitanDictionary: async (dictionaryTitle) => {
deletes.push(dictionaryTitle);
return true;
},
upsertYomitanDictionarySettings: async () => true,
removeYomitanDictionarySettings: async (dictionaryTitle, _scope, mode) => {
removals.push({ title: dictionaryTitle, mode });
return true;
},
now: () => Date.now(),
});
await runtime.runSyncNow();
await runtime.runSyncNow();
assert.ok(removals.some((entry) => entry.mode === 'disable' && entry.title.includes('(AniList 1)')));
assert.equal(deletes.some((title) => title.includes('(AniList 1)')), false);
const statePath = path.join(userDataPath, 'character-dictionaries', 'auto-sync-state.json');
const state = JSON.parse(fs.readFileSync(statePath, 'utf8')) as {
activeMediaIds: number[];
dictionariesByMediaId: Record<string, unknown>;
};
assert.deepEqual(state.activeMediaIds, [2]);
assert.ok(state.dictionariesByMediaId['1']);
assert.ok(state.dictionariesByMediaId['2']);
});
test('auto sync fails fast when yomitan import hangs', async () => {
const userDataPath = makeTempDir();
const runtime = createCharacterDictionaryAutoSyncRuntimeService({
userDataPath,
operationTimeoutMs: 5,
getConfig: () => ({
enabled: true,
refreshTtlHours: 168,
maxLoaded: 3,
evictionPolicy: 'delete',
profileScope: 'all',
}),
generateCharacterDictionary: async () => ({
zipPath: '/tmp/anilist-130298.zip',
fromCache: true,
mediaId: 130298,
mediaTitle: 'The Eminence in Shadow',
entryCount: 2544,
dictionaryTitle: 'SubMiner Character Dictionary (AniList 130298)',
revision: '100',
}),
getYomitanDictionaryInfo: async () => [],
importYomitanDictionary: async () =>
new Promise<boolean>(() => {
// never resolve
}),
deleteYomitanDictionary: async () => true,
upsertYomitanDictionarySettings: async () => true,
removeYomitanDictionarySettings: async () => true,
now: () => Date.now(),
});
await assert.rejects(async () => runtime.runSyncNow(), /importYomitanDictionary\(anilist-130298\.zip\) timed out after 5ms/);
});

View File

@@ -0,0 +1,308 @@
import * as fs from 'fs';
import * as path from 'path';
import type {
AnilistCharacterDictionaryEvictionPolicy,
AnilistCharacterDictionaryProfileScope,
} from '../../types';
import type {
CharacterDictionaryBuildResult,
CharacterDictionaryGenerateOptions,
} from '../character-dictionary-runtime';
type AutoSyncStateDictionaryEntry = {
mediaId: number;
dictionaryTitle: string;
lastImportedRevision: string | null;
lastUsedAt: number;
};
type AutoSyncState = {
activeMediaIds: number[];
dictionariesByMediaId: Record<string, AutoSyncStateDictionaryEntry>;
};
type AutoSyncDictionaryInfo = {
title: string;
revision?: string | number;
};
export interface CharacterDictionaryAutoSyncConfig {
enabled: boolean;
refreshTtlHours: number;
maxLoaded: number;
evictionPolicy: AnilistCharacterDictionaryEvictionPolicy;
profileScope: AnilistCharacterDictionaryProfileScope;
}
export interface CharacterDictionaryAutoSyncRuntimeDeps {
userDataPath: string;
getConfig: () => CharacterDictionaryAutoSyncConfig;
generateCharacterDictionary: (
options?: CharacterDictionaryGenerateOptions,
) => Promise<CharacterDictionaryBuildResult>;
getYomitanDictionaryInfo: () => Promise<AutoSyncDictionaryInfo[]>;
importYomitanDictionary: (zipPath: string) => Promise<boolean>;
deleteYomitanDictionary: (dictionaryTitle: string) => Promise<boolean>;
upsertYomitanDictionarySettings: (
dictionaryTitle: string,
profileScope: AnilistCharacterDictionaryProfileScope,
) => Promise<boolean>;
removeYomitanDictionarySettings: (
dictionaryTitle: string,
profileScope: AnilistCharacterDictionaryProfileScope,
mode: 'delete' | 'disable',
) => Promise<boolean>;
now: () => number;
schedule?: (fn: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
clearSchedule?: (timer: ReturnType<typeof setTimeout>) => void;
operationTimeoutMs?: number;
logInfo?: (message: string) => void;
logWarn?: (message: string) => void;
}
function ensureDir(dirPath: string): void {
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
}
function readAutoSyncState(statePath: string): AutoSyncState {
try {
const raw = fs.readFileSync(statePath, 'utf8');
const parsed = JSON.parse(raw) as Partial<AutoSyncState>;
if (!parsed || typeof parsed !== 'object') {
return { activeMediaIds: [], dictionariesByMediaId: {} };
}
const dictionariesByMediaId = parsed.dictionariesByMediaId ?? {};
if (!dictionariesByMediaId || typeof dictionariesByMediaId !== 'object') {
return { activeMediaIds: [], dictionariesByMediaId: {} };
}
const normalizedEntries: Record<string, AutoSyncStateDictionaryEntry> = {};
for (const [key, value] of Object.entries(dictionariesByMediaId)) {
if (!value || typeof value !== 'object') {
continue;
}
const mediaId = Number.parseInt(key, 10);
const dictionaryTitle =
typeof (value as { dictionaryTitle?: unknown }).dictionaryTitle === 'string'
? (value as { dictionaryTitle: string }).dictionaryTitle.trim()
: '';
if (!Number.isFinite(mediaId) || mediaId <= 0 || !dictionaryTitle) {
continue;
}
const lastImportedRevisionRaw = (value as { lastImportedRevision?: unknown })
.lastImportedRevision;
const lastUsedAtRaw = (value as { lastUsedAt?: unknown }).lastUsedAt;
normalizedEntries[String(mediaId)] = {
mediaId,
dictionaryTitle,
lastImportedRevision:
typeof lastImportedRevisionRaw === 'string' && lastImportedRevisionRaw.length > 0
? lastImportedRevisionRaw
: null,
lastUsedAt:
typeof lastUsedAtRaw === 'number' && Number.isFinite(lastUsedAtRaw) ? lastUsedAtRaw : 0,
};
}
const activeMediaIdsRaw = Array.isArray(parsed.activeMediaIds) ? parsed.activeMediaIds : [];
const activeMediaIds = activeMediaIdsRaw
.filter((value): value is number => typeof value === 'number' && Number.isFinite(value))
.map((value) => Math.max(1, Math.floor(value)))
.filter((value, index, all) => all.indexOf(value) === index)
.filter((value) => normalizedEntries[String(value)] !== undefined);
return {
activeMediaIds,
dictionariesByMediaId: normalizedEntries,
};
} catch {
return { activeMediaIds: [], dictionariesByMediaId: {} };
}
}
function writeAutoSyncState(statePath: string, state: AutoSyncState): void {
ensureDir(path.dirname(statePath));
fs.writeFileSync(statePath, JSON.stringify(state, null, 2), 'utf8');
}
function buildDictionaryTitle(mediaId: number): string {
return `SubMiner Character Dictionary (AniList ${mediaId})`;
}
export function createCharacterDictionaryAutoSyncRuntimeService(
deps: CharacterDictionaryAutoSyncRuntimeDeps,
): {
scheduleSync: () => void;
runSyncNow: () => Promise<void>;
} {
const dictionariesDir = path.join(deps.userDataPath, 'character-dictionaries');
const statePath = path.join(dictionariesDir, 'auto-sync-state.json');
const schedule = deps.schedule ?? ((fn, delayMs) => setTimeout(fn, delayMs));
const clearSchedule = deps.clearSchedule ?? ((timer) => clearTimeout(timer));
const debounceMs = 800;
const operationTimeoutMs = Math.max(1, Math.floor(deps.operationTimeoutMs ?? 7_000));
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
let syncInFlight = false;
let runQueued = false;
const withOperationTimeout = async <T>(label: string, promise: Promise<T>): Promise<T> => {
let timer: ReturnType<typeof setTimeout> | null = null;
try {
return await Promise.race([
promise,
new Promise<never>((_resolve, reject) => {
timer = setTimeout(() => {
reject(new Error(`${label} timed out after ${operationTimeoutMs}ms`));
}, operationTimeoutMs);
}),
]);
} finally {
if (timer !== null) {
clearTimeout(timer);
}
}
};
const runSyncOnce = async (): Promise<void> => {
const config = deps.getConfig();
if (!config.enabled) {
return;
}
const refreshTtlMs = Math.max(1, Math.floor(config.refreshTtlHours)) * 60 * 60 * 1000;
const generation = await deps.generateCharacterDictionary({ refreshTtlMs });
const dictionaryTitle = generation.dictionaryTitle ?? buildDictionaryTitle(generation.mediaId);
const revision =
typeof generation.revision === 'string' && generation.revision.length > 0
? generation.revision
: null;
const state = readAutoSyncState(statePath);
const dictionaryInfo = await withOperationTimeout(
'getYomitanDictionaryInfo',
deps.getYomitanDictionaryInfo(),
);
const existing = dictionaryInfo.find((entry) => entry.title === dictionaryTitle) ?? null;
const existingRevision =
existing && (typeof existing.revision === 'string' || typeof existing.revision === 'number')
? String(existing.revision)
: null;
const shouldImport =
existing === null || (revision !== null && existingRevision !== revision);
if (shouldImport) {
if (existing !== null) {
await withOperationTimeout(
`deleteYomitanDictionary(${dictionaryTitle})`,
deps.deleteYomitanDictionary(dictionaryTitle),
);
}
deps.logInfo?.(
`[dictionary:auto-sync] importing AniList ${generation.mediaId}: ${generation.zipPath}`,
);
const imported = await withOperationTimeout(
`importYomitanDictionary(${path.basename(generation.zipPath)})`,
deps.importYomitanDictionary(generation.zipPath),
);
if (!imported) {
throw new Error(`Failed to import dictionary ZIP: ${generation.zipPath}`);
}
}
await withOperationTimeout(
`upsertYomitanDictionarySettings(${dictionaryTitle})`,
deps.upsertYomitanDictionarySettings(dictionaryTitle, config.profileScope),
);
const mediaIdKey = String(generation.mediaId);
state.dictionariesByMediaId[mediaIdKey] = {
mediaId: generation.mediaId,
dictionaryTitle,
lastImportedRevision: revision,
lastUsedAt: deps.now(),
};
state.activeMediaIds = [
generation.mediaId,
...state.activeMediaIds.filter((value) => value !== generation.mediaId),
];
const maxLoaded = Math.max(1, Math.floor(config.maxLoaded));
while (state.activeMediaIds.length > maxLoaded) {
const evictedMediaId = state.activeMediaIds.pop();
if (evictedMediaId === undefined) {
break;
}
const evicted = state.dictionariesByMediaId[String(evictedMediaId)];
if (!evicted) {
continue;
}
await withOperationTimeout(
`removeYomitanDictionarySettings(${evicted.dictionaryTitle})`,
deps.removeYomitanDictionarySettings(
evicted.dictionaryTitle,
config.profileScope,
config.evictionPolicy,
),
);
if (config.evictionPolicy === 'delete') {
await withOperationTimeout(
`deleteYomitanDictionary(${evicted.dictionaryTitle})`,
deps.deleteYomitanDictionary(evicted.dictionaryTitle),
);
delete state.dictionariesByMediaId[String(evictedMediaId)];
}
}
writeAutoSyncState(statePath, state);
deps.logInfo?.(
`[dictionary:auto-sync] synced AniList ${generation.mediaId}: ${dictionaryTitle} (${generation.entryCount} entries)`,
);
};
const enqueueSync = (): void => {
runQueued = true;
if (syncInFlight) {
return;
}
syncInFlight = true;
void (async () => {
while (runQueued) {
runQueued = false;
try {
await runSyncOnce();
} catch (error) {
deps.logWarn?.(
`[dictionary:auto-sync] sync failed: ${(error as Error)?.message ?? String(error)}`,
);
}
}
})().finally(() => {
syncInFlight = false;
});
};
return {
scheduleSync: () => {
const config = deps.getConfig();
if (!config.enabled) {
return;
}
if (debounceTimer !== null) {
clearSchedule(debounceTimer);
}
debounceTimer = schedule(() => {
debounceTimer = null;
enqueueSync();
}, debounceMs);
},
runSyncNow: async () => {
await runSyncOnce();
},
};
}

View File

@@ -46,6 +46,13 @@ test('build cli command context deps maps handlers and values', () => {
openJellyfinSetup: () => calls.push('jellyfin'),
getAnilistQueueStatus: () => ({}) as never,
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
generateCharacterDictionary: async () => ({
zipPath: '/tmp/anilist-1.zip',
fromCache: false,
mediaId: 1,
mediaTitle: 'Test',
entryCount: 10,
}),
runJellyfinCommand: async () => {
calls.push('run-jellyfin');
},

View File

@@ -32,6 +32,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
openJellyfinSetup: CliCommandContextFactoryDeps['openJellyfinSetup'];
getAnilistQueueStatus: CliCommandContextFactoryDeps['getAnilistQueueStatus'];
retryAnilistQueueNow: CliCommandContextFactoryDeps['retryAnilistQueueNow'];
generateCharacterDictionary: CliCommandContextFactoryDeps['generateCharacterDictionary'];
runJellyfinCommand: (args: CliArgs) => Promise<void>;
openYomitanSettings: () => void;
cycleSecondarySubMode: () => void;
@@ -76,6 +77,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
openJellyfinSetup: deps.openJellyfinSetup,
getAnilistQueueStatus: deps.getAnilistQueueStatus,
retryAnilistQueueNow: deps.retryAnilistQueueNow,
generateCharacterDictionary: deps.generateCharacterDictionary,
runJellyfinCommand: deps.runJellyfinCommand,
openYomitanSettings: deps.openYomitanSettings,
cycleSecondarySubMode: deps.cycleSecondarySubMode,

View File

@@ -53,6 +53,13 @@ test('cli command context factory composes main deps and context handlers', () =
lastError: null,
}),
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'ok' }),
generateCharacterDictionary: async () => ({
zipPath: '/tmp/anilist-1.zip',
fromCache: false,
mediaId: 1,
mediaTitle: 'Test',
entryCount: 10,
}),
runJellyfinCommand: async () => {},
openYomitanSettings: () => {},
cycleSecondarySubMode: () => {},

View File

@@ -70,6 +70,13 @@ test('cli command context main deps builder maps state and callbacks', async ()
lastError: null,
}),
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'ok' }),
generateCharacterDictionary: async () => ({
zipPath: '/tmp/anilist-1.zip',
fromCache: false,
mediaId: 1,
mediaTitle: 'Test',
entryCount: 10,
}),
runJellyfinCommand: async () => {
calls.push('run-jellyfin');
},

View File

@@ -37,6 +37,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
openJellyfinSetupWindow: () => void;
getAnilistQueueStatus: CliCommandContextFactoryDeps['getAnilistQueueStatus'];
processNextAnilistRetryUpdate: CliCommandContextFactoryDeps['retryAnilistQueueNow'];
generateCharacterDictionary: CliCommandContextFactoryDeps['generateCharacterDictionary'];
runJellyfinCommand: (args: CliArgs) => Promise<void>;
openYomitanSettings: () => void;
@@ -87,6 +88,8 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
openJellyfinSetup: () => deps.openJellyfinSetupWindow(),
getAnilistQueueStatus: () => deps.getAnilistQueueStatus(),
retryAnilistQueueNow: () => deps.processNextAnilistRetryUpdate(),
generateCharacterDictionary: (targetPath?: string) =>
deps.generateCharacterDictionary(targetPath),
runJellyfinCommand: (args: CliArgs) => deps.runJellyfinCommand(args),
openYomitanSettings: () => deps.openYomitanSettings(),
cycleSecondarySubMode: () => deps.cycleSecondarySubMode(),

View File

@@ -40,6 +40,13 @@ function createDeps() {
openJellyfinSetup: () => {},
getAnilistQueueStatus: () => ({}) as never,
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
generateCharacterDictionary: async () => ({
zipPath: '/tmp/anilist-1.zip',
fromCache: false,
mediaId: 1,
mediaTitle: 'Test',
entryCount: 1,
}),
runJellyfinCommand: async () => {},
openYomitanSettings: () => {},
cycleSecondarySubMode: () => {},

View File

@@ -37,6 +37,7 @@ export type CliCommandContextFactoryDeps = {
openJellyfinSetup: CliCommandRuntimeServiceContext['openJellyfinSetup'];
getAnilistQueueStatus: CliCommandRuntimeServiceContext['getAnilistQueueStatus'];
retryAnilistQueueNow: CliCommandRuntimeServiceContext['retryAnilistQueueNow'];
generateCharacterDictionary: CliCommandRuntimeServiceContext['generateCharacterDictionary'];
runJellyfinCommand: (args: CliArgs) => Promise<void>;
openYomitanSettings: () => void;
cycleSecondarySubMode: () => void;
@@ -88,6 +89,7 @@ export function createCliCommandContext(
openJellyfinSetup: deps.openJellyfinSetup,
getAnilistQueueStatus: deps.getAnilistQueueStatus,
retryAnilistQueueNow: deps.retryAnilistQueueNow,
generateCharacterDictionary: deps.generateCharacterDictionary,
runJellyfinCommand: deps.runJellyfinCommand,
openYomitanSettings: deps.openYomitanSettings,
cycleSecondarySubMode: deps.cycleSecondarySubMode,

View File

@@ -19,6 +19,7 @@ export function createHandleMpvConnectionChangeHandler(deps: {
reportJellyfinRemoteStopped: () => void;
refreshDiscordPresence: () => void;
syncOverlayMpvSubtitleSuppression: () => void;
scheduleCharacterDictionarySync?: () => void;
hasInitialJellyfinPlayArg: () => boolean;
isOverlayRuntimeInitialized: () => boolean;
isQuitOnDisconnectArmed: () => boolean;
@@ -30,6 +31,7 @@ export function createHandleMpvConnectionChangeHandler(deps: {
deps.refreshDiscordPresence();
if (connected) {
deps.syncOverlayMpvSubtitleSuppression();
deps.scheduleCharacterDictionarySync?.();
return;
}
deps.reportJellyfinRemoteStopped();
@@ -67,8 +69,8 @@ export function createBindMpvClientEventHandlers(deps: {
onSubtitleAssChange: (payload: { text: string }) => void;
onSecondarySubtitleChange: (payload: { text: string }) => void;
onSubtitleTiming: (payload: { text: string; start: number; end: number }) => void;
onMediaPathChange: (payload: { path: string }) => void;
onMediaTitleChange: (payload: { title: string }) => void;
onMediaPathChange: (payload: { path: string | null }) => void;
onMediaTitleChange: (payload: { title: string | null }) => void;
onTimePosChange: (payload: { time: number }) => void;
onPauseChange: (payload: { paused: boolean }) => void;
onSubtitleMetricsChange: (payload: { patch: Record<string, unknown> }) => void;

View File

@@ -57,6 +57,7 @@ test('media path change handler reports stop for empty path and probes media key
maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`),
ensureAnilistMediaGuess: (mediaKey) => calls.push(`guess:${mediaKey}`),
syncImmersionMediaState: () => calls.push('sync'),
scheduleCharacterDictionarySync: () => calls.push('dict-sync'),
refreshDiscordPresence: () => calls.push('presence'),
});
@@ -80,6 +81,7 @@ test('media title change handler clears guess state and syncs immersion', () =>
resetAnilistMediaGuessState: () => calls.push('reset-guess'),
notifyImmersionTitleUpdate: (title) => calls.push(`notify:${title}`),
syncImmersionMediaState: () => calls.push('sync'),
scheduleCharacterDictionarySync: () => calls.push('dict-sync'),
refreshDiscordPresence: () => calls.push('presence'),
});
@@ -89,6 +91,7 @@ test('media title change handler clears guess state and syncs immersion', () =>
'reset-guess',
'notify:Episode 1',
'sync',
'dict-sync',
'presence',
]);
});

View File

@@ -39,11 +39,13 @@ export function createHandleMpvMediaPathChangeHandler(deps: {
maybeProbeAnilistDuration: (mediaKey: string) => void;
ensureAnilistMediaGuess: (mediaKey: string) => void;
syncImmersionMediaState: () => void;
scheduleCharacterDictionarySync?: () => void;
refreshDiscordPresence: () => void;
}) {
return ({ path }: { path: string }): void => {
deps.updateCurrentMediaPath(path);
if (!path) {
return ({ path }: { path: string | null }): void => {
const normalizedPath = typeof path === 'string' ? path : '';
deps.updateCurrentMediaPath(normalizedPath);
if (!normalizedPath) {
deps.reportJellyfinRemoteStopped();
deps.restoreMpvSubVisibility();
}
@@ -54,6 +56,9 @@ export function createHandleMpvMediaPathChangeHandler(deps: {
deps.ensureAnilistMediaGuess(mediaKey);
}
deps.syncImmersionMediaState();
if (normalizedPath.trim().length > 0) {
deps.scheduleCharacterDictionarySync?.();
}
deps.refreshDiscordPresence();
};
}
@@ -63,13 +68,18 @@ export function createHandleMpvMediaTitleChangeHandler(deps: {
resetAnilistMediaGuessState: () => void;
notifyImmersionTitleUpdate: (title: string) => void;
syncImmersionMediaState: () => void;
scheduleCharacterDictionarySync?: () => void;
refreshDiscordPresence: () => void;
}) {
return ({ title }: { title: string }): void => {
deps.updateCurrentMediaTitle(title);
return ({ title }: { title: string | null }): void => {
const normalizedTitle = typeof title === 'string' ? title : '';
deps.updateCurrentMediaTitle(normalizedTitle);
deps.resetAnilistMediaGuessState();
deps.notifyImmersionTitleUpdate(title);
deps.notifyImmersionTitleUpdate(normalizedTitle);
deps.syncImmersionMediaState();
if (normalizedTitle.trim().length > 0) {
deps.scheduleCharacterDictionarySync?.();
}
deps.refreshDiscordPresence();
};
}

View File

@@ -20,6 +20,7 @@ type MpvEventClient = Parameters<ReturnType<typeof createBindMpvClientEventHandl
export function createBindMpvMainEventHandlersHandler(deps: {
reportJellyfinRemoteStopped: () => void;
syncOverlayMpvSubtitleSuppression: () => void;
scheduleCharacterDictionarySync?: () => void;
hasInitialJellyfinPlayArg: () => boolean;
isOverlayRuntimeInitialized: () => boolean;
isQuitOnDisconnectArmed: () => boolean;
@@ -66,6 +67,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(),
refreshDiscordPresence: () => deps.refreshDiscordPresence(),
syncOverlayMpvSubtitleSuppression: () => deps.syncOverlayMpvSubtitleSuppression(),
scheduleCharacterDictionarySync: () => deps.scheduleCharacterDictionarySync?.(),
hasInitialJellyfinPlayArg: () => deps.hasInitialJellyfinPlayArg(),
isOverlayRuntimeInitialized: () => deps.isOverlayRuntimeInitialized(),
isQuitOnDisconnectArmed: () => deps.isQuitOnDisconnectArmed(),
@@ -103,6 +105,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
maybeProbeAnilistDuration: (mediaKey) => deps.maybeProbeAnilistDuration(mediaKey),
ensureAnilistMediaGuess: (mediaKey) => deps.ensureAnilistMediaGuess(mediaKey),
syncImmersionMediaState: () => deps.syncImmersionMediaState(),
scheduleCharacterDictionarySync: () => deps.scheduleCharacterDictionarySync?.(),
refreshDiscordPresence: () => deps.refreshDiscordPresence(),
});
const handleMpvMediaTitleChange = createHandleMpvMediaTitleChangeHandler({
@@ -110,6 +113,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
resetAnilistMediaGuessState: () => deps.resetAnilistMediaGuessState(),
notifyImmersionTitleUpdate: (title) => deps.notifyImmersionTitleUpdate(title),
syncImmersionMediaState: () => deps.syncImmersionMediaState(),
scheduleCharacterDictionarySync: () => deps.scheduleCharacterDictionarySync?.(),
refreshDiscordPresence: () => deps.refreshDiscordPresence(),
});
const handleMpvTimePosChange = createHandleMpvTimePosChangeHandler({

View File

@@ -33,6 +33,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
maybeProbeAnilistDuration: (mediaKey: string) => void;
ensureAnilistMediaGuess: (mediaKey: string) => void;
syncImmersionMediaState: () => void;
scheduleCharacterDictionarySync?: () => void;
updateCurrentMediaTitle: (title: string) => void;
resetAnilistMediaGuessState: () => void;
reportJellyfinRemoteProgress: (forceImmediate: boolean) => void;
@@ -81,6 +82,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
maybeProbeAnilistDuration: (mediaKey: string) => deps.maybeProbeAnilistDuration(mediaKey),
ensureAnilistMediaGuess: (mediaKey: string) => deps.ensureAnilistMediaGuess(mediaKey),
syncImmersionMediaState: () => deps.syncImmersionMediaState(),
scheduleCharacterDictionarySync: () => deps.scheduleCharacterDictionarySync?.(),
updateCurrentMediaTitle: (title: string) => deps.updateCurrentMediaTitle(title),
resetAnilistMediaGuessState: () => deps.resetAnilistMediaGuessState(),
notifyImmersionTitleUpdate: (title: string) => {

View File

@@ -28,7 +28,7 @@ test('renderer stylesheet no longer contains invisible-layer selectors', () => {
assert.doesNotMatch(cssSource, /body\.layer-invisible/);
});
test('top-level docs avoid stale overlay-layers wording', () => {
const docsReadmeSource = readWorkspaceFile('docs/README.md');
assert.doesNotMatch(docsReadmeSource, /overlay layers/i);
test('top-level readme avoids stale overlay-layers wording', () => {
const readmeSource = readWorkspaceFile('README.md');
assert.doesNotMatch(readmeSource, /overlay layers/i);
});

View File

@@ -393,9 +393,21 @@ export interface JimakuConfig {
maxEntryResults?: number;
}
export type AnilistCharacterDictionaryEvictionPolicy = 'disable' | 'delete';
export type AnilistCharacterDictionaryProfileScope = 'all' | 'active';
export interface AnilistCharacterDictionaryConfig {
enabled?: boolean;
refreshTtlHours?: number;
maxLoaded?: number;
evictionPolicy?: AnilistCharacterDictionaryEvictionPolicy;
profileScope?: AnilistCharacterDictionaryProfileScope;
}
export interface AnilistConfig {
enabled?: boolean;
accessToken?: string;
characterDictionary?: AnilistCharacterDictionaryConfig;
}
export interface JellyfinConfig {
@@ -583,6 +595,13 @@ export interface ResolvedConfig {
anilist: {
enabled: boolean;
accessToken: string;
characterDictionary: {
enabled: boolean;
refreshTtlHours: number;
maxLoaded: number;
evictionPolicy: AnilistCharacterDictionaryEvictionPolicy;
profileScope: AnilistCharacterDictionaryProfileScope;
};
};
jellyfin: {
enabled: boolean;