Add inline character portraits and dictionary search workflow (#83)

This commit is contained in:
2026-05-25 03:16:25 -07:00
committed by GitHub
parent 7e6f9672cf
commit 807c0ff3db
54 changed files with 2306 additions and 178 deletions
+53 -17
View File
@@ -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,
};