mirror of
https://github.com/p-stream/providers.git
synced 2026-04-19 11:12:06 +00:00
use AI for returning anime id for myanime
This commit is contained in:
parent
628d2f7401
commit
f2cff631e4
4 changed files with 235 additions and 78 deletions
|
|
@ -78,7 +78,7 @@ import { iosmirrorScraper } from './sources/iosmirror';
|
|||
import { iosmirrorPVScraper } from './sources/iosmirrorpv';
|
||||
import { lookmovieScraper } from './sources/lookmovie';
|
||||
import { madplayScraper } from './sources/madplay';
|
||||
import { myanimeScraper } from './sources/myanime';
|
||||
import { myanimeScraper } from './sources/myanime/myanime';
|
||||
import { nunflixScraper } from './sources/nunflix';
|
||||
import { rgshowsScraper } from './sources/rgshows';
|
||||
import { ridooMoviesScraper } from './sources/ridomovies';
|
||||
|
|
|
|||
|
|
@ -1,77 +0,0 @@
|
|||
import { flags } from '@/entrypoint/utils/targets';
|
||||
import { SourcererOutput, makeSourcerer } from '@/providers/base';
|
||||
import { compareTitle } from '@/utils/compare';
|
||||
import { MovieScrapeContext, ShowScrapeContext } from '@/utils/context';
|
||||
import { NotFoundError } from '@/utils/errors';
|
||||
|
||||
// Levenshtein distance function for string similarity
|
||||
const levenshtein = (s: string, t: string): number => {
|
||||
if (!s.length) return t.length;
|
||||
if (!t.length) return s.length;
|
||||
const arr: number[][] = [];
|
||||
for (let i = 0; i <= t.length; i++) {
|
||||
arr[i] = [i];
|
||||
for (let j = 1; j <= s.length; j++) {
|
||||
arr[i][j] =
|
||||
i === 0
|
||||
? j
|
||||
: Math.min(arr[i - 1][j] + 1, arr[i][j - 1] + 1, arr[i - 1][j - 1] + (s[j - 1] === t[i - 1] ? 0 : 1));
|
||||
}
|
||||
}
|
||||
return arr[t.length][s.length];
|
||||
};
|
||||
|
||||
const universalScraper = async (ctx: MovieScrapeContext | ShowScrapeContext): Promise<SourcererOutput> => {
|
||||
const searchResults = await ctx.proxiedFetcher<any>(
|
||||
`https://anime-api-cyan-zeta.vercel.app/api/search?keyword=${encodeURIComponent(ctx.media.title)}`,
|
||||
);
|
||||
|
||||
const bestMatch = searchResults.results.data
|
||||
.map((item: any) => {
|
||||
const similarity =
|
||||
1 - levenshtein(item.title, ctx.media.title) / Math.max(item.title.length, ctx.media.title.length);
|
||||
const isExactMatch = compareTitle(item.title, ctx.media.title);
|
||||
return { ...item, similarity, isExactMatch };
|
||||
})
|
||||
.sort((a: any, b: any) => {
|
||||
if (a.isExactMatch && !b.isExactMatch) return -1;
|
||||
if (!a.isExactMatch && b.isExactMatch) return 1;
|
||||
return b.similarity - a.similarity;
|
||||
})[0];
|
||||
|
||||
if (!bestMatch) {
|
||||
throw new NotFoundError('No watchable sources found');
|
||||
}
|
||||
|
||||
const episodeData = await ctx.proxiedFetcher<any>(`https://anime.aether.mom/api/episodes/${bestMatch.id}`);
|
||||
|
||||
const episode = episodeData.results.episodes.find(
|
||||
(e: any) => e.episode_no === (ctx.media.type === 'show' ? ctx.media.episode.number : 1),
|
||||
);
|
||||
|
||||
if (!episode) {
|
||||
throw new NotFoundError('No watchable sources found');
|
||||
}
|
||||
|
||||
return {
|
||||
embeds: [
|
||||
{
|
||||
embedId: 'myanimesub',
|
||||
url: episode.id,
|
||||
},
|
||||
{
|
||||
embedId: 'myanimedub',
|
||||
url: episode.id,
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
export const myanimeScraper = makeSourcerer({
|
||||
id: 'myanime',
|
||||
name: 'MyAnime',
|
||||
rank: 101,
|
||||
flags: [flags.CORS_ALLOWED],
|
||||
scrapeMovie: universalScraper,
|
||||
scrapeShow: universalScraper,
|
||||
});
|
||||
88
src/providers/sources/myanime/ai.ts
Normal file
88
src/providers/sources/myanime/ai.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import { ShowMedia } from '@/entrypoint/utils/media';
|
||||
import { ScrapeContext } from '@/utils/context';
|
||||
|
||||
const GEMINI_BASE_URL = 'https://gemini.aether.mom/v1beta/models/gemini-2.5-flash-lite:generateContent';
|
||||
|
||||
type MyanimeSearchResult = {
|
||||
id: string;
|
||||
title: string;
|
||||
alt_title: string;
|
||||
tvInfo: {
|
||||
showType: string;
|
||||
totalEpisodes: number;
|
||||
poster: string;
|
||||
year: number;
|
||||
sub: number;
|
||||
dub: number;
|
||||
eps: number;
|
||||
};
|
||||
};
|
||||
|
||||
type GeminiResponse = {
|
||||
results: Array<{
|
||||
id: string;
|
||||
season?: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
function buildPrompt(media: ShowMedia, searchResults: MyanimeSearchResult[]): string {
|
||||
const seasons = media.season.number > 1 ? ` and has ${media.season.number} seasons` : '';
|
||||
const prompt = `
|
||||
You are an AI that matches TMDB movie and show data to myanime search results.
|
||||
The user is searching for "${media.title}" which was released in ${media.releaseYear}${seasons}.
|
||||
The user is looking for season ${media.season.number} (TMDB title: "${media.season.title}", ${
|
||||
media.season.episodeCount ?? 'unknown'
|
||||
} episodes), episode ${media.episode.number}.
|
||||
|
||||
Here are the search results from myanime:
|
||||
${JSON.stringify(searchResults, null, 2)}
|
||||
|
||||
IMPORTANT: Some shows on TMDB have continuous episode numbering across seasons (e.g., episode 25 is the first episode of season 2), but myanime lists seasons as separate entries with their own episode counts. The myanime entry may also have a different title (e.g., "Mugen Train Arc").
|
||||
To solve this, please return a JSON object with a "results" array that contains ALL entries from the search results that match the requested show, including all of its seasons, even if the user is only asking for one.
|
||||
Each object in the "results" array should have the "id" of the matching anime from the myanime search results, and the "season" number. You must determine the season number for each entry based on its title.
|
||||
The results MUST be sorted by season number in ascending order so the calling code can correctly map the episode number.
|
||||
Pay close attention to the season title and episode counts from both TMDB and the myanime results to find the best match. If TMDB combines seasons into one, you must split them based on the episode counts in the search results.
|
||||
Use the TMDB season title as the primary key for matching, and do not assign the same season number to different arcs.
|
||||
Your response must only be the raw JSON object, without any markdown formatting, comments, or other text.
|
||||
`;
|
||||
return prompt.trim();
|
||||
}
|
||||
|
||||
export async function getAiMatching(
|
||||
ctx: ScrapeContext,
|
||||
media: ShowMedia,
|
||||
searchResults: MyanimeSearchResult[],
|
||||
): Promise<GeminiResponse | null> {
|
||||
try {
|
||||
const prompt = buildPrompt(media, searchResults);
|
||||
const response = await ctx.fetcher<any>(GEMINI_BASE_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
contents: [{ parts: [{ text: prompt }] }],
|
||||
}),
|
||||
});
|
||||
|
||||
const text = response.candidates[0].content.parts[0].text;
|
||||
const firstBracket = text.indexOf('{');
|
||||
const lastBracket = text.lastIndexOf('}');
|
||||
if (firstBracket === -1 || lastBracket === -1) {
|
||||
throw new Error('Invalid AI response: No JSON object found');
|
||||
}
|
||||
const jsonString = text.substring(firstBracket, lastBracket + 1);
|
||||
const data = JSON.parse(jsonString) as GeminiResponse;
|
||||
|
||||
if (!data.results || !Array.isArray(data.results)) {
|
||||
throw new Error('Invalid AI response format');
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
ctx.progress(0); // Reset progress on error
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
146
src/providers/sources/myanime/myanime.ts
Normal file
146
src/providers/sources/myanime/myanime.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
import { flags } from '@/entrypoint/utils/targets';
|
||||
import { SourcererOutput, makeSourcerer } from '@/providers/base';
|
||||
import { getAnilistEnglishTitle } from '@/utils/anilist';
|
||||
import { compareTitle } from '@/utils/compare';
|
||||
import { MovieScrapeContext, ShowScrapeContext } from '@/utils/context';
|
||||
import { NotFoundError } from '@/utils/errors';
|
||||
|
||||
import { getAiMatching } from './ai';
|
||||
|
||||
const showScraper = async (ctx: ShowScrapeContext): Promise<SourcererOutput> => {
|
||||
const title = await getAnilistEnglishTitle(ctx, ctx.media);
|
||||
if (!title) throw new NotFoundError('Anime not found');
|
||||
|
||||
const allAnimes: any[] = [];
|
||||
for (const t of [ctx.media.title, title]) {
|
||||
try {
|
||||
const searchResult = await ctx.proxiedFetcher<any>(
|
||||
`https://anime.aether.mom/api/search?keyword=${encodeURIComponent(t)}`,
|
||||
);
|
||||
if (searchResult?.results?.data) {
|
||||
allAnimes.push(...searchResult.results.data);
|
||||
}
|
||||
} catch (err) {
|
||||
// ignore network errors
|
||||
}
|
||||
}
|
||||
|
||||
const uniqueAnimes = [...new Map(allAnimes.map((item) => [item.id, item])).values()];
|
||||
if (uniqueAnimes.length === 0) throw new NotFoundError('Anime not found');
|
||||
|
||||
const tvAnimes = uniqueAnimes.filter((v) => v.tvInfo.showType === 'TV');
|
||||
|
||||
const aiResult = await getAiMatching(ctx, ctx.media, tvAnimes);
|
||||
|
||||
let seasons: Array<any> = [];
|
||||
if (aiResult && aiResult.results.length > 0) {
|
||||
seasons = aiResult.results
|
||||
.map((v) => {
|
||||
const anime = tvAnimes.find((a) => a.id === v.id);
|
||||
if (!anime) return null;
|
||||
return {
|
||||
...anime,
|
||||
seasonNum: v.season ?? 1,
|
||||
};
|
||||
})
|
||||
.filter((v) => v !== null)
|
||||
.sort((a, b) => a.seasonNum - b.seasonNum);
|
||||
}
|
||||
|
||||
if (seasons.length === 0) throw new NotFoundError('Anime not found');
|
||||
|
||||
let episodeId: string | undefined;
|
||||
|
||||
// strategy 1: direct mapping
|
||||
let season = seasons.find((v) => v.seasonNum === ctx.media.season.number);
|
||||
const seasonEntries = seasons.filter((v) => v.seasonNum === ctx.media.season.number);
|
||||
|
||||
if (seasonEntries.length > 1) {
|
||||
const sorted = seasonEntries.sort((a, b) => {
|
||||
const aTitleText = a.title;
|
||||
const bTitleText = b.title;
|
||||
const targetTitle = ctx.media.season.title;
|
||||
return Number(compareTitle(bTitleText, targetTitle)) - Number(compareTitle(aTitleText, targetTitle));
|
||||
});
|
||||
season = sorted[0];
|
||||
}
|
||||
|
||||
if (season) {
|
||||
const episodeData = await ctx.proxiedFetcher<any>(`https://anime.aether.mom/api/episodes/${season.id}`);
|
||||
if (episodeData?.results?.episodes) {
|
||||
const episode = episodeData.results.episodes.find((ep: any) => ep.episode_no === ctx.media.episode.number);
|
||||
if (episode) episodeId = episode.id;
|
||||
}
|
||||
}
|
||||
|
||||
// strategy 2: cumulative mapping
|
||||
if (!episodeId) {
|
||||
let episodeNumber = ctx.media.episode.number;
|
||||
for (const s of seasons) {
|
||||
const epCount = s.tvInfo.sub ?? 0;
|
||||
if (episodeNumber <= epCount) {
|
||||
const targetEpisodeNumber = episodeNumber;
|
||||
const episodeData = await ctx.proxiedFetcher<any>(`https://anime.aether.mom/api/episodes/${s.id}`);
|
||||
if (episodeData?.results?.episodes) {
|
||||
const episode = episodeData.results.episodes.find((ep: any) => ep.episode_no === targetEpisodeNumber);
|
||||
if (episode) {
|
||||
episodeId = episode.id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (episodeId) break;
|
||||
episodeNumber -= epCount;
|
||||
}
|
||||
}
|
||||
|
||||
if (!episodeId) throw new NotFoundError('Episode not found');
|
||||
|
||||
return {
|
||||
embeds: [
|
||||
{
|
||||
embedId: 'myanimesub',
|
||||
url: episodeId,
|
||||
},
|
||||
{
|
||||
embedId: 'myanimedub',
|
||||
url: episodeId,
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
const universalScraper = async (ctx: MovieScrapeContext): Promise<SourcererOutput> => {
|
||||
const searchResults = await ctx.proxiedFetcher<any>(
|
||||
`https://anime.aether.mom/api/search?keyword=${encodeURIComponent(ctx.media.title)}`,
|
||||
);
|
||||
|
||||
const movie = searchResults.results.data.find((v: any) => v.tvInfo.showType === 'Movie');
|
||||
if (!movie) throw new NotFoundError('No watchable sources found');
|
||||
|
||||
const episodeData = await ctx.proxiedFetcher<any>(`https://anime.aether.mom/api/episodes/${movie.id}`);
|
||||
const episode = episodeData.results.episodes.find((e: any) => e.episode_no === 1);
|
||||
if (!episode) throw new NotFoundError('No watchable sources found');
|
||||
|
||||
return {
|
||||
embeds: [
|
||||
{
|
||||
embedId: 'myanimesub',
|
||||
url: episode.id,
|
||||
},
|
||||
{
|
||||
embedId: 'myanimedub',
|
||||
url: episode.id,
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
export const myanimeScraper = makeSourcerer({
|
||||
id: 'myanime',
|
||||
name: 'MyAnime 🌸',
|
||||
rank: 810,
|
||||
flags: [flags.CORS_ALLOWED],
|
||||
scrapeMovie: universalScraper,
|
||||
scrapeShow: showScraper,
|
||||
});
|
||||
Loading…
Reference in a new issue