feat: add manual AniList selection for character dictionaries

This commit is contained in:
2026-04-25 15:53:20 -07:00
parent 60435fee10
commit 055bd76718
78 changed files with 1986 additions and 160 deletions

View File

@@ -1,6 +1,7 @@
import type { AnilistMediaGuess } from '../../core/services/anilist/anilist-updater';
import { ANILIST_GRAPHQL_URL } from './constants';
import type {
AniListMediaCandidate,
CharacterDictionaryRole,
CharacterRecord,
ResolvedAniListMedia,
@@ -123,6 +124,29 @@ function pickAniListSearchResult(
};
}
function toAniListMediaCandidate(
entry: {
id: number;
episodes?: number | null;
title?: {
romaji?: string | null;
english?: string | null;
native?: string | null;
};
},
fallbackTitle: string,
): AniListMediaCandidate {
return {
id: entry.id,
title:
entry.title?.english?.trim() ||
entry.title?.romaji?.trim() ||
entry.title?.native?.trim() ||
fallbackTitle,
episodes: typeof entry.episodes === 'number' && entry.episodes > 0 ? entry.episodes : null,
};
}
async function fetchAniList<T>(
query: string,
variables: Record<string, unknown>,
@@ -208,6 +232,69 @@ export async function resolveAniListMediaIdFromGuess(
return resolved;
}
export async function searchAniListMediaCandidates(
title: string,
beforeRequest?: () => Promise<void>,
): Promise<AniListMediaCandidate[]> {
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: title },
beforeRequest,
);
return (data.Page?.media ?? []).map((entry) => toAniListMediaCandidate(entry, title));
}
export async function fetchAniListMediaCandidateById(
mediaId: number,
beforeRequest?: () => Promise<void>,
): Promise<AniListMediaCandidate> {
const data = await fetchAniList<{
Media?: {
id: number;
episodes?: number | null;
title?: {
romaji?: string | null;
english?: string | null;
native?: string | null;
};
} | null;
}>(
`
query($id: Int!) {
Media(id: $id, type: ANIME) {
id
episodes
title {
romaji
english
native
}
}
}
`,
{ id: mediaId },
beforeRequest,
);
if (!data.Media) {
throw new Error(`AniList media ${mediaId} not found.`);
}
return toAniListMediaCandidate(data.Media, `AniList ${mediaId}`);
}
export async function fetchCharactersForMedia(
mediaId: number,
beforeRequest?: () => Promise<void>,

View File

@@ -0,0 +1,81 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import {
buildCharacterDictionarySeriesKey,
createCharacterDictionaryManualSelectionStore,
} 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';
const REZERO_EP2 =
'/anime/Re - ZERO, Starting Life in Another World (2016) - S01E02 - Reunion with the Witch [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', () => {
const key = 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',
},
});
assert.equal(key, 're-zero-starting-life-in-another-world-2016');
});
test('manual selection store persists overrides and matches later episodes in the same series', async () => {
const userDataPath = makeTempDir();
const store = createCharacterDictionaryManualSelectionStore({ userDataPath });
const firstKey = 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: firstKey,
mediaId: 21355,
mediaTitle: 'Re:ZERO -Starting Life in Another World-',
staleMediaIds: [10607],
});
const reloaded = createCharacterDictionaryManualSelectionStore({ userDataPath });
const secondKey = buildCharacterDictionarySeriesKey({
mediaPath: REZERO_EP2,
mediaTitle: null,
guess: {
title: 'Re ZERO, Starting Life in Another World',
alternativeTitle: 'ZERO, Starting Life in Another World',
year: 2016,
season: 1,
episode: 2,
source: 'guessit',
},
});
assert.equal(secondKey, firstKey);
assert.deepEqual(await reloaded.getOverride(secondKey), {
seriesKey: firstKey,
mediaId: 21355,
mediaTitle: 'Re:ZERO -Starting Life in Another World-',
staleMediaIds: [10607],
});
});

View File

@@ -0,0 +1,122 @@
import * as fs from 'fs';
import * as path from 'path';
import type { AnilistMediaGuess } from '../../core/services/anilist/anilist-updater';
import { ensureDir } from '../../shared/fs-utils';
export type CharacterDictionaryManualSelection = {
seriesKey: string;
mediaId: number;
mediaTitle: string;
staleMediaIds: number[];
};
type ManualSelectionStoreFile = {
overrides?: CharacterDictionaryManualSelection[];
};
function normalizeManualMediaId(value: unknown): number | null {
if (typeof value !== 'number' || !Number.isFinite(value)) return null;
const mediaId = Math.floor(value);
return mediaId > 0 ? mediaId : null;
}
function normalizeSeriesKeyPart(value: string): string {
return value
.normalize('NFKD')
.replace(/[':]/g, '')
.replace(/&/g, ' and ')
.replace(/[^a-zA-Z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.replace(/-{2,}/g, '-')
.toLowerCase();
}
function dedupeNumbers(values: number[]): number[] {
const seen = new Set<number>();
const result: number[] = [];
for (const value of values) {
const normalized = normalizeManualMediaId(value);
if (normalized === null || seen.has(normalized)) continue;
seen.add(normalized);
result.push(normalized);
}
return result;
}
function normalizeOverride(value: unknown): CharacterDictionaryManualSelection | null {
if (!value || typeof value !== 'object') return null;
const raw = value as Partial<CharacterDictionaryManualSelection>;
const seriesKey = typeof raw.seriesKey === 'string' ? raw.seriesKey.trim() : '';
const mediaId = normalizeManualMediaId(raw.mediaId);
const mediaTitle = typeof raw.mediaTitle === 'string' ? raw.mediaTitle.trim() : '';
if (!seriesKey || mediaId === null || !mediaTitle) return null;
return {
seriesKey,
mediaId,
mediaTitle,
staleMediaIds: dedupeNumbers(Array.isArray(raw.staleMediaIds) ? raw.staleMediaIds : []),
};
}
function readOverrides(filePath: string): CharacterDictionaryManualSelection[] {
try {
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8')) as ManualSelectionStoreFile;
if (!Array.isArray(parsed.overrides)) return [];
const byKey = new Map<string, CharacterDictionaryManualSelection>();
for (const value of parsed.overrides) {
const normalized = normalizeOverride(value);
if (normalized) byKey.set(normalized.seriesKey, normalized);
}
return [...byKey.values()];
} catch {
return [];
}
}
function writeOverrides(filePath: string, overrides: CharacterDictionaryManualSelection[]): void {
ensureDir(path.dirname(filePath));
fs.writeFileSync(filePath, JSON.stringify({ overrides }, null, 2), 'utf8');
}
export function buildCharacterDictionarySeriesKey(input: {
mediaPath: string | null;
mediaTitle: string | null;
guess: AnilistMediaGuess | null;
}): string {
const guessedTitle = input.guess?.title.trim() || input.guess?.alternativeTitle?.trim() || '';
const sourceTitle =
guessedTitle ||
(input.mediaTitle && input.mediaTitle.trim()) ||
(input.mediaPath && path.basename(input.mediaPath).replace(/\.[^.]+$/, '')) ||
'unknown';
const withoutEpisode = sourceTitle
.replace(/\bS\d{1,2}E\d{1,3}\b/gi, ' ')
.replace(/\bepisode\s+\d+\b/gi, ' ')
.trim();
const base = normalizeSeriesKeyPart(withoutEpisode) || 'unknown';
return input.guess?.year ? `${base}-${input.guess.year}` : base;
}
export function createCharacterDictionaryManualSelectionStore(deps: { userDataPath: string }) {
const filePath = path.join(
deps.userDataPath,
'character-dictionaries',
'anilist-overrides.json',
);
return {
getOverride: async (seriesKey: string): Promise<CharacterDictionaryManualSelection | null> => {
return readOverrides(filePath).find((entry) => entry.seriesKey === seriesKey) ?? null;
},
setOverride: async (selection: CharacterDictionaryManualSelection): Promise<void> => {
const normalized = normalizeOverride(selection);
if (!normalized) {
throw new Error('Invalid character dictionary manual selection.');
}
const remaining = readOverrides(filePath).filter(
(entry) => entry.seriesKey !== normalized.seriesKey,
);
writeOverrides(filePath, [...remaining, normalized]);
},
};
}

View File

@@ -93,6 +93,7 @@ export type CharacterDictionarySnapshotResult = {
entryCount: number;
fromCache: boolean;
updatedAt: number;
staleMediaIds?: number[];
};
export type CharacterDictionarySnapshotProgress = {
@@ -112,6 +113,27 @@ export type MergedCharacterDictionaryBuildResult = {
entryCount: number;
};
export type AniListMediaCandidate = {
id: number;
title: string;
episodes: number | null;
};
export type CharacterDictionaryManualSelectionSnapshot = {
seriesKey: string;
guessTitle: string | null;
current: AniListMediaCandidate | null;
override: AniListMediaCandidate | null;
candidates: AniListMediaCandidate[];
};
export type CharacterDictionaryManualSelectionResult = {
ok: boolean;
seriesKey: string;
selected: AniListMediaCandidate;
staleMediaIds: number[];
};
export interface CharacterDictionaryRuntimeDeps {
userDataPath: string;
getCurrentMediaPath: () => string | null;
@@ -133,4 +155,5 @@ export interface CharacterDictionaryRuntimeDeps {
export type ResolvedAniListMedia = {
id: number;
title: string;
staleMediaIds?: number[];
};