feat: add AniList character dictionary sync

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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