Files
SubMiner/src/main/character-dictionary-runtime.ts

397 lines
13 KiB
TypeScript

import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { hasVideoExtension } from '../shared/video-extensions';
import {
applyCollapsibleOpenStatesToTermEntries,
buildDictionaryTitle,
buildDictionaryZip,
buildSnapshotFromCharacters,
buildSnapshotImagePath,
buildVaImagePath,
} from './character-dictionary-runtime/build';
import {
buildMergedRevision,
getMergedZipPath,
getSnapshotPath,
normalizeMergedMediaIds,
readSnapshot,
writeSnapshot,
} from './character-dictionary-runtime/cache';
import {
ANILIST_REQUEST_DELAY_MS,
CHARACTER_DICTIONARY_MERGED_TITLE,
CHARACTER_IMAGE_DOWNLOAD_DELAY_MS,
} from './character-dictionary-runtime/constants';
import {
downloadCharacterImage,
fetchCharactersForMedia,
resolveAniListMediaIdFromGuess,
} from './character-dictionary-runtime/fetch';
import type {
CharacterDictionaryBuildResult,
CharacterDictionaryGenerateOptions,
CharacterDictionaryRuntimeDeps,
CharacterDictionarySnapshotImage,
CharacterDictionarySnapshotProgress,
CharacterDictionarySnapshotProgressCallbacks,
CharacterDictionarySnapshotResult,
MergedCharacterDictionaryBuildResult,
ResolvedAniListMedia,
} from './character-dictionary-runtime/types';
export type {
CharacterDictionaryBuildResult,
CharacterDictionaryGenerateOptions,
CharacterDictionaryRuntimeDeps,
CharacterDictionarySnapshot,
CharacterDictionarySnapshotProgress,
CharacterDictionarySnapshotProgressCallbacks,
CharacterDictionarySnapshotResult,
MergedCharacterDictionaryBuildResult,
} from './character-dictionary-runtime/types';
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function expandUserPath(input: string): string {
if (input.startsWith('~')) {
return path.join(os.homedir(), input.slice(1));
}
return input;
}
function isVideoFile(filePath: string): boolean {
return hasVideoExtension(path.extname(filePath));
}
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}`);
}
export function createCharacterDictionaryRuntimeService(deps: CharacterDictionaryRuntimeDeps): {
getOrCreateCurrentSnapshot: (
targetPath?: string,
progress?: CharacterDictionarySnapshotProgressCallbacks,
) => Promise<CharacterDictionarySnapshotResult>;
buildMergedDictionary: (mediaIds: number[]) => Promise<MergedCharacterDictionaryBuildResult>;
generateForCurrentMedia: (
targetPath?: string,
options?: CharacterDictionaryGenerateOptions,
) => Promise<CharacterDictionaryBuildResult>;
} {
const outputDir = path.join(deps.userDataPath, 'character-dictionaries');
const sleepMs = deps.sleep ?? sleep;
const getCollapsibleSectionOpenState = deps.getCollapsibleSectionOpenState ?? (() => false);
const resolveCurrentMedia = async (
targetPath?: string,
beforeRequest?: () => Promise<void>,
): Promise<ResolvedAniListMedia> => {
deps.logInfo?.('[dictionary] resolving current anime for character dictionary generation');
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.');
}
deps.logInfo?.(
`[dictionary] current anime guess: ${guessed.title.trim()}${
typeof guessed.episode === 'number' && guessed.episode > 0
? ` (episode ${guessed.episode})`
: ''
}`,
);
const resolved = await resolveAniListMediaIdFromGuess(guessed, beforeRequest);
deps.logInfo?.(`[dictionary] AniList match: ${resolved.title} -> AniList ${resolved.id}`);
return resolved;
};
const getOrCreateSnapshot = async (
mediaId: number,
mediaTitleHint?: string,
beforeRequest?: () => Promise<void>,
progress?: CharacterDictionarySnapshotProgressCallbacks,
): Promise<CharacterDictionarySnapshotResult> => {
const snapshotPath = getSnapshotPath(outputDir, mediaId);
const cachedSnapshot = readSnapshot(snapshotPath);
if (cachedSnapshot) {
deps.logInfo?.(`[dictionary] snapshot hit for AniList ${mediaId}`);
return {
mediaId: cachedSnapshot.mediaId,
mediaTitle: cachedSnapshot.mediaTitle,
entryCount: cachedSnapshot.entryCount,
fromCache: true,
updatedAt: cachedSnapshot.updatedAt,
};
}
progress?.onGenerating?.({
mediaId,
mediaTitle: mediaTitleHint || `AniList ${mediaId}`,
});
deps.logInfo?.(`[dictionary] snapshot miss for AniList ${mediaId}, fetching characters`);
const { mediaTitle: fetchedMediaTitle, characters } = await fetchCharactersForMedia(
mediaId,
beforeRequest,
(page) => {
deps.logInfo?.(
`[dictionary] downloaded AniList character page ${page} for AniList ${mediaId}`,
);
},
);
if (characters.length === 0) {
throw new Error(`No characters returned for AniList media ${mediaId}.`);
}
const imagesByCharacterId = new Map<number, CharacterDictionarySnapshotImage>();
const imagesByVaId = new Map<number, CharacterDictionarySnapshotImage>();
const allImageUrls: Array<{ id: number; url: string; kind: 'character' | 'va' }> = [];
const seenVaIds = new Set<number>();
for (const character of characters) {
if (character.imageUrl) {
allImageUrls.push({ id: character.id, url: character.imageUrl, kind: 'character' });
}
for (const va of character.voiceActors) {
if (va.imageUrl && !seenVaIds.has(va.id)) {
seenVaIds.add(va.id);
allImageUrls.push({ id: va.id, url: va.imageUrl, kind: 'va' });
}
}
}
if (allImageUrls.length > 0) {
deps.logInfo?.(
`[dictionary] downloading ${allImageUrls.length} images for AniList ${mediaId}`,
);
}
let hasAttemptedImageDownload = false;
for (const entry of allImageUrls) {
if (hasAttemptedImageDownload) {
await sleepMs(CHARACTER_IMAGE_DOWNLOAD_DELAY_MS);
}
hasAttemptedImageDownload = true;
const image = await downloadCharacterImage(entry.url, entry.id);
if (!image) continue;
if (entry.kind === 'character') {
imagesByCharacterId.set(entry.id, {
path: buildSnapshotImagePath(mediaId, entry.id, image.ext),
dataBase64: image.bytes.toString('base64'),
});
} else {
imagesByVaId.set(entry.id, {
path: buildVaImagePath(mediaId, entry.id, image.ext),
dataBase64: image.bytes.toString('base64'),
});
}
}
const snapshot = buildSnapshotFromCharacters(
mediaId,
fetchedMediaTitle || mediaTitleHint || `AniList ${mediaId}`,
characters,
imagesByCharacterId,
imagesByVaId,
deps.now(),
getCollapsibleSectionOpenState,
);
writeSnapshot(snapshotPath, snapshot);
deps.logInfo?.(
`[dictionary] stored snapshot for AniList ${mediaId}: ${snapshot.entryCount} terms`,
);
return {
mediaId: snapshot.mediaId,
mediaTitle: snapshot.mediaTitle,
entryCount: snapshot.entryCount,
fromCache: false,
updatedAt: snapshot.updatedAt,
};
};
return {
getOrCreateCurrentSnapshot: async (
targetPath?: string,
progress?: CharacterDictionarySnapshotProgressCallbacks,
) => {
let hasAniListRequest = false;
const waitForAniListRequestSlot = async (): Promise<void> => {
if (!hasAniListRequest) {
hasAniListRequest = true;
return;
}
await sleepMs(ANILIST_REQUEST_DELAY_MS);
};
const resolvedMedia = await resolveCurrentMedia(targetPath, waitForAniListRequestSlot);
progress?.onChecking?.({
mediaId: resolvedMedia.id,
mediaTitle: resolvedMedia.title,
});
return getOrCreateSnapshot(
resolvedMedia.id,
resolvedMedia.title,
waitForAniListRequestSlot,
progress,
);
},
buildMergedDictionary: async (mediaIds: number[]) => {
const normalizedMediaIds = normalizeMergedMediaIds(mediaIds);
const snapshotResults = await Promise.all(
normalizedMediaIds.map((mediaId) => getOrCreateSnapshot(mediaId)),
);
const snapshots = snapshotResults.map(({ mediaId }) => {
const snapshot = readSnapshot(getSnapshotPath(outputDir, mediaId));
if (!snapshot) {
throw new Error(`Missing character dictionary snapshot for AniList ${mediaId}.`);
}
return snapshot;
});
const revision = buildMergedRevision(normalizedMediaIds, snapshots);
const description =
snapshots.length === 1
? `Character names from ${snapshots[0]!.mediaTitle}`
: `Character names from ${snapshots.length} recent anime`;
const { zipPath, entryCount } = buildDictionaryZip(
getMergedZipPath(outputDir),
CHARACTER_DICTIONARY_MERGED_TITLE,
description,
revision,
applyCollapsibleOpenStatesToTermEntries(
snapshots.flatMap((snapshot) => snapshot.termEntries),
getCollapsibleSectionOpenState,
),
snapshots.flatMap((snapshot) => snapshot.images),
);
deps.logInfo?.(
`[dictionary] rebuilt merged dictionary: ${normalizedMediaIds.join(', ') || '<empty>'} -> ${zipPath}`,
);
return {
zipPath,
revision,
dictionaryTitle: CHARACTER_DICTIONARY_MERGED_TITLE,
entryCount,
};
},
generateForCurrentMedia: async (
targetPath?: string,
_options?: CharacterDictionaryGenerateOptions,
) => {
let hasAniListRequest = false;
const waitForAniListRequestSlot = async (): Promise<void> => {
if (!hasAniListRequest) {
hasAniListRequest = true;
return;
}
await sleepMs(ANILIST_REQUEST_DELAY_MS);
};
const resolvedMedia = await resolveCurrentMedia(targetPath, waitForAniListRequestSlot);
const snapshot = await getOrCreateSnapshot(
resolvedMedia.id,
resolvedMedia.title,
waitForAniListRequestSlot,
);
const storedSnapshot = readSnapshot(getSnapshotPath(outputDir, resolvedMedia.id));
if (!storedSnapshot) {
throw new Error(`Snapshot missing after generation for AniList ${resolvedMedia.id}.`);
}
const revision = String(storedSnapshot.updatedAt);
const dictionaryTitle = buildDictionaryTitle(resolvedMedia.id);
const description = `Character names from ${storedSnapshot.mediaTitle} [AniList media ID ${resolvedMedia.id}]`;
const zipPath = path.join(outputDir, `anilist-${resolvedMedia.id}.zip`);
deps.logInfo?.(`[dictionary] building ZIP for AniList ${resolvedMedia.id}`);
buildDictionaryZip(
zipPath,
dictionaryTitle,
description,
revision,
applyCollapsibleOpenStatesToTermEntries(
storedSnapshot.termEntries,
getCollapsibleSectionOpenState,
),
storedSnapshot.images,
);
deps.logInfo?.(
`[dictionary] generated AniList ${resolvedMedia.id}: ${storedSnapshot.entryCount} terms -> ${zipPath}`,
);
return {
zipPath,
fromCache: snapshot.fromCache,
mediaId: resolvedMedia.id,
mediaTitle: storedSnapshot.mediaTitle,
entryCount: storedSnapshot.entryCount,
dictionaryTitle,
revision,
};
},
};
}