mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-28 16:49:50 -07:00
9f85c55d23
- Add season-resolver: search base title, walk AniList SEQUEL relations (falling back to air-date ordering) to reach the requested season, since AniList has no numbered-season entries - Stop writing progress to the season 1 entry when the season can't be resolved; skip the update and point at a manual override instead - Scope character dictionary AniList overrides by detected season as well as directory, and let an override pin the media id used for watch-progress updates too - Reuse the season resolver for cover art lookup so per-season art doesn't fall back to season 1 - Don't cache an unresolved season as a match, so the fallback can't stick for later episodes - Update docs and changelog entry for the fix
454 lines
12 KiB
TypeScript
454 lines
12 KiB
TypeScript
import type { AnilistMediaGuess } from '../../core/services/anilist/anilist-updater';
|
|
import { resolveAnilistSeasonMedia } from '../../core/services/anilist/season-resolver';
|
|
import { ANILIST_GRAPHQL_URL } from './constants';
|
|
import type {
|
|
AniListMediaCandidate,
|
|
CharacterDictionaryRole,
|
|
CharacterRecord,
|
|
ResolvedAniListMedia,
|
|
VoiceActorRecord,
|
|
} from './types';
|
|
|
|
type AniListSearchResponse = {
|
|
Page?: {
|
|
media?: Array<{
|
|
id: number;
|
|
episodes?: number | null;
|
|
title?: {
|
|
romaji?: string | null;
|
|
english?: string | null;
|
|
native?: string | null;
|
|
};
|
|
}>;
|
|
};
|
|
};
|
|
|
|
type AniListCharacterPageResponse = {
|
|
Media?: {
|
|
title?: {
|
|
romaji?: string | null;
|
|
english?: string | null;
|
|
native?: string | null;
|
|
};
|
|
characters?: {
|
|
pageInfo?: {
|
|
hasNextPage?: boolean | null;
|
|
};
|
|
edges?: Array<{
|
|
role?: string | null;
|
|
voiceActors?: Array<{
|
|
id: number;
|
|
name?: {
|
|
full?: string | null;
|
|
native?: string | null;
|
|
} | null;
|
|
image?: {
|
|
large?: string | null;
|
|
medium?: string | null;
|
|
} | null;
|
|
}> | null;
|
|
node?: {
|
|
id: number;
|
|
description?: string | null;
|
|
image?: {
|
|
large?: string | null;
|
|
medium?: string | null;
|
|
} | null;
|
|
gender?: string | null;
|
|
age?: string | number | null;
|
|
dateOfBirth?: {
|
|
month?: number | null;
|
|
day?: number | null;
|
|
} | null;
|
|
bloodType?: string | null;
|
|
name?: {
|
|
first?: string | null;
|
|
full?: string | null;
|
|
last?: string | null;
|
|
native?: string | null;
|
|
alternative?: Array<string | null> | null;
|
|
} | null;
|
|
} | null;
|
|
} | null>;
|
|
} | null;
|
|
} | null;
|
|
};
|
|
|
|
function toAniListMediaCandidate(
|
|
entry: {
|
|
id: number;
|
|
episodes?: number | null;
|
|
title?: {
|
|
romaji?: string | null;
|
|
english?: string | null;
|
|
native?: string | null;
|
|
};
|
|
},
|
|
fallbackTitle: string,
|
|
): AniListMediaCandidate {
|
|
const normalizedFallback = fallbackTitle.trim() || `AniList ${entry.id}`;
|
|
return {
|
|
id: entry.id,
|
|
title:
|
|
entry.title?.english?.trim() ||
|
|
entry.title?.romaji?.trim() ||
|
|
entry.title?.native?.trim() ||
|
|
normalizedFallback,
|
|
episodes: typeof entry.episodes === 'number' && entry.episodes > 0 ? entry.episodes : null,
|
|
};
|
|
}
|
|
|
|
async function fetchAniList<T>(
|
|
query: string,
|
|
variables: Record<string, unknown>,
|
|
beforeRequest?: () => Promise<void>,
|
|
): Promise<T> {
|
|
if (beforeRequest) {
|
|
await beforeRequest();
|
|
}
|
|
const response = await fetch(ANILIST_GRAPHQL_URL, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
query,
|
|
variables,
|
|
}),
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(`AniList request failed (${response.status})`);
|
|
}
|
|
const payload = (await response.json()) as {
|
|
data?: T;
|
|
errors?: Array<{ message?: string }>;
|
|
};
|
|
const firstError = payload.errors?.find((entry) => entry && typeof entry.message === 'string');
|
|
if (firstError?.message) {
|
|
throw new Error(firstError.message);
|
|
}
|
|
if (!payload.data) {
|
|
throw new Error('AniList response missing data');
|
|
}
|
|
return payload.data;
|
|
}
|
|
|
|
function mapRole(input: string | null | undefined): CharacterDictionaryRole {
|
|
const value = (input || '').trim().toUpperCase();
|
|
if (value === 'MAIN') return 'main';
|
|
if (value === 'SUPPORTING') return 'primary';
|
|
if (value === 'BACKGROUND') return 'side';
|
|
return 'side';
|
|
}
|
|
|
|
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';
|
|
}
|
|
|
|
export async function resolveAniListMediaIdFromGuess(
|
|
guess: AnilistMediaGuess,
|
|
beforeRequest?: () => Promise<void>,
|
|
logInfo?: (message: string) => void,
|
|
): Promise<ResolvedAniListMedia> {
|
|
const resolution = await resolveAnilistSeasonMedia(
|
|
{ title: guess.title, season: guess.season, episode: guess.episode },
|
|
{
|
|
execute: (query, variables) => fetchAniList(query, variables, beforeRequest),
|
|
logInfo,
|
|
},
|
|
);
|
|
if (!resolution) {
|
|
throw new Error(`No AniList media match found for "${guess.title}".`);
|
|
}
|
|
return {
|
|
id: resolution.id,
|
|
title: resolution.title,
|
|
seasonResolved: resolution.seasonResolved,
|
|
requestedSeason: resolution.requestedSeason,
|
|
};
|
|
}
|
|
|
|
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>,
|
|
onPageFetched?: (page: number) => void,
|
|
): Promise<{
|
|
mediaTitle: string;
|
|
characters: CharacterRecord[];
|
|
}> {
|
|
const characters: CharacterRecord[] = [];
|
|
let page = 1;
|
|
let mediaTitle = '';
|
|
for (;;) {
|
|
const data = await fetchAniList<AniListCharacterPageResponse>(
|
|
`
|
|
query($id: Int!, $page: Int!) {
|
|
Media(id: $id, type: ANIME) {
|
|
title {
|
|
romaji
|
|
english
|
|
native
|
|
}
|
|
characters(page: $page, perPage: 50, sort: [ROLE, RELEVANCE, ID]) {
|
|
pageInfo {
|
|
hasNextPage
|
|
}
|
|
edges {
|
|
role
|
|
voiceActors(language: JAPANESE) {
|
|
id
|
|
name {
|
|
full
|
|
native
|
|
}
|
|
image {
|
|
medium
|
|
}
|
|
}
|
|
node {
|
|
id
|
|
description(asHtml: false)
|
|
gender
|
|
age
|
|
dateOfBirth {
|
|
month
|
|
day
|
|
}
|
|
bloodType
|
|
image {
|
|
large
|
|
medium
|
|
}
|
|
name {
|
|
first
|
|
full
|
|
last
|
|
native
|
|
alternative
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`,
|
|
{
|
|
id: mediaId,
|
|
page,
|
|
},
|
|
beforeRequest,
|
|
);
|
|
onPageFetched?.(page);
|
|
|
|
const media = data.Media;
|
|
if (!media) {
|
|
throw new Error(`AniList media ${mediaId} not found.`);
|
|
}
|
|
if (!mediaTitle) {
|
|
mediaTitle =
|
|
media.title?.english?.trim() ||
|
|
media.title?.romaji?.trim() ||
|
|
media.title?.native?.trim() ||
|
|
`AniList ${mediaId}`;
|
|
}
|
|
|
|
const edges = media.characters?.edges ?? [];
|
|
for (const edge of edges) {
|
|
const node = edge?.node;
|
|
if (!node || typeof node.id !== 'number') continue;
|
|
const firstNameHint = node.name?.first?.trim() || '';
|
|
const fullName = node.name?.full?.trim() || '';
|
|
const lastNameHint = node.name?.last?.trim() || '';
|
|
const nativeName = node.name?.native?.trim() || '';
|
|
const alternativeNames = [
|
|
...new Set(
|
|
(node.name?.alternative ?? [])
|
|
.filter((value): value is string => typeof value === 'string')
|
|
.map((value) => value.trim())
|
|
.filter((value) => value.length > 0),
|
|
),
|
|
];
|
|
if (!nativeName) continue;
|
|
const voiceActors: VoiceActorRecord[] = [];
|
|
for (const va of edge?.voiceActors ?? []) {
|
|
if (!va || typeof va.id !== 'number') continue;
|
|
const vaFull = va.name?.full?.trim() || '';
|
|
const vaNative = va.name?.native?.trim() || '';
|
|
if (!vaFull && !vaNative) continue;
|
|
voiceActors.push({
|
|
id: va.id,
|
|
fullName: vaFull,
|
|
nativeName: vaNative,
|
|
imageUrl: va.image?.medium || null,
|
|
});
|
|
}
|
|
characters.push({
|
|
id: node.id,
|
|
role: mapRole(edge?.role),
|
|
firstNameHint,
|
|
fullName,
|
|
lastNameHint,
|
|
nativeName,
|
|
alternativeNames,
|
|
bloodType: node.bloodType?.trim() || '',
|
|
birthday:
|
|
typeof node.dateOfBirth?.month === 'number' && typeof node.dateOfBirth?.day === 'number'
|
|
? [node.dateOfBirth.month, node.dateOfBirth.day]
|
|
: null,
|
|
description: node.description || '',
|
|
imageUrl: node.image?.large || node.image?.medium || null,
|
|
age:
|
|
typeof node.age === 'string'
|
|
? node.age.trim()
|
|
: typeof node.age === 'number'
|
|
? String(node.age)
|
|
: '',
|
|
sex: node.gender?.trim() || '',
|
|
voiceActors,
|
|
});
|
|
}
|
|
|
|
const hasNextPage = Boolean(media.characters?.pageInfo?.hasNextPage);
|
|
if (!hasNextPage) {
|
|
break;
|
|
}
|
|
page += 1;
|
|
}
|
|
|
|
return {
|
|
mediaTitle,
|
|
characters,
|
|
};
|
|
}
|
|
|
|
export async function downloadCharacterImage(
|
|
imageUrl: string,
|
|
charId: number,
|
|
): Promise<{
|
|
filename: string;
|
|
ext: string;
|
|
bytes: Buffer;
|
|
} | null> {
|
|
try {
|
|
const response = await fetch(imageUrl);
|
|
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'), bytes);
|
|
return {
|
|
filename: `c${charId}.${ext}`,
|
|
ext,
|
|
bytes,
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|