diff --git a/src/providers/embeds/animetsu.ts b/src/providers/embeds/animetsu.ts index 007d72e..2480e2c 100644 --- a/src/providers/embeds/animetsu.ts +++ b/src/providers/embeds/animetsu.ts @@ -23,7 +23,7 @@ export function makeAnimetsuEmbed(id: string, rank: number = 100) { const serverName = id as (typeof ANIMETSU_SERVERS)[number]; const query = JSON.parse(ctx.url); - const { type, malId, episode } = query; + const { type, anilistId, episode } = query; if (type !== 'movie' && type !== 'show') { throw new NotFoundError('Unsupported media type'); @@ -34,7 +34,7 @@ export function makeAnimetsuEmbed(id: string, rank: number = 100) { headers, query: { server: serverName, - id: String(malId), + id: String(anilistId), num: String(episode ?? 1), subType: 'dub', }, diff --git a/src/providers/embeds/zunime.ts b/src/providers/embeds/zunime.ts index a0329a2..4ccf4fc 100644 --- a/src/providers/embeds/zunime.ts +++ b/src/providers/embeds/zunime.ts @@ -21,13 +21,13 @@ export function makeZunimeEmbed(id: string, rank: number = 100) { const serverName = id as (typeof ZUNIME_SERVERS)[number]; const query = JSON.parse(ctx.url); - const { malId, episode } = query; + const { anilistId, episode } = query; const res = await ctx.proxiedFetcher(`${'/sources'}`, { baseUrl, headers, query: { - id: String(malId), + id: String(anilistId), ep: String(episode ?? 1), host: serverName, type: 'dub', diff --git a/src/providers/sources/animetsu.ts b/src/providers/sources/animetsu.ts index 61bbc60..2df248c 100644 --- a/src/providers/sources/animetsu.ts +++ b/src/providers/sources/animetsu.ts @@ -1,16 +1,16 @@ import { SourcererOutput, makeSourcerer } from '@/providers/base'; +import { getAnilistIdFromMedia } from '@/utils/anilist'; import { MovieScrapeContext, ShowScrapeContext } from '@/utils/context'; -import { getMalIdFromMedia } from '@/utils/mal'; async function comboScraper(ctx: ShowScrapeContext | MovieScrapeContext): Promise { - const malId = await getMalIdFromMedia(ctx, ctx.media); + const anilistId = await getAnilistIdFromMedia(ctx, ctx.media); const query: any = { type: ctx.media.type, title: ctx.media.title, tmdbId: ctx.media.tmdbId, imdbId: ctx.media.imdbId, - malId, + anilistId, ...(ctx.media.type === 'show' && { season: ctx.media.season.number, episode: ctx.media.episode.number, diff --git a/src/providers/sources/zunime.ts b/src/providers/sources/zunime.ts index b1b3333..2f043e2 100644 --- a/src/providers/sources/zunime.ts +++ b/src/providers/sources/zunime.ts @@ -1,16 +1,16 @@ import { SourcererOutput, makeSourcerer } from '@/providers/base'; +import { getAnilistIdFromMedia } from '@/utils/anilist'; import { MovieScrapeContext, ShowScrapeContext } from '@/utils/context'; -import { getMalIdFromMedia } from '@/utils/mal'; async function comboScraper(ctx: ShowScrapeContext | MovieScrapeContext): Promise { - const malId = await getMalIdFromMedia(ctx, ctx.media); + const anilistId = await getAnilistIdFromMedia(ctx, ctx.media); const query: any = { type: ctx.media.type, title: ctx.media.title, tmdbId: ctx.media.tmdbId, imdbId: ctx.media.imdbId, - malId, + anilistId, ...(ctx.media.type === 'show' && { season: ctx.media.season.number, episode: ctx.media.episode.number, diff --git a/src/utils/anilist.ts b/src/utils/anilist.ts new file mode 100644 index 0000000..03bd8ee --- /dev/null +++ b/src/utils/anilist.ts @@ -0,0 +1,110 @@ +import { MovieMedia, ShowMedia } from '@/entrypoint/utils/media'; +import { ScrapeContext } from '@/utils/context'; + +type AnilistMedia = { + id: number; + type: 'ANIME' | 'MANGA'; + format: 'TV' | 'TV_SHORT' | 'MOVIE' | 'SPECIAL' | 'OVA' | 'ONA' | 'MUSIC' | 'MANGA' | 'NOVEL' | 'ONE_SHOT'; + seasonYear?: number; + title: { + romaji: string; + english?: string; + native?: string; + }; +}; + +type AnilistSearchResponse = { + data: { + Page: { + media: AnilistMedia[]; + }; + }; +}; + +const cache = new Map(); + +function normalizeTitle(t: string) { + return t + .toLowerCase() + .replace(/[^a-z0-9]+/g, ' ') + .trim(); +} + +function matchesType(mediaType: 'show' | 'movie', anilist: AnilistMedia) { + if (mediaType === 'show') { + return ['TV', 'TV_SHORT', 'OVA', 'ONA', 'SPECIAL'].includes(anilist.format); + } + return anilist.format === 'MOVIE'; +} + +const anilistQuery = ` +query ($search: String, $type: MediaType) { + Page(page: 1, perPage: 20) { + media(search: $search, type: $type, sort: POPULARITY_DESC) { + id + type + format + seasonYear + title { + romaji + english + native + } + } + } +} +`; + +export async function getAnilistIdFromMedia(ctx: ScrapeContext, media: MovieMedia | ShowMedia): Promise { + const key = `${media.type}:${media.title}:${media.releaseYear}`; + const cached = cache.get(key); + if (cached) return cached; + + const res = await ctx.proxiedFetcher('', { + baseUrl: 'https://graphql.anilist.co', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ + query: anilistQuery, + variables: { + search: media.title, + type: 'ANIME', + }, + }), + }); + + const items = res.data?.Page?.media ?? []; + if (!items.length) { + throw new Error('AniList id not found'); + } + + const targetTitle = normalizeTitle(media.title); + + const scored = items + .filter((it) => matchesType(media.type, it)) + .map((it) => { + const titles: string[] = [it.title.romaji]; + if (it.title.english) titles.push(it.title.english); + if (it.title.native) titles.push(it.title.native); + const normTitles = titles.map(normalizeTitle).filter(Boolean); + const exact = normTitles.includes(targetTitle); + const partial = normTitles.some((t) => t.includes(targetTitle) || targetTitle.includes(t)); + const yearDelta = it.seasonYear ? Math.abs(it.seasonYear - media.releaseYear) : 5; + let score = 0; + if (exact) score += 100; + else if (partial) score += 50; + score += Math.max(0, 20 - yearDelta * 4); + return { it, score }; + }) + .sort((a, b) => b.score - a.score); + + const winner = scored[0]?.it ?? items[0]; + const anilistId = winner?.id; + if (!anilistId) throw new Error('AniList id not found'); + + cache.set(key, anilistId); + return anilistId; +} diff --git a/src/utils/mal.ts b/src/utils/mal.ts deleted file mode 100644 index a5dcf9e..0000000 --- a/src/utils/mal.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { MovieMedia, ShowMedia } from '@/entrypoint/utils/media'; -import { ScrapeContext } from '@/utils/context'; - -type JikanAnime = { - mal_id: number; - type?: string; - title?: string; - title_english?: string | null; - titles?: Array<{ title: string; type: string }>; - year?: number | null; - aired?: { - prop?: { from?: { year?: number | null } }; - }; -}; - -type JikanSearchResponse = { - data: JikanAnime[]; -}; - -const cache = new Map(); - -function normalizeTitle(t: string) { - return t - .toLowerCase() - .replace(/[^a-z0-9]+/g, ' ') - .trim(); -} - -function guessYear(a: JikanAnime): number | null { - return (typeof a.year === 'number' ? a.year : null) ?? a.aired?.prop?.from?.year ?? null; -} - -function matchesType(mediaType: 'show' | 'movie', t?: string) { - if (!t) return true; // be lenient - const jt = t.toLowerCase(); - return mediaType === 'show' ? jt === 'tv' : jt === 'movie'; -} - -export async function getMalIdFromMedia(ctx: ScrapeContext, media: MovieMedia | ShowMedia): Promise { - const key = `${media.type}:${media.title}:${media.releaseYear}`; - const cached = cache.get(key); - if (cached) return cached; - - const q = media.title; - // Jikan search - const res = await ctx.proxiedFetcher('/anime', { - baseUrl: 'https://api.jikan.moe/v4', - query: { - q, - // Jikan expects tv|movie etc; provide a hint but still filter manually - type: media.type === 'show' ? 'tv' : 'movie', - sfw: 'true', - limit: '20', - order_by: 'popularity', - sort: 'asc', - }, - }); - - const items = Array.isArray(res?.data) ? res.data : []; - if (!items.length) { - throw new Error('MAL id not found'); - } - - const targetTitle = normalizeTitle(media.title); - - // Score results by title similarity and year closeness - const scored = items - .filter((it) => matchesType(media.type, it.type)) - .map((it) => { - const titles: string[] = [it.title || '']; - if (it.title_english) titles.push(it.title_english); - if (Array.isArray(it.titles)) titles.push(...it.titles.map((t) => t.title)); - const normTitles = titles.map(normalizeTitle).filter(Boolean); - const exact = normTitles.includes(targetTitle); - const partial = normTitles.some((t) => t.includes(targetTitle) || targetTitle.includes(t)); - const y = guessYear(it); - const yearDelta = typeof y === 'number' ? Math.abs(y - media.releaseYear) : 5; // unknown year => penalize - let score = 0; - if (exact) score += 100; - else if (partial) score += 50; - score += Math.max(0, 20 - yearDelta * 4); - return { it, score }; - }) - .sort((a, b) => b.score - a.score); - - const winner = scored[0]?.it ?? items[0]; - const malId = winner?.mal_id; - if (!malId) throw new Error('MAL id not found'); - - cache.set(key, malId); - return malId; -}