707 lines
21 KiB
JavaScript
707 lines
21 KiB
JavaScript
/**
|
|
* Resolve TMDB media to AniList, then retrieve Miruro playback sources.
|
|
*
|
|
* Miruro's current catalog is keyed by opaque catalog IDs and served from
|
|
* /api/v1. It no longer accepts AniList IDs as a filter, so this provider:
|
|
* 1. maps TMDB to AniList IDs through AniMap,
|
|
* 2. resolves TV seasons by walking AniList's SEQUEL relations,
|
|
* 3. finds the matching catalog entry by title search and verifies it via
|
|
* the entry's own external_ids.anilist list,
|
|
* 4. uses the show cluster's aired_season metadata to select the requested
|
|
* TMDB season, including seasons split across several entries,
|
|
* 5. requests episode playback data from the catalog.
|
|
*/
|
|
|
|
import { HEADERS, apiGet, fetchJson } from './http.js';
|
|
|
|
const ANIMAP_URL = 'https://animap.id/api/map';
|
|
const ANILIST_URL = 'https://graphql.anilist.co';
|
|
const SEARCH_LIMIT = 15;
|
|
const MAX_SEARCH_QUERIES = 5;
|
|
|
|
const PROVIDER_LABELS = {
|
|
animepahe: 'AnimePahe',
|
|
anikoto: 'Anikoto',
|
|
aniwaves: 'Aniwaves',
|
|
icarus: 'Icarus',
|
|
kickassanime: 'KickAssAnime'
|
|
};
|
|
|
|
const anilistCache = new Map();
|
|
const episodeCache = new Map();
|
|
const showCache = new Map();
|
|
|
|
function cacheGet(cache, key) {
|
|
return cache.has(key) ? cache.get(key) : undefined;
|
|
}
|
|
|
|
function cacheSet(cache, key, value) {
|
|
// Nuvio keeps a provider module alive between requests; bound the caches so
|
|
// browsing many shows cannot grow them without limit.
|
|
if (cache.size >= 40) {
|
|
const oldest = cache.keys().next().value;
|
|
cache.delete(oldest);
|
|
}
|
|
|
|
cache.set(key, value);
|
|
return value;
|
|
}
|
|
|
|
function asNumber(value) {
|
|
const number = Number(value);
|
|
return Number.isFinite(number) ? number : null;
|
|
}
|
|
|
|
function asArray(value) {
|
|
if (value == null) {
|
|
return [];
|
|
}
|
|
|
|
return Array.isArray(value) ? value : [value];
|
|
}
|
|
|
|
function parseIdentifier(value) {
|
|
const id = String(value || '').trim();
|
|
|
|
if (/^tt\d+$/i.test(id)) {
|
|
return { service: 'imdb', value: id.toLowerCase() };
|
|
}
|
|
|
|
if (/^\d+$/.test(id)) {
|
|
return { service: 'tmdb', value: id };
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function recordMatchesIdentifier(record, identifier, mediaType) {
|
|
const expectedType = mediaType === 'movie' ? 'movie' : 'tv';
|
|
|
|
if (identifier.service === 'imdb') {
|
|
const imdbMatches = asArray(record.imdb_id).some((id) => (
|
|
String(id).toLowerCase() === identifier.value
|
|
));
|
|
const tmdbMappings = asArray(record.tmdb_id);
|
|
const typeMatches = tmdbMappings.length === 0 || tmdbMappings.some((mapping) => (
|
|
mapping.type === expectedType
|
|
));
|
|
|
|
return imdbMatches && typeMatches;
|
|
}
|
|
|
|
return asArray(record.tmdb_id).some((mapping) => (
|
|
String(mapping.id) === identifier.value && mapping.type === expectedType
|
|
));
|
|
}
|
|
|
|
function getMapping(records, identifier, mediaType) {
|
|
const ids = [];
|
|
const titles = [];
|
|
const synonyms = [];
|
|
const normalizedRecords = Array.isArray(records)
|
|
? records
|
|
: records && typeof records === 'object'
|
|
? [records]
|
|
: [];
|
|
|
|
for (const record of normalizedRecords) {
|
|
if (!recordMatchesIdentifier(record, identifier, mediaType)) {
|
|
continue;
|
|
}
|
|
|
|
for (const id of asArray(record.anilist_id)) {
|
|
const numericId = asNumber(id);
|
|
|
|
if (numericId && !ids.includes(numericId)) {
|
|
ids.push(numericId);
|
|
}
|
|
}
|
|
|
|
if (record.title) {
|
|
titles.push(String(record.title));
|
|
}
|
|
|
|
for (const synonym of asArray(record.synonyms)) {
|
|
if (synonym) {
|
|
synonyms.push(String(synonym));
|
|
}
|
|
}
|
|
}
|
|
|
|
return { ids, titles, synonyms };
|
|
}
|
|
|
|
async function getAniListMeta(anilistId) {
|
|
if (anilistCache.has(anilistId)) {
|
|
return anilistCache.get(anilistId);
|
|
}
|
|
|
|
const response = await fetchJson(ANILIST_URL, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Accept': 'application/json',
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({
|
|
query: `
|
|
query ($id: Int!) {
|
|
Media(id: $id, type: ANIME) {
|
|
title {
|
|
romaji
|
|
english
|
|
native
|
|
}
|
|
synonyms
|
|
relations {
|
|
edges {
|
|
relationType
|
|
node {
|
|
id
|
|
type
|
|
format
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`,
|
|
variables: { id: anilistId }
|
|
})
|
|
});
|
|
|
|
const media = response && response.data && response.data.Media ? response.data.Media : null;
|
|
const edges = media && media.relations && Array.isArray(media.relations.edges)
|
|
? media.relations.edges
|
|
: [];
|
|
// A sequel edge can point at a compilation movie as well as the TV cour.
|
|
// Nuvio requests a TV season here, so only follow episodic formats.
|
|
const sequel = edges.find((edge) => (
|
|
edge.relationType === 'SEQUEL' &&
|
|
edge.node &&
|
|
edge.node.type === 'ANIME' &&
|
|
['TV', 'TV_SHORT', 'ONA'].includes(edge.node.format)
|
|
));
|
|
const meta = {
|
|
title: media && media.title ? media.title : null,
|
|
synonyms: media && Array.isArray(media.synonyms) ? media.synonyms : [],
|
|
sequelId: sequel ? asNumber(sequel.node.id) : null
|
|
};
|
|
|
|
anilistCache.set(anilistId, meta);
|
|
return meta;
|
|
}
|
|
|
|
async function getSeasonAniListId(anilistId, season) {
|
|
const requestedSeason = asNumber(season) || 1;
|
|
let currentId = anilistId;
|
|
|
|
for (let currentSeason = 1; currentSeason < requestedSeason; currentSeason++) {
|
|
const meta = await getAniListMeta(currentId);
|
|
|
|
if (!meta.sequelId || meta.sequelId === currentId) {
|
|
return null;
|
|
}
|
|
|
|
currentId = meta.sequelId;
|
|
}
|
|
|
|
return currentId;
|
|
}
|
|
|
|
function responseItems(response) {
|
|
if (Array.isArray(response)) {
|
|
return response;
|
|
}
|
|
|
|
if (response && Array.isArray(response.data)) {
|
|
return response.data;
|
|
}
|
|
|
|
if (response && Array.isArray(response.items)) {
|
|
return response.items;
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
function getExternalIds(anime, key) {
|
|
return asArray(anime && anime.external_ids ? anime.external_ids[key] : null)
|
|
.map((id) => asNumber(id))
|
|
.filter((id) => id != null);
|
|
}
|
|
|
|
async function searchCatalogAnime(title) {
|
|
// Miruro rejects search limits above 15 with a generic 400.
|
|
const response = await apiGet('anime', {
|
|
q: title,
|
|
limit: SEARCH_LIMIT,
|
|
sort: '-popularity'
|
|
});
|
|
|
|
return responseItems(response);
|
|
}
|
|
|
|
function matchesAnyAniListId(anime, aniListIds) {
|
|
const targets = new Set(aniListIds.map((id) => Number(id)));
|
|
return getExternalIds(anime, 'anilist').some((id) => targets.has(Number(id)));
|
|
}
|
|
|
|
function buildSearchTitles(targetMeta, mapping) {
|
|
const titles = [
|
|
targetMeta && targetMeta.title ? targetMeta.title.romaji : null,
|
|
mapping.titles[0],
|
|
targetMeta && targetMeta.title ? targetMeta.title.english : null,
|
|
...mapping.synonyms.slice(0, 2),
|
|
...(targetMeta ? targetMeta.synonyms.slice(0, 2) : [])
|
|
];
|
|
const seen = new Set();
|
|
|
|
return titles
|
|
.filter((title) => typeof title === 'string' && title.trim().length >= 3)
|
|
.map((title) => title.trim())
|
|
.filter((title) => {
|
|
const key = title.toLowerCase();
|
|
|
|
if (seen.has(key)) {
|
|
return false;
|
|
}
|
|
|
|
seen.add(key);
|
|
return title;
|
|
})
|
|
.slice(0, MAX_SEARCH_QUERIES);
|
|
}
|
|
|
|
async function findCatalogAnime(candidateIds, targetId, searchTitles) {
|
|
for (const title of searchTitles) {
|
|
let results;
|
|
|
|
try {
|
|
results = await searchCatalogAnime(title);
|
|
} catch (error) {
|
|
console.warn(`[Miruro] Catalog search "${title}" failed: ${error.message}`);
|
|
|
|
// A 403 means the catalog rejected the client itself (Cloudflare or
|
|
// an IP block); trying more title variants cannot help.
|
|
if (error.status === 403) {
|
|
break;
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
if (results.length === 0) {
|
|
continue;
|
|
}
|
|
|
|
const exact = results.find((anime) => getExternalIds(anime, 'anilist').includes(Number(targetId)));
|
|
|
|
if (exact) {
|
|
return exact;
|
|
}
|
|
|
|
const candidate = results.find((anime) => matchesAnyAniListId(anime, candidateIds));
|
|
|
|
if (candidate) {
|
|
return candidate;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
async function listEpisodes(animeId) {
|
|
const cached = cacheGet(episodeCache, animeId);
|
|
|
|
if (cached) {
|
|
return cached;
|
|
}
|
|
|
|
const episodes = [];
|
|
let cursor;
|
|
|
|
for (let page = 0; page < 5; page++) {
|
|
const response = await apiGet(`anime/${encodeURIComponent(animeId)}/episodes`, {
|
|
limit: 10000,
|
|
cursor
|
|
});
|
|
const items = responseItems(response);
|
|
|
|
episodes.push(...items);
|
|
|
|
if (!response || !response.has_more || !response.next_cursor || items.length === 0) {
|
|
break;
|
|
}
|
|
|
|
cursor = response.next_cursor;
|
|
}
|
|
|
|
return cacheSet(episodeCache, animeId, episodes);
|
|
}
|
|
|
|
async function listShowEntries(animeId) {
|
|
const cached = cacheGet(showCache, animeId);
|
|
|
|
if (cached) {
|
|
return cached;
|
|
}
|
|
|
|
let entries = [];
|
|
|
|
try {
|
|
const response = await apiGet(`anime/${encodeURIComponent(animeId)}/show`);
|
|
entries = responseItems(response);
|
|
} catch (error) {
|
|
console.warn(`[Miruro] Show cluster for ${animeId} failed: ${error.message}`);
|
|
}
|
|
|
|
return cacheSet(showCache, animeId, entries);
|
|
}
|
|
|
|
function clusterPosition(anime) {
|
|
const position = asNumber(anime && anime.show_cluster_position);
|
|
return position == null ? Number.MAX_SAFE_INTEGER : position;
|
|
}
|
|
|
|
function seasonOf(anime) {
|
|
return asNumber(anime && anime.aired_season ? anime.aired_season.number : null);
|
|
}
|
|
|
|
function entryBelongsToRequest(anime, mapping, identifier) {
|
|
if (identifier.service === 'tmdb') {
|
|
const tmdbIds = asArray(anime && anime.external_ids ? anime.external_ids.tmdb_tv : null)
|
|
.map((id) => String(id));
|
|
|
|
if (tmdbIds.includes(identifier.value)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
if (identifier.service === 'imdb') {
|
|
const imdbIds = asArray(anime && anime.external_ids ? anime.external_ids.imdb : null)
|
|
.map((id) => String(id).toLowerCase());
|
|
|
|
if (imdbIds.includes(identifier.value)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return matchesAnyAniListId(anime, mapping.ids);
|
|
}
|
|
|
|
function isMovieFormat(anime) {
|
|
return String(anime && anime.format || '').toUpperCase() === 'MOVIE';
|
|
}
|
|
|
|
async function resolveSeasonEpisode(seed, mapping, identifier, season, episode) {
|
|
const requestedSeason = asNumber(season) || 1;
|
|
const requestedEpisode = asNumber(episode);
|
|
|
|
if (!requestedEpisode || requestedEpisode < 1) {
|
|
return null;
|
|
}
|
|
|
|
const seedEpisodes = await listEpisodes(seed.id);
|
|
const showEntries = await listShowEntries(seed.id);
|
|
const related = showEntries.length > 0 ? showEntries : [seed];
|
|
const seasonCandidates = related
|
|
.filter((anime) => (
|
|
!isMovieFormat(anime) &&
|
|
seasonOf(anime) === requestedSeason &&
|
|
entryBelongsToRequest(anime, mapping, identifier)
|
|
))
|
|
.sort((first, second) => clusterPosition(first) - clusterPosition(second));
|
|
|
|
// Some catalog seasons are split across several entries (for example a TV
|
|
// season released in parts). Episode numbers restart in each entry, so
|
|
// concatenate the season's entries in cluster order and index into it.
|
|
const seasonEpisodes = [];
|
|
|
|
for (const anime of seasonCandidates) {
|
|
const episodes = anime.id === seed.id ? seedEpisodes : await listEpisodes(anime.id);
|
|
|
|
// A season candidate already belongs to the requested season. Some of
|
|
// its episodes have missing aired_season_number tags, so keep them all
|
|
// rather than trusting the per-episode tag here.
|
|
for (const item of episodes) {
|
|
seasonEpisodes.push({ anime, episode: item });
|
|
}
|
|
}
|
|
|
|
const fromCluster = seasonEpisodes[requestedEpisode - 1];
|
|
|
|
if (fromCluster) {
|
|
return fromCluster;
|
|
}
|
|
|
|
// Single-entry long runners (One Piece and similar) tag their episodes with
|
|
// the TMDB season even though the catalog has no per-season entry.
|
|
const tagged = seedEpisodes.filter((item) => (
|
|
item.aired_season_number != null &&
|
|
Number(item.aired_season_number) === requestedSeason
|
|
));
|
|
const fromTags = tagged[requestedEpisode - 1];
|
|
|
|
if (fromTags) {
|
|
return { anime: seed, episode: fromTags };
|
|
}
|
|
|
|
// TMDB sometimes keeps one season while the catalog splits the same run
|
|
// into several seasons (The Ramparts of Ice: TMDB S1 episode 15 is catalog
|
|
// season 2 episode 1). Walk catalog seasons at or after the requested one
|
|
// in order, carrying an episode offset, so those episodes stay reachable.
|
|
const continuation = related
|
|
.filter((anime) => (
|
|
!isMovieFormat(anime) &&
|
|
seasonOf(anime) != null &&
|
|
seasonOf(anime) >= requestedSeason &&
|
|
entryBelongsToRequest(anime, mapping, identifier)
|
|
))
|
|
.sort((first, second) => (
|
|
seasonOf(first) - seasonOf(second) ||
|
|
clusterPosition(first) - clusterPosition(second)
|
|
));
|
|
let offset = 0;
|
|
|
|
for (const anime of continuation) {
|
|
const episodes = anime.id === seed.id ? seedEpisodes : await listEpisodes(anime.id);
|
|
|
|
if (episodes.length === 0) {
|
|
continue;
|
|
}
|
|
|
|
const index = requestedEpisode - 1 - offset;
|
|
|
|
if (index >= 0 && index < episodes.length) {
|
|
return { anime, episode: episodes[index] };
|
|
}
|
|
|
|
offset += episodes.length;
|
|
}
|
|
|
|
const local = seedEpisodes.find((item) => Number(item.episode_number) === requestedEpisode);
|
|
|
|
return local ? { anime: seed, episode: local } : null;
|
|
}
|
|
|
|
function selectMovieEpisode(episodes) {
|
|
if (episodes.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
return episodes.find((item) => Number(item.episode_number) === 1) || episodes[0];
|
|
}
|
|
|
|
async function getPlayback(animeId, episodeNumber) {
|
|
const response = await apiGet(
|
|
`anime/${encodeURIComponent(animeId)}/episodes/${encodeURIComponent(episodeNumber)}/play`
|
|
);
|
|
|
|
if (response && response.data && Array.isArray(response.data.tracks)) {
|
|
return response.data;
|
|
}
|
|
|
|
return response;
|
|
}
|
|
|
|
function providerLabel(providerId) {
|
|
const id = String(providerId || 'miruro').toLowerCase();
|
|
|
|
if (PROVIDER_LABELS[id]) {
|
|
return PROVIDER_LABELS[id];
|
|
}
|
|
|
|
return id
|
|
.replace(/[-_]+/g, ' ')
|
|
.replace(/\b[a-z]/g, (letter) => letter.toUpperCase());
|
|
}
|
|
|
|
function getQuality(stream) {
|
|
const raw = stream && (stream.quality ?? stream.resolution);
|
|
const height = raw && typeof raw === 'object' ? raw.height : raw;
|
|
const match = String(height ?? '').match(/(\d{3,4})/);
|
|
|
|
if (match) {
|
|
return `${match[1]}p`;
|
|
}
|
|
|
|
return /4k/i.test(String(height ?? '')) ? '4K' : 'HD';
|
|
}
|
|
|
|
function normalizeHeaders(headers, streamHeaders) {
|
|
const result = {};
|
|
|
|
for (const source of [streamHeaders, headers]) {
|
|
if (!source || typeof source !== 'object') {
|
|
continue;
|
|
}
|
|
|
|
for (const [key, value] of Object.entries(source)) {
|
|
if (value == null) {
|
|
continue;
|
|
}
|
|
|
|
result[key] = String(value);
|
|
}
|
|
}
|
|
|
|
if (!Object.keys(result).some((key) => key.toLowerCase() === 'user-agent')) {
|
|
result['User-Agent'] = HEADERS['User-Agent'];
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
function normalizePlayback(playback) {
|
|
const streams = [];
|
|
const seen = new Set();
|
|
const tracks = playback && Array.isArray(playback.tracks) ? playback.tracks : [];
|
|
|
|
for (const track of tracks) {
|
|
const trackKey = String(track && track.track || '').toLowerCase();
|
|
const language = trackKey === 'dub' ? 'DUB' : 'SUB';
|
|
const providers = track && Array.isArray(track.providers) ? track.providers : [];
|
|
|
|
for (const provider of providers) {
|
|
const label = providerLabel(provider && provider.provider);
|
|
const hasSubtitles = !!(provider && Array.isArray(provider.subtitles) && provider.subtitles.length > 0);
|
|
const subtitleLabel = hasSubtitles && language === 'SUB' ? ' · CC' : '';
|
|
const servers = provider && Array.isArray(provider.servers) ? provider.servers : [];
|
|
|
|
for (const server of servers) {
|
|
const serverName = String(server && server.server || 'default');
|
|
const serverStreams = server && Array.isArray(server.streams) ? server.streams : [];
|
|
|
|
for (const stream of serverStreams) {
|
|
const url = stream && stream.url;
|
|
|
|
// A stream can carry both a direct HLS URL and an embed URL.
|
|
// Only skip entries that have no direct URL at all.
|
|
if (!url || typeof url !== 'string' || !/^https?:\/\//i.test(url)) {
|
|
continue;
|
|
}
|
|
|
|
if (seen.has(url)) {
|
|
continue;
|
|
}
|
|
|
|
seen.add(url);
|
|
|
|
const quality = getQuality(stream);
|
|
|
|
streams.push({
|
|
name: `Miruro · ${label} · ${language}`,
|
|
title: `[${language}] ${label} · ${serverName} · ${quality}${subtitleLabel}`,
|
|
url,
|
|
quality,
|
|
headers: normalizeHeaders(server && server.headers, stream.headers)
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return streams;
|
|
}
|
|
|
|
async function resolveAnime(mediaId, mediaType, season, episode) {
|
|
const identifier = parseIdentifier(mediaId);
|
|
|
|
if (!identifier) {
|
|
return null;
|
|
}
|
|
|
|
let mappingRecords;
|
|
|
|
try {
|
|
mappingRecords = await fetchJson(
|
|
`${ANIMAP_URL}/${identifier.service}/${encodeURIComponent(identifier.value)}`,
|
|
{ headers: { 'Accept': 'application/json' } }
|
|
);
|
|
} catch (error) {
|
|
console.warn(`[Miruro] No anime mapping for ${mediaId}: ${error.message}`);
|
|
return null;
|
|
}
|
|
|
|
const mapping = getMapping(mappingRecords, identifier, mediaType);
|
|
|
|
if (mapping.ids.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const requestedSeason = asNumber(season) || 1;
|
|
let targetId = mapping.ids[0];
|
|
|
|
if (mediaType === 'tv' && requestedSeason > 1) {
|
|
try {
|
|
const seasonAniListId = await getSeasonAniListId(mapping.ids[0], requestedSeason);
|
|
|
|
if (seasonAniListId) {
|
|
targetId = seasonAniListId;
|
|
} else if (mapping.ids[requestedSeason - 1]) {
|
|
targetId = mapping.ids[requestedSeason - 1];
|
|
}
|
|
} catch (error) {
|
|
console.warn(`[Miruro] Season ${requestedSeason} mapping failed: ${error.message}`);
|
|
|
|
if (mapping.ids[requestedSeason - 1]) {
|
|
targetId = mapping.ids[requestedSeason - 1];
|
|
}
|
|
}
|
|
}
|
|
|
|
let targetMeta = null;
|
|
|
|
try {
|
|
targetMeta = await getAniListMeta(targetId);
|
|
} catch (error) {
|
|
console.warn(`[Miruro] AniList metadata for ${targetId} failed: ${error.message}`);
|
|
}
|
|
|
|
const searchTitles = buildSearchTitles(targetMeta, mapping);
|
|
const seed = await findCatalogAnime(mapping.ids, targetId, searchTitles);
|
|
|
|
if (!seed || !seed.id) {
|
|
console.warn(`[Miruro] No catalog entry for AniList ${targetId}`);
|
|
return null;
|
|
}
|
|
|
|
let selection;
|
|
|
|
if (mediaType === 'movie') {
|
|
const episodeItem = selectMovieEpisode(await listEpisodes(seed.id));
|
|
|
|
if (!episodeItem) {
|
|
console.warn(`[Miruro] No playable episode for AniList ${targetId}`);
|
|
return null;
|
|
}
|
|
|
|
selection = { anime: seed, episode: episodeItem };
|
|
} else {
|
|
selection = await resolveSeasonEpisode(seed, mapping, identifier, requestedSeason, episode);
|
|
|
|
if (!selection) {
|
|
console.warn(`[Miruro] Episode ${episode} not found for AniList ${targetId}`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
const playback = await getPlayback(selection.anime.id, selection.episode.episode_number);
|
|
|
|
return { ...selection, playback };
|
|
}
|
|
|
|
export async function extractStreams(tmdbId, mediaType, season, episode) {
|
|
if (!tmdbId || (mediaType !== 'movie' && mediaType !== 'tv')) {
|
|
return [];
|
|
}
|
|
|
|
const resolved = await resolveAnime(tmdbId, mediaType, season, episode);
|
|
|
|
if (!resolved || !resolved.playback) {
|
|
return [];
|
|
}
|
|
|
|
return normalizePlayback(resolved.playback);
|
|
}
|