/** * 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); }