mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-06 19:57:26 -08:00
feat: add AniList character dictionary sync
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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/);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"/);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
|
||||
@@ -30,6 +30,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
anilistLogout: false,
|
||||
anilistSetup: false,
|
||||
anilistRetryQueue: false,
|
||||
dictionary: false,
|
||||
jellyfin: false,
|
||||
jellyfinLogin: false,
|
||||
jellyfinLogout: false,
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -30,6 +30,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
anilistLogout: false,
|
||||
anilistSetup: false,
|
||||
anilistRetryQueue: false,
|
||||
dictionary: false,
|
||||
jellyfin: false,
|
||||
jellyfinLogin: false,
|
||||
jellyfinLogout: false,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
239
src/core/services/yomitan-structured-content-generator.test.ts
Normal file
239
src/core/services/yomitan-structured-content-generator.test.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
120
src/main.ts
120
src/main.ts
@@ -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(),
|
||||
|
||||
346
src/main/character-dictionary-runtime.test.ts
Normal file
346
src/main/character-dictionary-runtime.test.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
955
src/main/character-dictionary-runtime.ts
Normal file
955
src/main/character-dictionary-runtime.ts
Normal 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,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
221
src/main/runtime/character-dictionary-auto-sync.test.ts
Normal file
221
src/main/runtime/character-dictionary-auto-sync.test.ts
Normal 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/);
|
||||
});
|
||||
308
src/main/runtime/character-dictionary-auto-sync.ts
Normal file
308
src/main/runtime/character-dictionary-auto-sync.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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');
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: () => {},
|
||||
|
||||
@@ -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');
|
||||
},
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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: () => {},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
19
src/types.ts
19
src/types.ts
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user