mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-27 00:55:16 -07:00
Add inline character portraits and dictionary search workflow (#83)
This commit is contained in:
@@ -195,22 +195,45 @@ test('generateForCurrentMedia emits structured-content glossary so image stays w
|
||||
assert.equal(nameDiv.tag, 'div');
|
||||
assert.equal(nameDiv.content, 'アレクシア・ミドガル');
|
||||
|
||||
const secondaryNameDiv = children[1] as { tag: string; content: string };
|
||||
assert.equal(secondaryNameDiv.tag, 'div');
|
||||
assert.equal(secondaryNameDiv.content, 'Alexia Midgar');
|
||||
assert.equal(
|
||||
children.some((child) => (child as { content?: unknown }).content === 'Alexia Midgar'),
|
||||
false,
|
||||
);
|
||||
|
||||
const imageWrap = children[2] as { tag: string; content: Record<string, unknown> };
|
||||
const imageWrap = children.find((child) => {
|
||||
const content = (child as { content?: unknown }).content;
|
||||
return (
|
||||
content &&
|
||||
typeof content === 'object' &&
|
||||
!Array.isArray(content) &&
|
||||
(content as { path?: unknown }).path === 'img/m130298-c123.png'
|
||||
);
|
||||
}) as { tag: string; content: Record<string, unknown> } | undefined;
|
||||
assert.ok(imageWrap);
|
||||
assert.equal(imageWrap.tag, 'div');
|
||||
const image = imageWrap.content as Record<string, unknown>;
|
||||
assert.equal(image.tag, 'img');
|
||||
assert.equal(image.path, 'img/m130298-c123.png');
|
||||
assert.equal(image.sizeUnits, 'em');
|
||||
|
||||
const sourceDiv = children[3] as { tag: string; content: string };
|
||||
const sourceDiv = children.find((child) => {
|
||||
const content = (child as { content?: unknown }).content;
|
||||
return typeof content === 'string' && content.includes('The Eminence in Shadow');
|
||||
}) as { tag: string; content: string } | undefined;
|
||||
assert.ok(sourceDiv);
|
||||
assert.equal(sourceDiv.tag, 'div');
|
||||
assert.ok(sourceDiv.content.includes('The Eminence in Shadow'));
|
||||
|
||||
const roleBadgeDiv = children[4] as { tag: string; content: Record<string, unknown> };
|
||||
const roleBadgeDiv = children.find((child) => {
|
||||
const content = (child as { content?: unknown }).content;
|
||||
return (
|
||||
content &&
|
||||
typeof content === 'object' &&
|
||||
!Array.isArray(content) &&
|
||||
(content as { content?: unknown }).content === 'Main Character'
|
||||
);
|
||||
}) as { tag: string; content: Record<string, unknown> } | undefined;
|
||||
assert.ok(roleBadgeDiv);
|
||||
assert.equal(roleBadgeDiv.tag, 'div');
|
||||
const badge = roleBadgeDiv.content as { tag: string; content: string };
|
||||
assert.equal(badge.tag, 'span');
|
||||
@@ -1882,9 +1905,9 @@ test('generateForCurrentMedia logs progress while resolving and rebuilding snaps
|
||||
'[dictionary] snapshot miss for AniList 130298, fetching characters',
|
||||
'[dictionary] downloaded AniList character page 1 for AniList 130298',
|
||||
'[dictionary] downloading 1 images for AniList 130298',
|
||||
'[dictionary] stored snapshot for AniList 130298: 32 terms',
|
||||
'[dictionary] stored snapshot for AniList 130298: 16 terms',
|
||||
'[dictionary] building ZIP for AniList 130298',
|
||||
'[dictionary] generated AniList 130298: 32 terms -> ' +
|
||||
'[dictionary] generated AniList 130298: 16 terms -> ' +
|
||||
path.join(userDataPath, 'character-dictionaries', 'anilist-130298.zip'),
|
||||
]);
|
||||
} finally {
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
buildCharacterDictionarySeriesKey,
|
||||
createCharacterDictionaryManualSelectionStore,
|
||||
} from './character-dictionary-runtime/manual-selection';
|
||||
import { snapshotHasCharacterNameImages } from './character-dictionary-runtime/image-lookup';
|
||||
import type {
|
||||
AniListMediaCandidate,
|
||||
CharacterDictionaryBuildResult,
|
||||
@@ -151,6 +152,7 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
|
||||
buildMergedDictionary: (mediaIds: number[]) => Promise<MergedCharacterDictionaryBuildResult>;
|
||||
getManualSelectionSnapshot: (
|
||||
targetPath?: string,
|
||||
searchTitle?: string,
|
||||
) => Promise<CharacterDictionaryManualSelectionSnapshot>;
|
||||
setManualSelection: (request: {
|
||||
targetPath?: string;
|
||||
@@ -168,6 +170,13 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
|
||||
userDataPath: deps.userDataPath,
|
||||
});
|
||||
|
||||
const shouldRefreshCachedSnapshot = (snapshot: CharacterDictionarySnapshot): boolean => {
|
||||
if (deps.getNameMatchImagesEnabled?.() !== true) {
|
||||
return false;
|
||||
}
|
||||
return !snapshotHasCharacterNameImages(snapshot);
|
||||
};
|
||||
|
||||
const createAniListRequestSlot = (): (() => Promise<void>) => {
|
||||
let hasAniListRequest = false;
|
||||
return async (): Promise<void> => {
|
||||
@@ -205,12 +214,19 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
|
||||
mediaTitle: guessInput.mediaTitle,
|
||||
guess: guessed,
|
||||
}),
|
||||
unscopedSeriesKey: buildCharacterDictionarySeriesKey({
|
||||
mediaPath: null,
|
||||
mediaTitle: guessInput.mediaTitle,
|
||||
guess: guessed,
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
const findCachedSnapshotForSeriesKey = (
|
||||
seriesKey: string,
|
||||
fallbackSeriesKey?: string,
|
||||
): CharacterDictionarySnapshot | null => {
|
||||
const acceptedKeys = new Set([seriesKey, fallbackSeriesKey].filter(Boolean));
|
||||
return (
|
||||
readCachedSnapshots(outputDir).find((snapshot) => {
|
||||
const snapshotSeriesKey = buildCharacterDictionarySeriesKey({
|
||||
@@ -223,7 +239,7 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
|
||||
source: 'fallback',
|
||||
},
|
||||
});
|
||||
return snapshotSeriesKey === seriesKey;
|
||||
return acceptedKeys.has(snapshotSeriesKey);
|
||||
}) ?? null
|
||||
);
|
||||
};
|
||||
@@ -233,7 +249,7 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
|
||||
beforeRequest?: () => Promise<void>,
|
||||
): Promise<ResolvedAniListMedia> => {
|
||||
deps.logInfo?.('[dictionary] resolving current anime for character dictionary generation');
|
||||
const { guessed, seriesKey } = await guessCurrentMedia(targetPath);
|
||||
const { guessed, seriesKey, unscopedSeriesKey } = await guessCurrentMedia(targetPath);
|
||||
deps.logInfo?.(
|
||||
`[dictionary] current anime guess: ${guessed.title.trim()}${
|
||||
typeof guessed.episode === 'number' && guessed.episode > 0
|
||||
@@ -267,7 +283,7 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
|
||||
}
|
||||
}
|
||||
|
||||
const cachedSnapshot = findCachedSnapshotForSeriesKey(seriesKey);
|
||||
const cachedSnapshot = findCachedSnapshotForSeriesKey(seriesKey, unscopedSeriesKey);
|
||||
if (cachedSnapshot) {
|
||||
writeCachedMediaResolution(outputDir, {
|
||||
seriesKey,
|
||||
@@ -301,7 +317,7 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
|
||||
): Promise<CharacterDictionarySnapshotResult> => {
|
||||
const snapshotPath = getSnapshotPath(outputDir, mediaId);
|
||||
const cachedSnapshot = readSnapshot(snapshotPath);
|
||||
if (cachedSnapshot) {
|
||||
if (cachedSnapshot && !shouldRefreshCachedSnapshot(cachedSnapshot)) {
|
||||
deps.logInfo?.(`[dictionary] snapshot hit for AniList ${mediaId}`);
|
||||
return {
|
||||
mediaId: cachedSnapshot.mediaId,
|
||||
@@ -311,6 +327,11 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
|
||||
updatedAt: cachedSnapshot.updatedAt,
|
||||
};
|
||||
}
|
||||
if (cachedSnapshot) {
|
||||
deps.logInfo?.(
|
||||
`[dictionary] snapshot stale for AniList ${mediaId}: missing cached character images`,
|
||||
);
|
||||
}
|
||||
|
||||
progress?.onGenerating?.({
|
||||
mediaId,
|
||||
@@ -455,28 +476,43 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
|
||||
entryCount,
|
||||
};
|
||||
},
|
||||
getManualSelectionSnapshot: async (targetPath?: string) => {
|
||||
getManualSelectionSnapshot: async (targetPath?: string, searchTitle?: string) => {
|
||||
const waitForAniListRequestSlot = createAniListRequestSlot();
|
||||
const { guessed, seriesKey } = await guessCurrentMedia(targetPath);
|
||||
const [candidates, override] = await Promise.all([
|
||||
searchAniListMediaCandidates(guessed.title, waitForAniListRequestSlot),
|
||||
const normalizedSearchTitle = searchTitle?.trim();
|
||||
const shouldUseExplicitSearch = searchTitle !== undefined;
|
||||
const candidateSearchTitle = shouldUseExplicitSearch ? normalizedSearchTitle : guessed.title;
|
||||
const candidates = candidateSearchTitle
|
||||
? await searchAniListMediaCandidates(candidateSearchTitle, waitForAniListRequestSlot)
|
||||
: [];
|
||||
const [override, current] = await Promise.all([
|
||||
manualSelectionStore.getOverride(seriesKey),
|
||||
shouldUseExplicitSearch
|
||||
? Promise.resolve(null)
|
||||
: resolveAniListMediaIdFromGuess(guessed, waitForAniListRequestSlot)
|
||||
.then(
|
||||
(entry): AniListMediaCandidate => ({
|
||||
id: entry.id,
|
||||
title: entry.title,
|
||||
episodes:
|
||||
candidates.find((candidate) => candidate.id === entry.id)?.episodes ?? null,
|
||||
}),
|
||||
)
|
||||
.catch(() => null),
|
||||
]);
|
||||
const current = await resolveAniListMediaIdFromGuess(guessed, waitForAniListRequestSlot)
|
||||
.then(
|
||||
(entry): AniListMediaCandidate => ({
|
||||
id: entry.id,
|
||||
title: entry.title,
|
||||
episodes: candidates.find((candidate) => candidate.id === entry.id)?.episodes ?? null,
|
||||
}),
|
||||
)
|
||||
.catch(() => null);
|
||||
const overrideCandidate = override
|
||||
? candidates.find((candidate) => candidate.id === override.mediaId)
|
||||
: null;
|
||||
return {
|
||||
seriesKey,
|
||||
guessTitle: guessed.title,
|
||||
current,
|
||||
override: override
|
||||
? { id: override.mediaId, title: override.mediaTitle, episodes: null }
|
||||
? {
|
||||
id: override.mediaId,
|
||||
title: override.mediaTitle,
|
||||
episodes: overrideCandidate?.episodes ?? null,
|
||||
}
|
||||
: null,
|
||||
candidates,
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyCollapsibleOpenStatesToTermEntries } from './build';
|
||||
import type { CharacterDictionaryTermEntry } from './types';
|
||||
import { applyCollapsibleOpenStatesToTermEntries, buildSnapshotFromCharacters } from './build';
|
||||
import type { CharacterDictionaryTermEntry, CharacterRecord } from './types';
|
||||
|
||||
test('applyCollapsibleOpenStatesToTermEntries reapplies configured details open states', () => {
|
||||
const termEntries: CharacterDictionaryTermEntry[] = [
|
||||
@@ -56,3 +56,66 @@ test('applyCollapsibleOpenStatesToTermEntries reapplies configured details open
|
||||
assert.equal(glossaryEntry.content.content[0]?.open, true);
|
||||
assert.equal(glossaryEntry.content.content[1]?.open, false);
|
||||
});
|
||||
|
||||
test('buildSnapshotFromCharacters shows Japanese aliases without adding romanized names as lookup entries', () => {
|
||||
const character: CharacterRecord = {
|
||||
id: 1,
|
||||
role: 'main',
|
||||
firstNameHint: '',
|
||||
fullName: 'Aqua',
|
||||
lastNameHint: '',
|
||||
nativeName: 'アクア',
|
||||
alternativeNames: ['阿久亜'],
|
||||
bloodType: '',
|
||||
birthday: null,
|
||||
description: '',
|
||||
imageUrl: null,
|
||||
age: '',
|
||||
sex: '',
|
||||
voiceActors: [],
|
||||
};
|
||||
|
||||
const snapshot = buildSnapshotFromCharacters(
|
||||
100,
|
||||
'KonoSuba',
|
||||
[character],
|
||||
new Map(),
|
||||
new Map(),
|
||||
1_700_000_000_000,
|
||||
() => false,
|
||||
);
|
||||
|
||||
const aquaEntry = snapshot.termEntries.find(([term]) => term === 'アクア');
|
||||
assert.ok(aquaEntry);
|
||||
const glossaryEntry = aquaEntry[5][0] as {
|
||||
content: {
|
||||
content: Array<{ content?: unknown }>;
|
||||
};
|
||||
};
|
||||
const wholeGlossary = JSON.stringify(glossaryEntry);
|
||||
|
||||
const knownNames = glossaryEntry.content.content.find((node) => {
|
||||
const content = node.content;
|
||||
return (
|
||||
Array.isArray(content) &&
|
||||
content.some(
|
||||
(child) =>
|
||||
child &&
|
||||
typeof child === 'object' &&
|
||||
(child as { content?: unknown }).content === 'Known names',
|
||||
)
|
||||
);
|
||||
}) as { content: Array<{ content?: unknown }> } | undefined;
|
||||
assert.ok(knownNames, 'expected a Known names block in the character glossary');
|
||||
const knownNameItems = JSON.stringify(knownNames.content);
|
||||
const terms = snapshot.termEntries.map(([term]) => term);
|
||||
|
||||
assert.match(knownNameItems, /アクア/);
|
||||
assert.match(knownNameItems, /阿久亜/);
|
||||
assert.doesNotMatch(wholeGlossary, /Aqua/);
|
||||
assert.doesNotMatch(knownNameItems, /Aqua/);
|
||||
assert.doesNotMatch(knownNameItems, /アクア様/);
|
||||
assert.equal(terms.includes('Aqua'), false);
|
||||
assert.equal(terms.includes('アクア'), true);
|
||||
assert.equal(terms.includes('阿久亜'), true);
|
||||
});
|
||||
|
||||
@@ -52,3 +52,18 @@ test('readSnapshot ignores snapshots written with an older format version', () =
|
||||
|
||||
assert.equal(readSnapshot(snapshotPath), null);
|
||||
});
|
||||
|
||||
test('readSnapshot ignores v15 snapshots with stale romanized character-name entries', () => {
|
||||
const outputDir = makeTempDir();
|
||||
const snapshotPath = getSnapshotPath(outputDir, 130298);
|
||||
const staleSnapshot = {
|
||||
...createSnapshot(),
|
||||
formatVersion: 15,
|
||||
termEntries: [['Vanir', 'ばにる', 'name primary', '', 75, ['Vanir'], 0, '']],
|
||||
};
|
||||
|
||||
fs.mkdirSync(path.dirname(snapshotPath), { recursive: true });
|
||||
fs.writeFileSync(snapshotPath, JSON.stringify(staleSnapshot), 'utf8');
|
||||
|
||||
assert.equal(readSnapshot(snapshotPath), null);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export const ANILIST_GRAPHQL_URL = 'https://graphql.anilist.co';
|
||||
export const ANILIST_REQUEST_DELAY_MS = 2000;
|
||||
export const CHARACTER_IMAGE_DOWNLOAD_DELAY_MS = 250;
|
||||
export const CHARACTER_DICTIONARY_FORMAT_VERSION = 15;
|
||||
export const CHARACTER_DICTIONARY_FORMAT_VERSION = 16;
|
||||
export const CHARACTER_DICTIONARY_MERGED_TITLE = 'SubMiner Character Dictionary';
|
||||
|
||||
export const HONORIFIC_SUFFIXES = [
|
||||
|
||||
@@ -191,11 +191,51 @@ function mapRole(input: string | null | undefined): CharacterDictionaryRole {
|
||||
return 'side';
|
||||
}
|
||||
|
||||
function inferImageExt(contentType: string | null): string {
|
||||
function inferImageExtFromBytes(bytes: Buffer): string | null {
|
||||
if (
|
||||
bytes.length >= 8 &&
|
||||
bytes[0] === 0x89 &&
|
||||
bytes[1] === 0x50 &&
|
||||
bytes[2] === 0x4e &&
|
||||
bytes[3] === 0x47
|
||||
) {
|
||||
return 'png';
|
||||
}
|
||||
if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
|
||||
return 'jpg';
|
||||
}
|
||||
if (
|
||||
bytes.length >= 12 &&
|
||||
bytes.subarray(0, 4).toString('ascii') === 'RIFF' &&
|
||||
bytes.subarray(8, 12).toString('ascii') === 'WEBP'
|
||||
) {
|
||||
return 'webp';
|
||||
}
|
||||
if (bytes.length >= 6 && bytes.subarray(0, 6).toString('ascii') === 'GIF89a') {
|
||||
return 'gif';
|
||||
}
|
||||
if (bytes.length >= 6 && bytes.subarray(0, 6).toString('ascii') === 'GIF87a') {
|
||||
return 'gif';
|
||||
}
|
||||
if (
|
||||
bytes.length >= 12 &&
|
||||
bytes.subarray(4, 8).toString('ascii') === 'ftyp' &&
|
||||
bytes.subarray(8, 12).toString('ascii') === 'avif'
|
||||
) {
|
||||
return 'avif';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function inferImageExt(contentType: string | null, bytes: Buffer): string {
|
||||
const extFromBytes = inferImageExtFromBytes(bytes);
|
||||
if (extFromBytes) return extFromBytes;
|
||||
|
||||
const normalized = (contentType || '').toLowerCase();
|
||||
if (normalized.includes('png')) return 'png';
|
||||
if (normalized.includes('gif')) return 'gif';
|
||||
if (normalized.includes('webp')) return 'webp';
|
||||
if (normalized.includes('avif')) return 'avif';
|
||||
return 'jpg';
|
||||
}
|
||||
|
||||
@@ -462,7 +502,7 @@ export async function downloadCharacterImage(
|
||||
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'));
|
||||
const ext = inferImageExt(response.headers.get('content-type'), bytes);
|
||||
return {
|
||||
filename: `c${charId}.${ext}`,
|
||||
ext,
|
||||
|
||||
@@ -117,20 +117,44 @@ function buildVoicedByContent(
|
||||
return { tag: 'ul', style: { marginTop: '0.15em' }, content: items };
|
||||
}
|
||||
|
||||
function buildKnownNamesBlock(nameTerms: string[]): Record<string, unknown> | null {
|
||||
const visibleTerms = [...new Set(nameTerms.map((term) => term.trim()).filter(Boolean))];
|
||||
if (visibleTerms.length <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
tag: 'div',
|
||||
style: { fontSize: '0.85em', marginBottom: '0.25em' },
|
||||
content: [
|
||||
{
|
||||
tag: 'div',
|
||||
style: { fontWeight: 'bold', color: '#d0d0d0', marginBottom: '0.1em' },
|
||||
content: 'Known names',
|
||||
},
|
||||
{
|
||||
tag: 'ul',
|
||||
style: { marginTop: '0', marginBottom: '0', paddingLeft: '1.2em' },
|
||||
content: visibleTerms.map((term) => ({
|
||||
tag: 'li',
|
||||
content: term,
|
||||
})),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function createDefinitionGlossary(
|
||||
character: CharacterRecord,
|
||||
mediaTitle: string,
|
||||
imagePath: string | null,
|
||||
vaImagePaths: Map<number, string>,
|
||||
nameTerms: string[],
|
||||
getCollapsibleSectionOpenState: (
|
||||
section: AnilistCharacterDictionaryCollapsibleSectionKey,
|
||||
) => boolean,
|
||||
): CharacterDictionaryGlossaryEntry[] {
|
||||
const displayName = character.nativeName || character.fullName || `Character ${character.id}`;
|
||||
const secondaryName =
|
||||
character.nativeName && character.fullName && character.fullName !== character.nativeName
|
||||
? character.fullName
|
||||
: null;
|
||||
const { fields, text: descriptionText } = parseCharacterDescription(character.description);
|
||||
|
||||
const content: Array<string | Record<string, unknown>> = [
|
||||
@@ -141,12 +165,9 @@ export function createDefinitionGlossary(
|
||||
},
|
||||
];
|
||||
|
||||
if (secondaryName) {
|
||||
content.push({
|
||||
tag: 'div',
|
||||
style: { fontSize: '0.85em', fontStyle: 'italic', color: '#b0b0b0', marginBottom: '0.2em' },
|
||||
content: secondaryName,
|
||||
});
|
||||
const knownNamesBlock = buildKnownNamesBlock(nameTerms);
|
||||
if (knownNamesBlock) {
|
||||
content.push(knownNamesBlock);
|
||||
}
|
||||
|
||||
if (imagePath) {
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
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 { getSnapshotPath, writeSnapshot } from './cache';
|
||||
import { CHARACTER_DICTIONARY_FORMAT_VERSION } from './constants';
|
||||
import { buildCharacterNameImageIndexFromSnapshots } from './image-lookup';
|
||||
import type { CharacterDictionarySnapshot } from './types';
|
||||
|
||||
const PNG_1X1_BASE64 =
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+nmX8AAAAASUVORK5CYII=';
|
||||
|
||||
function makeTempDir(): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-character-image-lookup-'));
|
||||
}
|
||||
|
||||
test('buildCharacterNameImageIndexFromSnapshots maps name terms to character portrait data URLs', () => {
|
||||
const outputDir = makeTempDir();
|
||||
const snapshot: CharacterDictionarySnapshot = {
|
||||
formatVersion: CHARACTER_DICTIONARY_FORMAT_VERSION,
|
||||
mediaId: 130298,
|
||||
mediaTitle: 'The Eminence in Shadow',
|
||||
entryCount: 1,
|
||||
updatedAt: 1_700_000_000_000,
|
||||
termEntries: [
|
||||
[
|
||||
'アレクシア',
|
||||
'あれくしあ',
|
||||
'name primary',
|
||||
'',
|
||||
75,
|
||||
[
|
||||
{
|
||||
type: 'structured-content',
|
||||
content: {
|
||||
tag: 'div',
|
||||
content: [
|
||||
{ tag: 'div', content: 'アレクシア・ミドガル' },
|
||||
{
|
||||
tag: 'div',
|
||||
content: {
|
||||
tag: 'img',
|
||||
path: 'img/m130298-c123.png',
|
||||
alt: 'アレクシア・ミドガル',
|
||||
},
|
||||
},
|
||||
{
|
||||
tag: 'details',
|
||||
content: [
|
||||
{ tag: 'summary', content: 'Voiced by' },
|
||||
{
|
||||
tag: 'div',
|
||||
content: {
|
||||
tag: 'img',
|
||||
path: 'img/m130298-va456.png',
|
||||
alt: 'VA',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
0,
|
||||
'',
|
||||
],
|
||||
],
|
||||
images: [
|
||||
{ path: 'img/m130298-c123.png', dataBase64: 'AAAA' },
|
||||
{ path: 'img/m130298-va456.png', dataBase64: 'BBBB' },
|
||||
],
|
||||
};
|
||||
writeSnapshot(getSnapshotPath(outputDir, snapshot.mediaId), snapshot);
|
||||
|
||||
const index = buildCharacterNameImageIndexFromSnapshots(outputDir);
|
||||
|
||||
assert.deepEqual(index.get('アレクシア'), {
|
||||
src: 'data:image/png;base64,AAAA',
|
||||
alt: 'アレクシア・ミドガル',
|
||||
});
|
||||
});
|
||||
|
||||
test('buildCharacterNameImageIndexFromSnapshots sniffs image MIME from bytes before path extension', () => {
|
||||
const outputDir = makeTempDir();
|
||||
const snapshot: CharacterDictionarySnapshot = {
|
||||
formatVersion: CHARACTER_DICTIONARY_FORMAT_VERSION,
|
||||
mediaId: 130298,
|
||||
mediaTitle: 'The Eminence in Shadow',
|
||||
entryCount: 1,
|
||||
updatedAt: 1_700_000_000_000,
|
||||
termEntries: [
|
||||
[
|
||||
'アレクシア',
|
||||
'あれくしあ',
|
||||
'name primary',
|
||||
'',
|
||||
75,
|
||||
[
|
||||
{
|
||||
type: 'structured-content',
|
||||
content: {
|
||||
tag: 'img',
|
||||
path: 'img/m130298-c123.jpg',
|
||||
alt: 'アレクシア・ミドガル',
|
||||
},
|
||||
},
|
||||
],
|
||||
0,
|
||||
'',
|
||||
],
|
||||
],
|
||||
images: [{ path: 'img/m130298-c123.jpg', dataBase64: PNG_1X1_BASE64 }],
|
||||
};
|
||||
writeSnapshot(getSnapshotPath(outputDir, snapshot.mediaId), snapshot);
|
||||
|
||||
const index = buildCharacterNameImageIndexFromSnapshots(outputDir);
|
||||
|
||||
assert.equal(index.get('アレクシア')?.src, `data:image/png;base64,${PNG_1X1_BASE64}`);
|
||||
});
|
||||
@@ -0,0 +1,249 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import type { CharacterNameImage } from '../../types';
|
||||
import { readCachedSnapshots } from './cache';
|
||||
import type {
|
||||
CharacterDictionaryGlossaryEntry,
|
||||
CharacterDictionarySnapshot,
|
||||
CharacterDictionarySnapshotImage,
|
||||
CharacterDictionaryTermEntry,
|
||||
} from './types';
|
||||
|
||||
const CHARACTER_IMAGE_PATH_PATTERN = /^img\/m\d+-c\d+\.[a-z0-9]+$/i;
|
||||
|
||||
type StructuredContentNode = {
|
||||
tag?: unknown;
|
||||
path?: unknown;
|
||||
alt?: unknown;
|
||||
title?: unknown;
|
||||
content?: unknown;
|
||||
};
|
||||
|
||||
function normalizeLookupTerm(term: string): string {
|
||||
return term.trim();
|
||||
}
|
||||
|
||||
function getSnapshotsDir(outputDir: string): string {
|
||||
return path.join(outputDir, 'snapshots');
|
||||
}
|
||||
|
||||
function getImageMimeType(imagePath: string, dataBase64: string): string {
|
||||
const signature = Buffer.from(dataBase64.slice(0, 64), 'base64');
|
||||
if (
|
||||
signature.length >= 8 &&
|
||||
signature[0] === 0x89 &&
|
||||
signature[1] === 0x50 &&
|
||||
signature[2] === 0x4e &&
|
||||
signature[3] === 0x47
|
||||
) {
|
||||
return 'image/png';
|
||||
}
|
||||
if (
|
||||
signature.length >= 12 &&
|
||||
signature.subarray(0, 4).toString('ascii') === 'RIFF' &&
|
||||
signature.subarray(8, 12).toString('ascii') === 'WEBP'
|
||||
) {
|
||||
return 'image/webp';
|
||||
}
|
||||
if (
|
||||
signature.length >= 6 &&
|
||||
(signature.subarray(0, 6).toString('ascii') === 'GIF89a' ||
|
||||
signature.subarray(0, 6).toString('ascii') === 'GIF87a')
|
||||
) {
|
||||
return 'image/gif';
|
||||
}
|
||||
if (signature.length >= 3 && signature[0] === 0xff && signature[1] === 0xd8) {
|
||||
return 'image/jpeg';
|
||||
}
|
||||
if (
|
||||
signature.length >= 12 &&
|
||||
signature.subarray(4, 8).toString('ascii') === 'ftyp' &&
|
||||
signature.subarray(8, 12).toString('ascii') === 'avif'
|
||||
) {
|
||||
return 'image/avif';
|
||||
}
|
||||
|
||||
const ext = path.extname(imagePath).toLowerCase();
|
||||
if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg';
|
||||
if (ext === '.png') return 'image/png';
|
||||
if (ext === '.webp') return 'image/webp';
|
||||
if (ext === '.gif') return 'image/gif';
|
||||
if (ext === '.avif') return 'image/avif';
|
||||
return 'image/jpeg';
|
||||
}
|
||||
|
||||
function buildImageByPath(
|
||||
images: ReadonlyArray<CharacterDictionarySnapshotImage>,
|
||||
): Map<string, CharacterDictionarySnapshotImage> {
|
||||
const imageByPath = new Map<string, CharacterDictionarySnapshotImage>();
|
||||
for (const image of images) {
|
||||
if (image.path && image.dataBase64) {
|
||||
imageByPath.set(image.path, image);
|
||||
}
|
||||
}
|
||||
return imageByPath;
|
||||
}
|
||||
|
||||
function findCharacterImageNode(value: unknown): StructuredContentNode | null {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
const found = findCharacterImageNode(item);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const node = value as StructuredContentNode;
|
||||
if (
|
||||
node.tag === 'img' &&
|
||||
typeof node.path === 'string' &&
|
||||
CHARACTER_IMAGE_PATH_PATTERN.test(node.path)
|
||||
) {
|
||||
return node;
|
||||
}
|
||||
|
||||
return findCharacterImageNode(node.content);
|
||||
}
|
||||
|
||||
function findCharacterImageNodeInGlossary(
|
||||
glossary: ReadonlyArray<CharacterDictionaryGlossaryEntry>,
|
||||
): StructuredContentNode | null {
|
||||
for (const entry of glossary) {
|
||||
const found = findCharacterImageNode(entry);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function createCharacterNameImage(
|
||||
entry: CharacterDictionaryTermEntry,
|
||||
imageByPath: ReadonlyMap<string, CharacterDictionarySnapshotImage>,
|
||||
): CharacterNameImage | null {
|
||||
const term = normalizeLookupTerm(entry[0]);
|
||||
if (!term) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const imageNode = findCharacterImageNodeInGlossary(entry[5]);
|
||||
const imagePath = typeof imageNode?.path === 'string' ? imageNode.path : '';
|
||||
const image = imageByPath.get(imagePath);
|
||||
if (!image) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawAlt =
|
||||
typeof imageNode?.alt === 'string'
|
||||
? imageNode.alt
|
||||
: typeof imageNode?.title === 'string'
|
||||
? imageNode.title
|
||||
: term;
|
||||
const alt = rawAlt.trim() || term;
|
||||
return {
|
||||
src: `data:${getImageMimeType(image.path, image.dataBase64)};base64,${image.dataBase64}`,
|
||||
alt,
|
||||
};
|
||||
}
|
||||
|
||||
function appendSnapshotImages(
|
||||
index: Map<string, CharacterNameImage>,
|
||||
snapshot: CharacterDictionarySnapshot,
|
||||
): void {
|
||||
const imageByPath = buildImageByPath(snapshot.images);
|
||||
for (const entry of snapshot.termEntries) {
|
||||
const term = normalizeLookupTerm(entry[0]);
|
||||
if (!term || index.has(term)) {
|
||||
continue;
|
||||
}
|
||||
const image = createCharacterNameImage(entry, imageByPath);
|
||||
if (image) {
|
||||
index.set(term, image);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function snapshotHasCharacterNameImages(snapshot: CharacterDictionarySnapshot): boolean {
|
||||
const imageByPath = buildImageByPath(snapshot.images);
|
||||
return snapshot.termEntries.some(
|
||||
(entry) => createCharacterNameImage(entry, imageByPath) !== null,
|
||||
);
|
||||
}
|
||||
|
||||
function getSnapshotDirectorySignature(outputDir: string): string {
|
||||
let entries: fs.Dirent[] = [];
|
||||
try {
|
||||
entries = fs.readdirSync(getSnapshotsDir(outputDir), { withFileTypes: true });
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile() || !/^anilist-\d+\.json$/.test(entry.name)) {
|
||||
continue;
|
||||
}
|
||||
const snapshotPath = path.join(getSnapshotsDir(outputDir), entry.name);
|
||||
try {
|
||||
const stat = fs.statSync(snapshotPath);
|
||||
parts.push(`${entry.name}:${stat.mtimeMs}:${stat.size}`);
|
||||
} catch {
|
||||
// Ignore files that disappear during refresh; next lookup will rebuild.
|
||||
}
|
||||
}
|
||||
return parts.sort().join('|');
|
||||
}
|
||||
|
||||
export function buildCharacterNameImageIndexFromSnapshots(
|
||||
outputDir: string,
|
||||
): Map<string, CharacterNameImage> {
|
||||
const index = new Map<string, CharacterNameImage>();
|
||||
for (const snapshot of readCachedSnapshots(outputDir)) {
|
||||
appendSnapshotImages(index, snapshot);
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
export function createCharacterDictionaryImageLookup(deps: {
|
||||
userDataPath?: string;
|
||||
outputDir?: string;
|
||||
}): {
|
||||
get: (term: string) => CharacterNameImage | null;
|
||||
invalidate: () => void;
|
||||
} {
|
||||
const outputDir =
|
||||
deps.outputDir ??
|
||||
(deps.userDataPath ? path.join(deps.userDataPath, 'character-dictionaries') : '');
|
||||
let signature: string | null = null;
|
||||
let index = new Map<string, CharacterNameImage>();
|
||||
|
||||
function refreshIfNeeded(): void {
|
||||
if (!outputDir) {
|
||||
index = new Map<string, CharacterNameImage>();
|
||||
signature = '';
|
||||
return;
|
||||
}
|
||||
const nextSignature = getSnapshotDirectorySignature(outputDir);
|
||||
if (nextSignature === signature) {
|
||||
return;
|
||||
}
|
||||
signature = nextSignature;
|
||||
index = buildCharacterNameImageIndexFromSnapshots(outputDir);
|
||||
}
|
||||
|
||||
return {
|
||||
get(term: string): CharacterNameImage | null {
|
||||
const normalizedTerm = normalizeLookupTerm(term);
|
||||
if (!normalizedTerm) {
|
||||
return null;
|
||||
}
|
||||
refreshIfNeeded();
|
||||
return index.get(normalizedTerm) ?? null;
|
||||
},
|
||||
invalidate(): void {
|
||||
signature = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
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';
|
||||
import { buildCharacterDictionarySeriesKey } from './manual-selection';
|
||||
|
||||
function makeTempDir(): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-character-dictionary-'));
|
||||
}
|
||||
|
||||
test('getManualSelectionSnapshot waits for explicit search text before fetching candidates', async () => {
|
||||
const userDataPath = makeTempDir();
|
||||
const originalFetch = globalThis.fetch;
|
||||
const searchTerms: string[] = [];
|
||||
|
||||
globalThis.fetch = (async (_input: string | URL | Request, init?: RequestInit) => {
|
||||
const body = JSON.parse(String(init?.body ?? '{}')) as {
|
||||
variables?: { search?: string };
|
||||
};
|
||||
searchTerms.push(String(body.variables?.search ?? ''));
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
Page: {
|
||||
media: [
|
||||
{
|
||||
id: 154587,
|
||||
episodes: 28,
|
||||
title: {
|
||||
romaji: 'Sousou no Frieren',
|
||||
english: 'Frieren: Beyond Journey’s End',
|
||||
native: '葬送のフリーレン',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
},
|
||||
);
|
||||
}) as typeof globalThis.fetch;
|
||||
|
||||
try {
|
||||
const runtime = createCharacterDictionaryRuntimeService({
|
||||
userDataPath,
|
||||
getCurrentMediaPath: () => '/tmp/[SubsPlease] Kage no Jitsuryokusha - 05.mkv',
|
||||
getCurrentMediaTitle: () => '[SubsPlease] Kage no Jitsuryokusha - 05.mkv',
|
||||
resolveMediaPathForJimaku: (mediaPath) => mediaPath,
|
||||
guessAnilistMediaInfo: async () => ({
|
||||
title: 'Kage no Jitsuryokusha ni Naritakute!',
|
||||
season: null,
|
||||
episode: 5,
|
||||
source: 'guessit',
|
||||
}),
|
||||
now: () => 1_700_000_000_000,
|
||||
});
|
||||
|
||||
const initial = await runtime.getManualSelectionSnapshot(undefined, '');
|
||||
assert.equal(initial.guessTitle, 'Kage no Jitsuryokusha ni Naritakute!');
|
||||
assert.deepEqual(initial.candidates, []);
|
||||
assert.deepEqual(searchTerms, []);
|
||||
|
||||
const searched = await runtime.getManualSelectionSnapshot(undefined, 'Frieren');
|
||||
assert.deepEqual(searchTerms, ['Frieren']);
|
||||
assert.deepEqual(searched.candidates, [
|
||||
{ id: 154587, title: 'Frieren: Beyond Journey’s End', episodes: 28 },
|
||||
]);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('getManualSelectionSnapshot hydrates override episode count from searched candidates', async () => {
|
||||
const userDataPath = makeTempDir();
|
||||
const overrideSeriesKey = buildCharacterDictionarySeriesKey({
|
||||
mediaPath: '/tmp/KonoSuba - 01.mkv',
|
||||
mediaTitle: 'KonoSuba - 01.mkv',
|
||||
guess: {
|
||||
title: "KonoSuba - God's blessing on this wonderful world!",
|
||||
year: 2016,
|
||||
season: null,
|
||||
episode: 1,
|
||||
source: 'guessit',
|
||||
},
|
||||
});
|
||||
const overrideDir = path.join(userDataPath, 'character-dictionaries');
|
||||
fs.mkdirSync(overrideDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(overrideDir, 'anilist-overrides.json'),
|
||||
JSON.stringify({
|
||||
overrides: [
|
||||
{
|
||||
seriesKey: overrideSeriesKey,
|
||||
mediaId: 21202,
|
||||
mediaTitle: "KONOSUBA -God's blessing on this wonderful world!",
|
||||
staleMediaIds: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
'utf8',
|
||||
);
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
globalThis.fetch = (async (_input: string | URL | Request) => {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
Page: {
|
||||
media: [
|
||||
{
|
||||
id: 21202,
|
||||
episodes: 10,
|
||||
title: {
|
||||
romaji: 'Kono Subarashii Sekai ni Shukufuku wo!',
|
||||
english: "KONOSUBA -God's blessing on this wonderful world!",
|
||||
native: 'この素晴らしい世界に祝福を!',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
},
|
||||
);
|
||||
}) as typeof globalThis.fetch;
|
||||
|
||||
try {
|
||||
const runtime = createCharacterDictionaryRuntimeService({
|
||||
userDataPath,
|
||||
getCurrentMediaPath: () => '/tmp/KonoSuba - 01.mkv',
|
||||
getCurrentMediaTitle: () => 'KonoSuba - 01.mkv',
|
||||
resolveMediaPathForJimaku: (mediaPath) => mediaPath,
|
||||
guessAnilistMediaInfo: async () => ({
|
||||
title: "KonoSuba - God's blessing on this wonderful world!",
|
||||
year: 2016,
|
||||
season: null,
|
||||
episode: 1,
|
||||
source: 'guessit',
|
||||
}),
|
||||
now: () => 1_700_000_000_000,
|
||||
});
|
||||
|
||||
const snapshot = await runtime.getManualSelectionSnapshot(undefined, 'KonoSuba');
|
||||
|
||||
assert.deepEqual(snapshot.override, {
|
||||
id: 21202,
|
||||
title: "KONOSUBA -God's blessing on this wonderful world!",
|
||||
episodes: 10,
|
||||
});
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
@@ -10,15 +10,17 @@ import {
|
||||
} from './manual-selection';
|
||||
|
||||
const REZERO_EP1 =
|
||||
'/anime/Re - ZERO, Starting Life in Another World (2016) - S01E01 - - The End of the Beginning and the Beginning of the End [v2 Bluray-1080p Proper][10bit][x265][FLAC 2.0][EN+JA]-SCY.mkv';
|
||||
'/anime/ReZERO/Season 1/Re - ZERO, Starting Life in Another World (2016) - S01E01 - - The End of the Beginning and the Beginning of the End [v2 Bluray-1080p Proper][10bit][x265][FLAC 2.0][EN+JA]-SCY.mkv';
|
||||
const REZERO_EP2 =
|
||||
'/anime/Re - ZERO, Starting Life in Another World (2016) - S01E02 - Reunion with the Witch [Bluray-1080p][x265][JA]-SCY.mkv';
|
||||
'/anime/ReZERO/Season 1/Re - ZERO, Starting Life in Another World (2016) - S01E02 - Reunion with the Witch [Bluray-1080p][x265][JA]-SCY.mkv';
|
||||
const REZERO_S2_EP1 =
|
||||
'/anime/ReZERO/Season 2/Re - ZERO, Starting Life in Another World (2016) - S02E01 - Each Ones Promise [Bluray-1080p][x265][JA]-SCY.mkv';
|
||||
|
||||
function makeTempDir(): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-manual-selection-'));
|
||||
}
|
||||
|
||||
test('buildCharacterDictionarySeriesKey uses guessit title, alternative title, and year for Re ZERO series scope', () => {
|
||||
test('buildCharacterDictionarySeriesKey scopes guessit title and year by media directory', () => {
|
||||
const key = buildCharacterDictionarySeriesKey({
|
||||
mediaPath: REZERO_EP1,
|
||||
mediaTitle: null,
|
||||
@@ -32,10 +34,10 @@ test('buildCharacterDictionarySeriesKey uses guessit title, alternative title, a
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(key, 're-zero-starting-life-in-another-world-2016');
|
||||
assert.equal(key, 'anime-rezero-season-1--re-zero-starting-life-in-another-world-2016');
|
||||
});
|
||||
|
||||
test('manual selection store persists overrides and matches later episodes in the same series', async () => {
|
||||
test('manual selection store persists overrides and matches later episodes in the same directory', async () => {
|
||||
const userDataPath = makeTempDir();
|
||||
const store = createCharacterDictionaryManualSelectionStore({ userDataPath });
|
||||
const firstKey = buildCharacterDictionarySeriesKey({
|
||||
@@ -79,3 +81,131 @@ test('manual selection store persists overrides and matches later episodes in th
|
||||
staleMediaIds: [10607],
|
||||
});
|
||||
});
|
||||
|
||||
test('manual selection store resolves legacy unscoped override keys', async () => {
|
||||
const userDataPath = makeTempDir();
|
||||
const overrideDir = path.join(userDataPath, 'character-dictionaries');
|
||||
fs.mkdirSync(overrideDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(overrideDir, 'anilist-overrides.json'),
|
||||
JSON.stringify({
|
||||
overrides: [
|
||||
{
|
||||
seriesKey: 're-zero-starting-life-in-another-world-2016',
|
||||
mediaId: 21355,
|
||||
mediaTitle: 'Re:ZERO -Starting Life in Another World-',
|
||||
staleMediaIds: [10607],
|
||||
},
|
||||
],
|
||||
}),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const scopedKey = buildCharacterDictionarySeriesKey({
|
||||
mediaPath: REZERO_EP1,
|
||||
mediaTitle: null,
|
||||
guess: {
|
||||
title: 'Re ZERO, Starting Life in Another World',
|
||||
alternativeTitle: 'ZERO, Starting Life in Another World',
|
||||
year: 2016,
|
||||
season: 1,
|
||||
episode: 1,
|
||||
source: 'guessit',
|
||||
},
|
||||
});
|
||||
|
||||
const store = createCharacterDictionaryManualSelectionStore({ userDataPath });
|
||||
|
||||
assert.deepEqual(await store.getOverride(scopedKey), {
|
||||
seriesKey: 're-zero-starting-life-in-another-world-2016',
|
||||
mediaId: 21355,
|
||||
mediaTitle: 'Re:ZERO -Starting Life in Another World-',
|
||||
staleMediaIds: [10607],
|
||||
});
|
||||
});
|
||||
|
||||
test('manual selection store prefers exact scoped override over legacy fallback', async () => {
|
||||
const userDataPath = makeTempDir();
|
||||
const overrideDir = path.join(userDataPath, 'character-dictionaries');
|
||||
fs.mkdirSync(overrideDir, { recursive: true });
|
||||
const scopedKey = buildCharacterDictionarySeriesKey({
|
||||
mediaPath: REZERO_EP1,
|
||||
mediaTitle: null,
|
||||
guess: {
|
||||
title: 'Re ZERO, Starting Life in Another World',
|
||||
alternativeTitle: 'ZERO, Starting Life in Another World',
|
||||
year: 2016,
|
||||
season: 1,
|
||||
episode: 1,
|
||||
source: 'guessit',
|
||||
},
|
||||
});
|
||||
fs.writeFileSync(
|
||||
path.join(overrideDir, 'anilist-overrides.json'),
|
||||
JSON.stringify({
|
||||
overrides: [
|
||||
{
|
||||
seriesKey: 're-zero-starting-life-in-another-world-2016',
|
||||
mediaId: 10607,
|
||||
mediaTitle: 'Legacy Re:ZERO',
|
||||
staleMediaIds: [],
|
||||
},
|
||||
{
|
||||
seriesKey: scopedKey,
|
||||
mediaId: 21355,
|
||||
mediaTitle: 'Re:ZERO -Starting Life in Another World-',
|
||||
staleMediaIds: [10607],
|
||||
},
|
||||
],
|
||||
}),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const store = createCharacterDictionaryManualSelectionStore({ userDataPath });
|
||||
|
||||
assert.deepEqual(await store.getOverride(scopedKey), {
|
||||
seriesKey: scopedKey,
|
||||
mediaId: 21355,
|
||||
mediaTitle: 'Re:ZERO -Starting Life in Another World-',
|
||||
staleMediaIds: [10607],
|
||||
});
|
||||
});
|
||||
|
||||
test('manual selection store keeps overrides separate for different season directories', async () => {
|
||||
const userDataPath = makeTempDir();
|
||||
const store = createCharacterDictionaryManualSelectionStore({ userDataPath });
|
||||
const firstSeasonKey = buildCharacterDictionarySeriesKey({
|
||||
mediaPath: REZERO_EP1,
|
||||
mediaTitle: null,
|
||||
guess: {
|
||||
title: 'Re ZERO, Starting Life in Another World',
|
||||
alternativeTitle: 'ZERO, Starting Life in Another World',
|
||||
year: 2016,
|
||||
season: 1,
|
||||
episode: 1,
|
||||
source: 'guessit',
|
||||
},
|
||||
});
|
||||
await store.setOverride({
|
||||
seriesKey: firstSeasonKey,
|
||||
mediaId: 21355,
|
||||
mediaTitle: 'Re:ZERO -Starting Life in Another World-',
|
||||
staleMediaIds: [],
|
||||
});
|
||||
|
||||
const secondSeasonKey = buildCharacterDictionarySeriesKey({
|
||||
mediaPath: REZERO_S2_EP1,
|
||||
mediaTitle: null,
|
||||
guess: {
|
||||
title: 'Re ZERO, Starting Life in Another World',
|
||||
alternativeTitle: 'ZERO, Starting Life in Another World',
|
||||
year: 2016,
|
||||
season: 2,
|
||||
episode: 1,
|
||||
source: 'guessit',
|
||||
},
|
||||
});
|
||||
|
||||
assert.notEqual(secondSeasonKey, firstSeasonKey);
|
||||
assert.equal(await store.getOverride(secondSeasonKey), null);
|
||||
});
|
||||
|
||||
@@ -31,6 +31,29 @@ function normalizeSeriesKeyPart(value: string): string {
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function getMediaDirectoryKey(mediaPath: string | null): string {
|
||||
const rawPath = mediaPath?.trim();
|
||||
if (!rawPath) return '';
|
||||
|
||||
if (/^[a-zA-Z][a-zA-Z\d+.-]*:\/\//.test(rawPath) || rawPath.startsWith('file:')) {
|
||||
try {
|
||||
const url = new URL(rawPath);
|
||||
const directoryPath = path.posix.dirname(
|
||||
decodeURIComponent(url.pathname).replace(/\\/g, '/'),
|
||||
);
|
||||
const scopedPath = `${url.hostname}${directoryPath === '/' ? '' : directoryPath}`;
|
||||
return normalizeSeriesKeyPart(scopedPath);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedPath = rawPath.replace(/\\/g, '/');
|
||||
const directoryPath = path.posix.dirname(normalizedPath);
|
||||
if (!directoryPath || directoryPath === '.') return '';
|
||||
return normalizeSeriesKeyPart(directoryPath);
|
||||
}
|
||||
|
||||
function dedupeNumbers(values: number[]): number[] {
|
||||
const seen = new Set<number>();
|
||||
const result: number[] = [];
|
||||
@@ -78,6 +101,12 @@ function writeOverrides(filePath: string, overrides: CharacterDictionaryManualSe
|
||||
fs.writeFileSync(filePath, JSON.stringify({ overrides }, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
function getLegacySeriesKeyCandidates(seriesKey: string): string[] {
|
||||
const scopedSeparatorIndex = seriesKey.indexOf('--');
|
||||
if (scopedSeparatorIndex < 0) return [seriesKey];
|
||||
return [seriesKey, seriesKey.slice(scopedSeparatorIndex + 2)];
|
||||
}
|
||||
|
||||
export function buildCharacterDictionarySeriesKey(input: {
|
||||
mediaPath: string | null;
|
||||
mediaTitle: string | null;
|
||||
@@ -94,7 +123,9 @@ export function buildCharacterDictionarySeriesKey(input: {
|
||||
.replace(/\bepisode\s+\d+\b/gi, ' ')
|
||||
.trim();
|
||||
const base = normalizeSeriesKeyPart(withoutEpisode) || 'unknown';
|
||||
return input.guess?.year ? `${base}-${input.guess.year}` : base;
|
||||
const directoryKey = getMediaDirectoryKey(input.mediaPath);
|
||||
const scopedBase = directoryKey ? `${directoryKey}--${base}` : base;
|
||||
return input.guess?.year ? `${scopedBase}-${input.guess.year}` : scopedBase;
|
||||
}
|
||||
|
||||
export function createCharacterDictionaryManualSelectionStore(deps: { userDataPath: string }) {
|
||||
@@ -102,7 +133,13 @@ export function createCharacterDictionaryManualSelectionStore(deps: { userDataPa
|
||||
|
||||
return {
|
||||
getOverride: async (seriesKey: string): Promise<CharacterDictionaryManualSelection | null> => {
|
||||
return readOverrides(filePath).find((entry) => entry.seriesKey === seriesKey) ?? null;
|
||||
const candidates = getLegacySeriesKeyCandidates(seriesKey);
|
||||
const overrides = readOverrides(filePath);
|
||||
for (const candidate of candidates) {
|
||||
const match = overrides.find((entry) => entry.seriesKey === candidate);
|
||||
if (match) return match;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
setOverride: async (selection: CharacterDictionaryManualSelection): Promise<void> => {
|
||||
const normalized = normalizeOverride(selection);
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
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';
|
||||
import { getSnapshotPath, writeSnapshot } from './cache';
|
||||
import { CHARACTER_DICTIONARY_FORMAT_VERSION } from './constants';
|
||||
import type { CharacterDictionarySnapshot } from './types';
|
||||
|
||||
const GRAPHQL_URL = 'https://graphql.anilist.co';
|
||||
const PNG_1X1 = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+nmX8AAAAASUVORK5CYII=',
|
||||
'base64',
|
||||
);
|
||||
|
||||
function makeTempDir(): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-character-dictionary-'));
|
||||
}
|
||||
|
||||
function createSnapshotWithoutImages(): CharacterDictionarySnapshot {
|
||||
return {
|
||||
formatVersion: CHARACTER_DICTIONARY_FORMAT_VERSION,
|
||||
mediaId: 130298,
|
||||
mediaTitle: 'The Eminence in Shadow',
|
||||
entryCount: 1,
|
||||
updatedAt: 1_700_000_000_000,
|
||||
termEntries: [['アレクシア', 'あれくしあ', 'name primary', '', 75, ['Alexia'], 0, '']],
|
||||
images: [],
|
||||
};
|
||||
}
|
||||
|
||||
test('generateForCurrentMedia refreshes same-version snapshots missing images when inline images are enabled', async () => {
|
||||
const userDataPath = makeTempDir();
|
||||
const outputDir = path.join(userDataPath, 'character-dictionaries');
|
||||
writeSnapshot(getSnapshotPath(outputDir, 130298), createSnapshotWithoutImages());
|
||||
const originalFetch = globalThis.fetch;
|
||||
const fetchUrls: string[] = [];
|
||||
|
||||
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
|
||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
||||
fetchUrls.push(url);
|
||||
|
||||
if (url === GRAPHQL_URL) {
|
||||
const body = JSON.parse(String(init?.body ?? '{}')) as {
|
||||
query?: string;
|
||||
};
|
||||
if (body.query?.includes('characters(page: $page')) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
Media: {
|
||||
title: {
|
||||
english: 'The Eminence in Shadow',
|
||||
},
|
||||
characters: {
|
||||
pageInfo: { hasNextPage: false },
|
||||
edges: [
|
||||
{
|
||||
role: 'SUPPORTING',
|
||||
node: {
|
||||
id: 123,
|
||||
description: 'Alexia Midgar.',
|
||||
image: {
|
||||
large: 'https://cdn.example.com/character-123.png',
|
||||
medium: null,
|
||||
},
|
||||
name: {
|
||||
full: 'Alexia Midgar',
|
||||
native: 'アレクシア・ミドガル',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (url === 'https://cdn.example.com/character-123.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',
|
||||
season: null,
|
||||
episode: 5,
|
||||
source: 'fallback',
|
||||
}),
|
||||
getNameMatchImagesEnabled: () => true,
|
||||
now: () => 1_700_000_000_500,
|
||||
});
|
||||
|
||||
const result = await runtime.generateForCurrentMedia();
|
||||
const refreshedSnapshot = JSON.parse(
|
||||
fs.readFileSync(getSnapshotPath(outputDir, 130298), 'utf8'),
|
||||
) as CharacterDictionarySnapshot;
|
||||
|
||||
assert.equal(result.fromCache, false);
|
||||
assert.ok(fetchUrls.includes(GRAPHQL_URL));
|
||||
assert.ok(refreshedSnapshot.images.some((image) => image.path === 'img/m130298-c123.png'));
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('generateForCurrentMedia keeps same-version snapshots without images when inline images are disabled', async () => {
|
||||
const userDataPath = makeTempDir();
|
||||
const outputDir = path.join(userDataPath, 'character-dictionaries');
|
||||
writeSnapshot(getSnapshotPath(outputDir, 130298), createSnapshotWithoutImages());
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
globalThis.fetch = (async (input: string | URL | Request) => {
|
||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
||||
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',
|
||||
season: null,
|
||||
episode: 5,
|
||||
source: 'fallback',
|
||||
}),
|
||||
getNameMatchImagesEnabled: () => false,
|
||||
now: () => 1_700_000_000_500,
|
||||
});
|
||||
|
||||
const result = await runtime.generateForCurrentMedia();
|
||||
|
||||
assert.equal(result.fromCache, true);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
@@ -2,7 +2,12 @@ import type { AnilistCharacterDictionaryCollapsibleSectionKey } from '../../type
|
||||
import { CHARACTER_DICTIONARY_FORMAT_VERSION } from './constants';
|
||||
import { createDefinitionGlossary } from './glossary';
|
||||
import { generateNameReadings, splitJapaneseName } from './name-reading';
|
||||
import { buildNameTerms, buildReadingForTerm, buildTermEntry } from './term-building';
|
||||
import {
|
||||
buildNameTerms,
|
||||
buildReadingForTerm,
|
||||
buildTermEntry,
|
||||
buildVisibleNameTerms,
|
||||
} from './term-building';
|
||||
import type {
|
||||
CharacterDictionaryGlossaryEntry,
|
||||
CharacterDictionarySnapshot,
|
||||
@@ -40,14 +45,15 @@ export function buildSnapshotFromCharacters(
|
||||
const vaImg = imagesByVaId.get(va.id);
|
||||
if (vaImg) vaImagePaths.set(va.id, vaImg.path);
|
||||
}
|
||||
const candidateTerms = buildNameTerms(character);
|
||||
const glossary = createDefinitionGlossary(
|
||||
character,
|
||||
mediaTitle,
|
||||
imagePath,
|
||||
vaImagePaths,
|
||||
buildVisibleNameTerms(candidateTerms),
|
||||
getCollapsibleSectionOpenState,
|
||||
);
|
||||
const candidateTerms = buildNameTerms(character);
|
||||
const nameParts = splitJapaneseName(
|
||||
character.nativeName,
|
||||
character.firstNameHint,
|
||||
|
||||
@@ -41,25 +41,27 @@ function expandRawNameVariants(rawName: string): string[] {
|
||||
|
||||
export function buildNameTerms(character: CharacterRecord): string[] {
|
||||
const base = new Set<string>();
|
||||
const romanizedBase = new Set<string>();
|
||||
const rawNames = [character.nativeName, character.fullName, ...character.alternativeNames];
|
||||
for (const rawName of rawNames) {
|
||||
for (const name of expandRawNameVariants(rawName)) {
|
||||
base.add(name);
|
||||
const target = isRomanizedName(name) ? romanizedBase : base;
|
||||
target.add(name);
|
||||
|
||||
const compact = name.replace(/[\s\u3000]+/g, '');
|
||||
if (compact && compact !== name) {
|
||||
base.add(compact);
|
||||
target.add(compact);
|
||||
}
|
||||
|
||||
const noMiddleDots = compact.replace(/[・・·•]/g, '');
|
||||
if (noMiddleDots && noMiddleDots !== compact) {
|
||||
base.add(noMiddleDots);
|
||||
target.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]!);
|
||||
target.add(split[0]!);
|
||||
target.add(split[1]!);
|
||||
}
|
||||
|
||||
const splitByMiddleDot = name
|
||||
@@ -68,12 +70,16 @@ export function buildNameTerms(character: CharacterRecord): string[] {
|
||||
.filter((part) => part.length > 0);
|
||||
if (splitByMiddleDot.length >= 2) {
|
||||
for (const part of splitByMiddleDot) {
|
||||
base.add(part);
|
||||
target.add(part);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const alias of addRomanizedKanaAliases(romanizedBase)) {
|
||||
base.add(alias);
|
||||
}
|
||||
|
||||
const nativeParts = splitJapaneseName(
|
||||
character.nativeName,
|
||||
character.firstNameHint,
|
||||
@@ -94,16 +100,24 @@ export function buildNameTerms(character: CharacterRecord): string[] {
|
||||
}
|
||||
}
|
||||
|
||||
for (const alias of addRomanizedKanaAliases(withHonorifics)) {
|
||||
withHonorifics.add(alias);
|
||||
for (const suffix of HONORIFIC_SUFFIXES) {
|
||||
withHonorifics.add(`${alias}${suffix.term}`);
|
||||
}
|
||||
}
|
||||
|
||||
return [...withHonorifics].filter((entry) => entry.trim().length > 0);
|
||||
}
|
||||
|
||||
export function buildVisibleNameTerms(nameTerms: string[]): string[] {
|
||||
const allTerms = new Set(nameTerms);
|
||||
return nameTerms.filter((term) => {
|
||||
for (const suffix of HONORIFIC_SUFFIXES) {
|
||||
if (!term.endsWith(suffix.term) || term.length <= suffix.term.length) {
|
||||
continue;
|
||||
}
|
||||
if (allTerms.has(term.slice(0, -suffix.term.length))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function buildReadingForTerm(
|
||||
term: string,
|
||||
character: CharacterRecord,
|
||||
|
||||
@@ -147,6 +147,7 @@ export interface CharacterDictionaryRuntimeDeps {
|
||||
sleep?: (ms: number) => Promise<void>;
|
||||
logInfo?: (message: string) => void;
|
||||
logWarn?: (message: string) => void;
|
||||
getNameMatchImagesEnabled?: () => boolean;
|
||||
getCollapsibleSectionOpenState?: (
|
||||
section: AnilistCharacterDictionaryCollapsibleSectionKey,
|
||||
) => boolean;
|
||||
|
||||
@@ -124,6 +124,8 @@ function hasAnnotationRuntimeHotReload(diff: ConfigHotReloadDiff): boolean {
|
||||
'ankiConnect.knownWords',
|
||||
'ankiConnect.nPlusOne',
|
||||
'ankiConnect.fields.word',
|
||||
'subtitleStyle.nameMatchEnabled',
|
||||
'subtitleStyle.nameMatchImagesEnabled',
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,9 @@ test('tokenizer deps builder records known-word lookups and maps readers', () =>
|
||||
getJlptLevel: () => 'N2',
|
||||
getJlptEnabled: () => true,
|
||||
getNameMatchEnabled: () => false,
|
||||
getNameMatchImagesEnabled: () => true,
|
||||
getCharacterNameImage: (term) =>
|
||||
term === 'name' ? { src: 'data:image/png;base64,AAAA', alt: 'Name' } : null,
|
||||
getFrequencyDictionaryEnabled: () => true,
|
||||
getFrequencyDictionaryMatchMode: () => 'surface',
|
||||
getFrequencyRank: () => 5,
|
||||
@@ -52,6 +55,11 @@ test('tokenizer deps builder records known-word lookups and maps readers', () =>
|
||||
assert.equal(deps.getNPlusOneEnabled?.(), true);
|
||||
assert.equal(deps.getMinSentenceWordsForNPlusOne?.(), 3);
|
||||
assert.equal(deps.getNameMatchEnabled?.(), false);
|
||||
assert.equal(deps.getNameMatchImagesEnabled?.(), true);
|
||||
assert.deepEqual(deps.getCharacterNameImage?.('name'), {
|
||||
src: 'data:image/png;base64,AAAA',
|
||||
alt: 'Name',
|
||||
});
|
||||
assert.equal(deps.getFrequencyDictionaryMatchMode?.(), 'surface');
|
||||
assert.deepEqual(calls, ['lookup:true', 'lookup:false', 'set-window', 'set-ready', 'set-init']);
|
||||
});
|
||||
@@ -74,6 +82,7 @@ test('tokenizer deps builder disables name matching when character dictionary is
|
||||
getJlptEnabled: () => true,
|
||||
getCharacterDictionaryEnabled: () => false,
|
||||
getNameMatchEnabled: () => true,
|
||||
getNameMatchImagesEnabled: () => true,
|
||||
getFrequencyDictionaryEnabled: () => true,
|
||||
getFrequencyDictionaryMatchMode: () => 'surface',
|
||||
getFrequencyRank: () => 5,
|
||||
@@ -82,6 +91,7 @@ test('tokenizer deps builder disables name matching when character dictionary is
|
||||
})();
|
||||
|
||||
assert.equal(deps.getNameMatchEnabled?.(), false);
|
||||
assert.equal(deps.getNameMatchImagesEnabled?.(), false);
|
||||
});
|
||||
|
||||
test('mecab tokenizer check creates tokenizer once and runs availability check', async () => {
|
||||
|
||||
@@ -4,6 +4,8 @@ type TokenizerMainDeps = TokenizerDepsRuntimeOptions & {
|
||||
getJlptEnabled: NonNullable<TokenizerDepsRuntimeOptions['getJlptEnabled']>;
|
||||
getCharacterDictionaryEnabled?: () => boolean;
|
||||
getNameMatchEnabled?: NonNullable<TokenizerDepsRuntimeOptions['getNameMatchEnabled']>;
|
||||
getNameMatchImagesEnabled?: NonNullable<TokenizerDepsRuntimeOptions['getNameMatchImagesEnabled']>;
|
||||
getCharacterNameImage?: NonNullable<TokenizerDepsRuntimeOptions['getCharacterNameImage']>;
|
||||
getFrequencyDictionaryEnabled: NonNullable<
|
||||
TokenizerDepsRuntimeOptions['getFrequencyDictionaryEnabled']
|
||||
>;
|
||||
@@ -57,6 +59,17 @@ export function createBuildTokenizerDepsMainHandler(deps: TokenizerMainDeps) {
|
||||
deps.getCharacterDictionaryEnabled?.() !== false && deps.getNameMatchEnabled!(),
|
||||
}
|
||||
: {}),
|
||||
...(deps.getNameMatchImagesEnabled
|
||||
? {
|
||||
getNameMatchImagesEnabled: () =>
|
||||
deps.getCharacterDictionaryEnabled?.() !== false && deps.getNameMatchImagesEnabled!(),
|
||||
}
|
||||
: {}),
|
||||
...(deps.getCharacterNameImage
|
||||
? {
|
||||
getCharacterNameImage: (term: string) => deps.getCharacterNameImage!(term),
|
||||
}
|
||||
: {}),
|
||||
getFrequencyDictionaryEnabled: () => deps.getFrequencyDictionaryEnabled(),
|
||||
getFrequencyDictionaryMatchMode: () => deps.getFrequencyDictionaryMatchMode(),
|
||||
getFrequencyRank: (text: string) => deps.getFrequencyRank(text),
|
||||
|
||||
Reference in New Issue
Block a user