mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-07 03:22:17 -08:00
feat: add AniList character dictionary sync
This commit is contained in:
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) => {
|
||||
|
||||
Reference in New Issue
Block a user