mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-04-26 16:19:26 -07:00
feat: add manual AniList selection for character dictionaries
This commit is contained in:
@@ -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>,
|
||||
|
||||
@@ -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],
|
||||
});
|
||||
});
|
||||
122
src/main/character-dictionary-runtime/manual-selection.ts
Normal file
122
src/main/character-dictionary-runtime/manual-selection.ts
Normal 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]);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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[];
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user