From 8c0fd70b7b045dd23142aa60208277b9a7e7e984 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C4=81nis=20L?= Date: Tue, 27 Oct 2026 12:15:52 +0200 Subject: [PATCH] fix miruro --- .gitignore | 1 + manifest.json | 6 +- providers/miruro.js | 672 ++++++++++++++++++++--------------- src/miruro/extractor.js | 759 ++++++++++++++++++++++++++-------------- src/miruro/http.js | 158 ++++----- 5 files changed, 955 insertions(+), 641 deletions(-) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b512c09 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +node_modules \ No newline at end of file diff --git a/manifest.json b/manifest.json index 1c35520..9b3473c 100644 --- a/manifest.json +++ b/manifest.json @@ -1,6 +1,6 @@ { "name": "Nuvio Providers", - "version": "1.0.12", + "version": "1.0.13", "scrapers": [ { "id": "bcine", @@ -54,7 +54,7 @@ "id": "miruro", "name": "Miruro", "description": "Anime streams from Miruro with subbed and dubbed sources.", - "version": "1.0.4", + "version": "1.0.5", "author": "Jānis L", "supportedTypes": [ "movie", @@ -67,7 +67,7 @@ "m3u8", "mp4" ], - "logo": "https://www.miruro.to/icon-light-1024x1024.png", + "logo": "https://www.miruro.cx/icon-light-1024x1024.png", "contentLanguage": [ "en", "ja" diff --git a/providers/miruro.js b/providers/miruro.js index c32a473..720d312 100644 --- a/providers/miruro.js +++ b/providers/miruro.js @@ -435,76 +435,39 @@ function strFromU8(dat, latin1) { } // src/miruro/http.js -var BASE_URL = "https://www.miruro.to"; +var BASE_URL = "https://www.miruro.cx"; var HEADERS = { "Accept": "*/*", + "Accept-Language": "en-US,en;q=0.9", "Referer": `${BASE_URL}/`, "User-Agent": "Mozilla/5.0 (Linux; Android 15) AppleWebKit/537.36 Chrome/140.0.0.0 Mobile Safari/537.36", "Sec-Fetch-Dest": "empty", "Sec-Fetch-Mode": "cors", "Sec-Fetch-Site": "same-origin" }; -var PIPE_OBFUSCATION_KEY = hexToBytes("71951034f8fbcf53d89db52ceb3dc22c"); -var BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; -function hexToBytes(value) { - const bytes = new Uint8Array(value.length / 2); +var CATALOG_OBFUSCATION_KEY = "miruro/catalog"; +var RETRY_STATUSES = /* @__PURE__ */ new Set([400, 403, 408, 429, 500, 502, 503, 504]); +var MAX_ATTEMPTS = 3; +function decodeCatalogPayload(bytes) { for (let index = 0; index < bytes.length; index++) { - bytes[index] = parseInt(value.slice(index * 2, index * 2 + 2), 16); - } - return bytes; -} -function encodeBase64Url(value) { - let result = ""; - for (let index = 0; index < value.length; index += 3) { - const first = value.charCodeAt(index); - const hasSecond = index + 1 < value.length; - const hasThird = index + 2 < value.length; - const second = hasSecond ? value.charCodeAt(index + 1) : 0; - const third = hasThird ? value.charCodeAt(index + 2) : 0; - const chunk = first << 16 | second << 8 | third; - result += BASE64_ALPHABET[chunk >> 18 & 63]; - result += BASE64_ALPHABET[chunk >> 12 & 63]; - result += hasSecond ? BASE64_ALPHABET[chunk >> 6 & 63] : "="; - result += hasThird ? BASE64_ALPHABET[chunk & 63] : "="; - } - return result.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); -} -function decodeBase64Url(value) { - const normalized = value.replace(/-/g, "+").replace(/_/g, "/").replace(/\s/g, ""); - const outputLength = Math.floor(normalized.length * 6 / 8); - const output = new Uint8Array(outputLength); - let buffer = 0; - let bits2 = 0; - let outputIndex = 0; - for (let index = 0; index < normalized.length; index++) { - const code = BASE64_ALPHABET.indexOf(normalized[index]); - if (code < 0) { - continue; - } - buffer = buffer << 6 | code; - bits2 += 6; - if (bits2 >= 8) { - bits2 -= 8; - output[outputIndex++] = buffer >> bits2 & 255; - } - } - return outputIndex === output.length ? output : output.slice(0, outputIndex); -} -function decodeSecurePayload(raw, obfuscationVersion) { - if (!obfuscationVersion) { - return JSON.parse(raw); - } - const bytes = decodeBase64Url(raw); - if (obfuscationVersion === "2") { - for (let index = 0; index < bytes.length; index++) { - bytes[index] ^= PIPE_OBFUSCATION_KEY[index % PIPE_OBFUSCATION_KEY.length]; - } + bytes[index] ^= CATALOG_OBFUSCATION_KEY.charCodeAt(index % CATALOG_OBFUSCATION_KEY.length); } return JSON.parse(strFromU8(gunzipSync(bytes))); } function mergeHeaders(headers) { return __spreadValues(__spreadValues({}, HEADERS), headers || {}); } +function buildQuery(query) { + const parts = []; + for (const [key, value] of Object.entries(query || {})) { + if (value === void 0 || value === null || value === "") { + continue; + } + const serialized = Array.isArray(value) ? value.join(",") : String(value); + parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(serialized)}`); + } + return parts.length > 0 ? `?${parts.join("&")}` : ""; +} async function fetchText(url, options = {}) { const response = await fetch(url, __spreadProps(__spreadValues({}, options), { headers: mergeHeaders(options.headers) @@ -518,42 +481,69 @@ async function fetchJson(url, options = {}) { const raw = await fetchText(url, options); return JSON.parse(raw); } -async function secureGet(path, query = {}) { - const envelope = encodeBase64Url(JSON.stringify({ - path, - method: "GET", - query, - body: null - })); - const url = `${BASE_URL}/api/secure/pipe?e=${envelope}`; +async function apiGet(path, query = {}) { + const url = `${BASE_URL}/api/v1/${path.replace(/^\/+/, "")}${buildQuery(query)}`; let response; - for (let attempt = 0; attempt < 2; attempt++) { - response = await fetch(url, { headers: HEADERS }); - if (response.ok || response.status !== 403 && response.status !== 429 && response.status < 500) { + let lastError; + for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { + try { + response = await fetch(url, { headers: HEADERS }); + lastError = void 0; + } catch (error) { + response = void 0; + lastError = error; + } + if (response && !RETRY_STATUSES.has(response.status)) { break; } + if (attempt < MAX_ATTEMPTS - 1) { + await new Promise((resolve) => setTimeout(resolve, 400 * (attempt + 1))); + } } - if (!response || !response.ok) { - throw new Error(`Miruro API returned HTTP ${response ? response.status : "unknown"}`); + if (!response) { + throw lastError || new Error(`Miruro API request failed for ${url}`); } - const raw = await response.text(); - const version = response.headers && response.headers.get ? response.headers.get("x-obfuscated") : null; - return decodeSecurePayload(raw.trim(), version); + if (!response.ok) { + const error = new Error(`Miruro API returned HTTP ${response.status}`); + error.status = response.status; + throw error; + } + const contentType = String( + response.headers && response.headers.get ? response.headers.get("content-type") || "" : "" + ).toLowerCase(); + if (contentType.includes("application/octet-stream")) { + return decodeCatalogPayload(new Uint8Array(await response.arrayBuffer())); + } + const raw = (await response.text()).trim(); + return raw ? JSON.parse(raw) : null; } // src/miruro/extractor.js var ANIMAP_URL = "https://animap.id/api/map"; var ANILIST_URL = "https://graphql.anilist.co"; -var HLS_QUALITY_TIMEOUT_MS = 4e3; -var PROVIDERS = [ - { id: "bonk", label: "Bonk", subCategory: "ssub" }, - { id: "kiwi", label: "Kiwi", subCategory: "sub" }, - { id: "hop", label: "Hop", subCategory: "ssub" }, - { id: "ally", label: "Ally", subCategory: "sub" }, - { id: "pewe", label: "Pewe", subCategory: "sub" }, - { id: "bee", label: "Bee", subCategory: "ssub" }, - { id: "moo", label: "Moo", subCategory: "sub" } -]; +var SEARCH_LIMIT = 15; +var MAX_SEARCH_QUERIES = 5; +var PROVIDER_LABELS = { + animepahe: "AnimePahe", + anikoto: "Anikoto", + aniwaves: "Aniwaves", + icarus: "Icarus", + kickassanime: "KickAssAnime" +}; +var anilistCache = /* @__PURE__ */ new Map(); +var episodeCache = /* @__PURE__ */ new Map(); +var showCache = /* @__PURE__ */ new Map(); +function cacheGet(cache, key) { + return cache.has(key) ? cache.get(key) : void 0; +} +function cacheSet(cache, key, value) { + 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; @@ -584,8 +574,10 @@ function recordMatchesIdentifier(record, identifier, mediaType) { } return asArray(record.tmdb_id).some((mapping) => String(mapping.id) === identifier.value && mapping.type === expectedType); } -function getAniListIds(records, identifier, mediaType) { +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)) { @@ -597,36 +589,21 @@ function getAniListIds(records, identifier, mediaType) { 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; + return { ids, titles, synonyms }; } -function getLocalEpisode(mappings, mediaType, season, episode) { - if (!mappings) { - return null; +async function getAniListMeta(anilistId) { + if (anilistCache.has(anilistId)) { + return anilistCache.get(anilistId); } - if (mediaType === "movie") { - return 1; - } - const requestedSeason = asNumber(season) || 1; - const requestedEpisode = asNumber(episode); - if (!requestedEpisode || requestedEpisode < 1) { - return null; - } - const mappedSeason = mappings.tmdbSeason || mappings.defaultTvdbSeason; - if (mappedSeason === "absolute") { - return requestedSeason === 1 ? requestedEpisode : null; - } - if (mappedSeason != null && String(mappedSeason) !== String(requestedSeason)) { - return null; - } - const offset = asNumber(mappings.tmdbOffset) || asNumber(mappings.episodeOffset) || 0; - const episodeCount = asNumber(mappings.episodes); - if (requestedEpisode <= offset || episodeCount && requestedEpisode > offset + episodeCount) { - return null; - } - return requestedEpisode - offset; -} -async function getAniListSequel(anilistId) { const response = await fetchJson(ANILIST_URL, { method: "POST", headers: { @@ -637,6 +614,12 @@ async function getAniListSequel(anilistId) { query: ` query ($id: Int!) { Media(id: $id, type: ANIME) { + title { + romaji + english + native + } + synonyms relations { edges { relationType @@ -653,26 +636,279 @@ async function getAniListSequel(anilistId) { variables: { id: anilistId } }) }); - const edges = response && response.data && response.data.Media && response.data.Media.relations ? response.data.Media.relations.edges : []; - const sequel = (edges || []).find((edge) => edge.relationType === "SEQUEL" && edge.node && edge.node.type === "ANIME" && ["TV", "TV_SHORT", "ONA"].includes(edge.node.format)); - return sequel ? asNumber(sequel.node.id) : null; + 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 : []; + 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 sequelId = await getAniListSequel(currentId); - if (!sequelId || sequelId === currentId) { + const meta = await getAniListMeta(currentId); + if (!meta.sequelId || meta.sequelId === currentId) { return null; } - currentId = sequelId; + currentId = meta.sequelId; } return currentId; } -function getSequelEpisode(payload, mediaType, season, episode) { - return getLocalEpisode(payload.mappings, mediaType, season, episode) || getLocalEpisode(payload.mappings, mediaType, 1, episode); +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 []; } -async function resolveEpisodePayload(mediaId, mediaType, season, episode) { +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) { + 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 = /* @__PURE__ */ 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}`); + 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: 1e4, + 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)); + const seasonEpisodes = []; + for (const anime of seasonCandidates) { + const episodes = anime.id === seed.id ? seedEpisodes : await listEpisodes(anime.id); + for (const item of episodes) { + seasonEpisodes.push({ anime, episode: item }); + } + } + const fromCluster = seasonEpisodes[requestedEpisode - 1]; + if (fromCluster) { + return fromCluster; + } + 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 }; + } + 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) { + var _a2; + const raw = stream && ((_a2 = stream.quality) != null ? _a2 : stream.resolution); + const height = raw && typeof raw === "object" ? raw.height : raw; + const match = String(height != null ? height : "").match(/(\d{3,4})/); + if (match) { + return `${match[1]}p`; + } + return /4k/i.test(String(height != null ? 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 = /* @__PURE__ */ 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" ? " \xB7 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; + 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 \xB7 ${label} \xB7 ${language}`, + title: `[${language}] ${label} \xB7 ${serverName} \xB7 ${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; @@ -687,182 +923,66 @@ async function resolveEpisodePayload(mediaId, mediaType, season, episode) { console.warn(`[Miruro] No anime mapping for ${mediaId}: ${error.message}`); return null; } - const aniListIds = getAniListIds(mappingRecords, identifier, mediaType); - if (aniListIds.length === 0) { + const mapping = getMapping(mappingRecords, identifier, mediaType); + if (mapping.ids.length === 0) { return null; } - if (aniListIds.length === 1) { - const anilistId = aniListIds[0]; - if (mediaType === "tv" && (asNumber(season) || 1) > 1) { - try { - const seasonAniListId = await getSeasonAniListId(anilistId, season); - if (seasonAniListId && seasonAniListId !== anilistId) { - const payload2 = await secureGet("episodes", { anilistId: seasonAniListId }); - const localEpisode2 = getSequelEpisode(payload2, mediaType, season, episode); - if (localEpisode2) { - return { anilistId: seasonAniListId, localEpisode: localEpisode2, payload: payload2 }; - } - } - } catch (error) { - console.warn(`[Miruro] Season ${season} mapping fallback failed: ${error.message}`); - } - } - const payload = await secureGet("episodes", { anilistId }); - const localEpisode = getLocalEpisode(payload.mappings, mediaType, season, episode); - return localEpisode ? { anilistId, localEpisode, payload } : null; - } - for (const anilistId of aniListIds) { + const requestedSeason = asNumber(season) || 1; + let targetId = mapping.ids[0]; + if (mediaType === "tv" && requestedSeason > 1) { try { - const info = await secureGet(`info/${anilistId}`); - const localEpisode = getLocalEpisode(info.mappings, mediaType, season, episode); - if (localEpisode) { - const payload = await secureGet("episodes", { anilistId }); - return { anilistId, localEpisode, payload }; + 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] Skipping AniList ${anilistId}: ${error.message}`); + console.warn(`[Miruro] Season ${requestedSeason} mapping failed: ${error.message}`); + if (mapping.ids[requestedSeason - 1]) { + targetId = mapping.ids[requestedSeason - 1]; + } } } - return null; -} -function findEpisode(providerPayload, variant, episodeNumber) { - const episodes = providerPayload && providerPayload.episodes ? providerPayload.episodes[variant] : null; - if (!Array.isArray(episodes)) { + 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; } - return episodes.find((item) => Number(item.number) === Number(episodeNumber)) || null; -} -function getOrigin(url) { - const match = typeof url === "string" ? url.match(/^(https?:\/\/[^/]+)/i) : null; - return match ? match[1] : BASE_URL; -} -function getDeclaredQuality(stream) { - const value = `${stream.quality || ""} ${stream.resolution || ""} ${stream.server || ""}`; - const resolution = value.match(/(2160|1440|1080|720|480|360)p?/i); - if (resolution) { - return `${resolution[1]}p`; - } - return /4k/i.test(value) ? "4K" : null; -} -function getHlsQuality(manifest) { - const heights = []; - const pattern = /\bRESOLUTION\s*=\s*\d+\s*x\s*(\d+)\b/gi; - let match; - while ((match = pattern.exec(manifest)) !== null) { - const height = Number(match[1]); - if (height > 0) { - heights.push(height); + 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; } } - return heights.length > 0 ? `${Math.max(...heights)}p` : null; -} -function isHlsStream(stream) { - const type = String(stream && stream.type || "").toLowerCase(); - return type === "hls" || type === "m3u8" || type === "application/x-mpegurl"; -} -async function resolveQuality(stream, headers) { - const declaredQuality = getDeclaredQuality(stream); - if (declaredQuality) { - return declaredQuality; - } - if (isHlsStream(stream)) { - let timeoutId; - try { - const manifest = await Promise.race([ - fetchText(stream.url, { headers }), - new Promise((resolve) => { - timeoutId = setTimeout(() => resolve(null), HLS_QUALITY_TIMEOUT_MS); - }) - ]); - const hlsQuality = typeof manifest === "string" ? getHlsQuality(manifest) : null; - if (hlsQuality) { - return hlsQuality; - } - } catch (_) { - } finally { - if (timeoutId != null) { - clearTimeout(timeoutId); - } - } - } - return "HD"; -} -function isPlayableStream(stream) { - const type = String(stream && stream.type || "").toLowerCase(); - return !!(stream && stream.url) && (type === "hls" || type === "m3u8" || type === "mp4" || type === "application/x-mpegurl"); -} -async function normalizeStreams(sourcePayload, provider, variant) { - const subtitleCount = Array.isArray(sourcePayload.subtitles) ? sourcePayload.subtitles.length : 0; - const language = variant === "dub" ? "DUB" : "SUB"; - return Promise.all((sourcePayload.streams || []).filter(isPlayableStream).map(async (stream) => { - const streamHeaders = stream.headers && typeof stream.headers === "object" ? stream.headers : {}; - const referer = stream.referer || streamHeaders.Referer || streamHeaders.referer || BASE_URL; - const subtitleLabel = subtitleCount > 0 && variant !== "dub" ? " \xB7 CC" : ""; - const headers = __spreadProps(__spreadValues({}, streamHeaders), { - "Referer": referer, - "Origin": getOrigin(referer), - "User-Agent": HEADERS["User-Agent"] - }); - const quality = await resolveQuality(stream, headers); - const server = !stream.server || /^(?:HD|auto)$/i.test(stream.server) ? quality : stream.server; - return { - // Nuvio's source picker prominently displays `name`, so include the - // Miruro server and audio variant here instead of only in `title`. - name: `Miruro \xB7 ${provider.label} \xB7 ${language}`, - title: `[${language}] ${provider.label} \xB7 ${server}${subtitleLabel}`, - url: stream.url, - quality, - headers - }; - })); -} -async function getProviderStreams(provider, providerPayload, anilistId, episodeNumber) { - const requests = []; - const subEpisode = findEpisode(providerPayload, "sub", episodeNumber); - const dubEpisode = findEpisode(providerPayload, "dub", episodeNumber); - if (subEpisode) { - requests.push({ episode: subEpisode, variant: "sub", category: provider.subCategory }); - } - if (dubEpisode) { - requests.push({ episode: dubEpisode, variant: "dub", category: "dub" }); - } - const results = await Promise.all(requests.map(async (request) => { - try { - const sources = await secureGet("sources", { - episodeId: request.episode.id, - provider: provider.id, - category: request.category, - anilistId - }); - return normalizeStreams(sources, provider, request.variant); - } catch (error) { - console.warn(`[Miruro] ${provider.label} ${request.variant} failed: ${error.message}`); - return []; - } - })); - return results.flat(); + const playback = await getPlayback(selection.anime.id, selection.episode.episode_number); + return __spreadProps(__spreadValues({}, selection), { playback }); } async function extractStreams(tmdbId, mediaType, season, episode) { if (!tmdbId || mediaType !== "movie" && mediaType !== "tv") { return []; } - const resolved = await resolveEpisodePayload(tmdbId, mediaType, season, episode); - if (!resolved || !resolved.payload || !resolved.payload.providers) { + const resolved = await resolveAnime(tmdbId, mediaType, season, episode); + if (!resolved || !resolved.playback) { return []; } - const providerResults = await Promise.all(PROVIDERS.map((provider) => { - const payload = resolved.payload.providers[provider.id]; - return payload ? getProviderStreams(provider, payload, resolved.anilistId, resolved.localEpisode) : []; - })); - const streams = providerResults.flat(); - const seen = /* @__PURE__ */ new Set(); - return streams.filter((stream) => { - if (seen.has(stream.url)) { - return false; - } - seen.add(stream.url); - return true; - }); + return normalizePlayback(resolved.playback); } // src/miruro/index.js diff --git a/src/miruro/extractor.js b/src/miruro/extractor.js index 871e2bc..c74769e 100644 --- a/src/miruro/extractor.js +++ b/src/miruro/extractor.js @@ -1,23 +1,51 @@ /** - * Resolve TMDB media to AniList, then retrieve Miruro episode sources. + * 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 { BASE_URL, fetchJson, fetchText, HEADERS, secureGet } from './http.js'; +import { HEADERS, apiGet, fetchJson } from './http.js'; const ANIMAP_URL = 'https://animap.id/api/map'; const ANILIST_URL = 'https://graphql.anilist.co'; -const HLS_QUALITY_TIMEOUT_MS = 4000; +const SEARCH_LIMIT = 15; +const MAX_SEARCH_QUERIES = 5; -// Miruro's visible native-player providers and their preferred subtitle mode. -const PROVIDERS = [ - { id: 'bonk', label: 'Bonk', subCategory: 'ssub' }, - { id: 'kiwi', label: 'Kiwi', subCategory: 'sub' }, - { id: 'hop', label: 'Hop', subCategory: 'ssub' }, - { id: 'ally', label: 'Ally', subCategory: 'sub' }, - { id: 'pewe', label: 'Pewe', subCategory: 'sub' }, - { id: 'bee', label: 'Bee', subCategory: 'ssub' }, - { id: 'moo', label: 'Moo', subCategory: 'sub' } -]; +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); @@ -66,8 +94,10 @@ function recordMatchesIdentifier(record, identifier, mediaType) { )); } -function getAniListIds(records, identifier, mediaType) { +function getMapping(records, identifier, mediaType) { const ids = []; + const titles = []; + const synonyms = []; const normalizedRecords = Array.isArray(records) ? records : records && typeof records === 'object' @@ -86,50 +116,26 @@ function getAniListIds(records, identifier, mediaType) { 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; + return { ids, titles, synonyms }; } -function getLocalEpisode(mappings, mediaType, season, episode) { - if (!mappings) { - return null; +async function getAniListMeta(anilistId) { + if (anilistCache.has(anilistId)) { + return anilistCache.get(anilistId); } - if (mediaType === 'movie') { - return 1; - } - - const requestedSeason = asNumber(season) || 1; - const requestedEpisode = asNumber(episode); - - if (!requestedEpisode || requestedEpisode < 1) { - return null; - } - - const mappedSeason = mappings.tmdbSeason || mappings.defaultTvdbSeason; - - if (mappedSeason === 'absolute') { - // Absolute mappings cannot reliably translate later TMDB seasons without - // season lengths. Season one still maps directly. - return requestedSeason === 1 ? requestedEpisode : null; - } - - if (mappedSeason != null && String(mappedSeason) !== String(requestedSeason)) { - return null; - } - - const offset = asNumber(mappings.tmdbOffset) || asNumber(mappings.episodeOffset) || 0; - const episodeCount = asNumber(mappings.episodes); - - if (requestedEpisode <= offset || (episodeCount && requestedEpisode > offset + episodeCount)) { - return null; - } - - return requestedEpisode - offset; -} - -async function getAniListSequel(anilistId) { const response = await fetchJson(ANILIST_URL, { method: 'POST', headers: { @@ -140,6 +146,12 @@ async function getAniListSequel(anilistId) { query: ` query ($id: Int!) { Media(id: $id, type: ANIME) { + title { + romaji + english + native + } + synonyms relations { edges { relationType @@ -156,17 +168,27 @@ async function getAniListSequel(anilistId) { variables: { id: anilistId } }) }); - const edges = response && response.data && response.data.Media && response.data.Media.relations - ? response.data.Media.relations.edges + + 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 : []; - const sequel = (edges || []).find((edge) => ( + // 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 + }; - return sequel ? asNumber(sequel.node.id) : null; + anilistCache.set(anilistId, meta); + return meta; } async function getSeasonAniListId(anilistId, season) { @@ -174,28 +196,382 @@ async function getSeasonAniListId(anilistId, season) { let currentId = anilistId; for (let currentSeason = 1; currentSeason < requestedSeason; currentSeason++) { - const sequelId = await getAniListSequel(currentId); + const meta = await getAniListMeta(currentId); - if (!sequelId || sequelId === currentId) { + if (!meta.sequelId || meta.sequelId === currentId) { return null; } - currentId = sequelId; + currentId = meta.sequelId; } return currentId; } -function getSequelEpisode(payload, mediaType, season, episode) { - // A sequel has its own AniList episode list, so its first episode is local - // episode 1 even when the caller identifies it as TMDB/IMDb season 2+. Keep - // an explicit TMDB season mapping when Miruro provides one, then fall back - // to numbering relative to the selected AniList entry. - return getLocalEpisode(payload.mappings, mediaType, season, episode) || - getLocalEpisode(payload.mappings, mediaType, 1, episode); +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 []; } -async function resolveEpisodePayload(mediaId, mediaType, season, episode) { +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 }; + } + + 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) { @@ -214,217 +590,72 @@ async function resolveEpisodePayload(mediaId, mediaType, season, episode) { return null; } - const aniListIds = getAniListIds(mappingRecords, identifier, mediaType); + const mapping = getMapping(mappingRecords, identifier, mediaType); - if (aniListIds.length === 0) { + if (mapping.ids.length === 0) { return null; } - // A single mapping can be validated using the episode response itself. - if (aniListIds.length === 1) { - const anilistId = aniListIds[0]; + const requestedSeason = asNumber(season) || 1; + let targetId = mapping.ids[0]; - if (mediaType === 'tv' && (asNumber(season) || 1) > 1) { - try { - const seasonAniListId = await getSeasonAniListId(anilistId, season); - - if (seasonAniListId && seasonAniListId !== anilistId) { - const payload = await secureGet('episodes', { anilistId: seasonAniListId }); - const localEpisode = getSequelEpisode(payload, mediaType, season, episode); - - if (localEpisode) { - return { anilistId: seasonAniListId, localEpisode, payload }; - } - } - } catch (error) { - // AniList is a fallback for mappings that only identify season - // one. If it is unavailable, retain Miruro's normal mapping. - console.warn(`[Miruro] Season ${season} mapping fallback failed: ${error.message}`); - } - } - - const payload = await secureGet('episodes', { anilistId }); - const localEpisode = getLocalEpisode(payload.mappings, mediaType, season, episode); - - return localEpisode ? { anilistId, localEpisode, payload } : null; - } - - // Franchises can map multiple AniList cours to one TMDB show. Miruro's info - // metadata identifies both the TMDB season and the cour's episode offset. - for (const anilistId of aniListIds) { + if (mediaType === 'tv' && requestedSeason > 1) { try { - const info = await secureGet(`info/${anilistId}`); - const localEpisode = getLocalEpisode(info.mappings, mediaType, season, episode); + const seasonAniListId = await getSeasonAniListId(mapping.ids[0], requestedSeason); - if (localEpisode) { - const payload = await secureGet('episodes', { anilistId }); - return { anilistId, localEpisode, payload }; + if (seasonAniListId) { + targetId = seasonAniListId; + } else if (mapping.ids[requestedSeason - 1]) { + targetId = mapping.ids[requestedSeason - 1]; } } catch (error) { - console.warn(`[Miruro] Skipping AniList ${anilistId}: ${error.message}`); + console.warn(`[Miruro] Season ${requestedSeason} mapping failed: ${error.message}`); + + if (mapping.ids[requestedSeason - 1]) { + targetId = mapping.ids[requestedSeason - 1]; + } } } - return null; -} + let targetMeta = null; -function findEpisode(providerPayload, variant, episodeNumber) { - const episodes = providerPayload && providerPayload.episodes - ? providerPayload.episodes[variant] - : null; + try { + targetMeta = await getAniListMeta(targetId); + } catch (error) { + console.warn(`[Miruro] AniList metadata for ${targetId} failed: ${error.message}`); + } - if (!Array.isArray(episodes)) { + 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; } - return episodes.find((item) => Number(item.number) === Number(episodeNumber)) || null; -} + let selection; -function getOrigin(url) { - const match = typeof url === 'string' ? url.match(/^(https?:\/\/[^/]+)/i) : null; - return match ? match[1] : BASE_URL; -} + if (mediaType === 'movie') { + const episodeItem = selectMovieEpisode(await listEpisodes(seed.id)); -function getDeclaredQuality(stream) { - const value = `${stream.quality || ''} ${stream.resolution || ''} ${stream.server || ''}`; - const resolution = value.match(/(2160|1440|1080|720|480|360)p?/i); + if (!episodeItem) { + console.warn(`[Miruro] No playable episode for AniList ${targetId}`); + return null; + } - if (resolution) { - return `${resolution[1]}p`; - } + selection = { anime: seed, episode: episodeItem }; + } else { + selection = await resolveSeasonEpisode(seed, mapping, identifier, requestedSeason, episode); - return /4k/i.test(value) ? '4K' : null; -} - -function getHlsQuality(manifest) { - const heights = []; - const pattern = /\bRESOLUTION\s*=\s*\d+\s*x\s*(\d+)\b/gi; - let match; - - while ((match = pattern.exec(manifest)) !== null) { - const height = Number(match[1]); - - if (height > 0) { - heights.push(height); + if (!selection) { + console.warn(`[Miruro] Episode ${episode} not found for AniList ${targetId}`); + return null; } } - return heights.length > 0 ? `${Math.max(...heights)}p` : null; -} + const playback = await getPlayback(selection.anime.id, selection.episode.episode_number); -function isHlsStream(stream) { - const type = String(stream && stream.type || '').toLowerCase(); - return type === 'hls' || type === 'm3u8' || type === 'application/x-mpegurl'; -} - -async function resolveQuality(stream, headers) { - const declaredQuality = getDeclaredQuality(stream); - - if (declaredQuality) { - return declaredQuality; - } - - if (isHlsStream(stream)) { - let timeoutId; - - try { - const manifest = await Promise.race([ - fetchText(stream.url, { headers }), - new Promise((resolve) => { - timeoutId = setTimeout(() => resolve(null), HLS_QUALITY_TIMEOUT_MS); - }) - ]); - const hlsQuality = typeof manifest === 'string' ? getHlsQuality(manifest) : null; - - if (hlsQuality) { - return hlsQuality; - } - } catch (_) { - // A stream can still be playable when its manifest rejects this - // metadata request, so retain it with an unknown HD quality. - } finally { - if (timeoutId != null) { - clearTimeout(timeoutId); - } - } - } - - return 'HD'; -} - -function isPlayableStream(stream) { - const type = String(stream && stream.type || '').toLowerCase(); - return !!(stream && stream.url) && ( - type === 'hls' || - type === 'm3u8' || - type === 'mp4' || - type === 'application/x-mpegurl' - ); -} - -async function normalizeStreams(sourcePayload, provider, variant) { - const subtitleCount = Array.isArray(sourcePayload.subtitles) ? sourcePayload.subtitles.length : 0; - const language = variant === 'dub' ? 'DUB' : 'SUB'; - - return Promise.all((sourcePayload.streams || []).filter(isPlayableStream).map(async (stream) => { - const streamHeaders = stream.headers && typeof stream.headers === 'object' - ? stream.headers - : {}; - const referer = stream.referer || streamHeaders.Referer || streamHeaders.referer || BASE_URL; - const subtitleLabel = subtitleCount > 0 && variant !== 'dub' ? ' · CC' : ''; - const headers = { - ...streamHeaders, - 'Referer': referer, - 'Origin': getOrigin(referer), - 'User-Agent': HEADERS['User-Agent'] - }; - const quality = await resolveQuality(stream, headers); - const server = !stream.server || /^(?:HD|auto)$/i.test(stream.server) - ? quality - : stream.server; - - return { - // Nuvio's source picker prominently displays `name`, so include the - // Miruro server and audio variant here instead of only in `title`. - name: `Miruro · ${provider.label} · ${language}`, - title: `[${language}] ${provider.label} · ${server}${subtitleLabel}`, - url: stream.url, - quality, - headers - }; - })); -} - -async function getProviderStreams(provider, providerPayload, anilistId, episodeNumber) { - const requests = []; - const subEpisode = findEpisode(providerPayload, 'sub', episodeNumber); - const dubEpisode = findEpisode(providerPayload, 'dub', episodeNumber); - - if (subEpisode) { - requests.push({ episode: subEpisode, variant: 'sub', category: provider.subCategory }); - } - - if (dubEpisode) { - requests.push({ episode: dubEpisode, variant: 'dub', category: 'dub' }); - } - - const results = await Promise.all(requests.map(async (request) => { - try { - const sources = await secureGet('sources', { - episodeId: request.episode.id, - provider: provider.id, - category: request.category, - anilistId - }); - - return normalizeStreams(sources, provider, request.variant); - } catch (error) { - console.warn(`[Miruro] ${provider.label} ${request.variant} failed: ${error.message}`); - return []; - } - })); - - return results.flat(); + return { ...selection, playback }; } export async function extractStreams(tmdbId, mediaType, season, episode) { @@ -432,27 +663,11 @@ export async function extractStreams(tmdbId, mediaType, season, episode) { return []; } - const resolved = await resolveEpisodePayload(tmdbId, mediaType, season, episode); + const resolved = await resolveAnime(tmdbId, mediaType, season, episode); - if (!resolved || !resolved.payload || !resolved.payload.providers) { + if (!resolved || !resolved.playback) { return []; } - const providerResults = await Promise.all(PROVIDERS.map((provider) => { - const payload = resolved.payload.providers[provider.id]; - return payload - ? getProviderStreams(provider, payload, resolved.anilistId, resolved.localEpisode) - : []; - })); - const streams = providerResults.flat(); - const seen = new Set(); - - return streams.filter((stream) => { - if (seen.has(stream.url)) { - return false; - } - - seen.add(stream.url); - return true; - }); + return normalizePlayback(resolved.playback); } diff --git a/src/miruro/http.js b/src/miruro/http.js index 1617eba..58782f0 100644 --- a/src/miruro/http.js +++ b/src/miruro/http.js @@ -1,13 +1,18 @@ /** - * HTTP helpers for Miruro's public read API. + * HTTP helpers for Miruro's public catalog API. + * + * Miruro's current web app talks to a same-origin /api/v1 catalog. Successful + * responses are either plain JSON or, depending on the endpoint, gzip data + * obfuscated with a fixed XOR key and served as application/octet-stream. */ import { gunzipSync, strFromU8 } from 'fflate'; -export const BASE_URL = 'https://www.miruro.to'; +export const BASE_URL = 'https://www.miruro.cx'; export const HEADERS = { 'Accept': '*/*', + 'Accept-Language': 'en-US,en;q=0.9', 'Referer': `${BASE_URL}/`, 'User-Agent': 'Mozilla/5.0 (Linux; Android 15) AppleWebKit/537.36 Chrome/140.0.0.0 Mobile Safari/537.36', 'Sec-Fetch-Dest': 'empty', @@ -15,77 +20,18 @@ export const HEADERS = { 'Sec-Fetch-Site': 'same-origin' }; -const PIPE_OBFUSCATION_KEY = hexToBytes('71951034f8fbcf53d89db52ceb3dc22c'); -const BASE64_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; +// Miruro's catalog responses XOR the gzip stream with this key. Keep it as a +// string so the bundle does not depend on TextEncoder being available. +const CATALOG_OBFUSCATION_KEY = 'miruro/catalog'; -function hexToBytes(value) { - const bytes = new Uint8Array(value.length / 2); +// Miruro occasionally answers bursts with a generic 400 ("Unsupported catalog +// request") instead of 429, so treat it as retryable too. +const RETRY_STATUSES = new Set([400, 403, 408, 429, 500, 502, 503, 504]); +const MAX_ATTEMPTS = 3; +function decodeCatalogPayload(bytes) { for (let index = 0; index < bytes.length; index++) { - bytes[index] = parseInt(value.slice(index * 2, index * 2 + 2), 16); - } - - return bytes; -} - -function encodeBase64Url(value) { - let result = ''; - - for (let index = 0; index < value.length; index += 3) { - const first = value.charCodeAt(index); - const hasSecond = index + 1 < value.length; - const hasThird = index + 2 < value.length; - const second = hasSecond ? value.charCodeAt(index + 1) : 0; - const third = hasThird ? value.charCodeAt(index + 2) : 0; - const chunk = (first << 16) | (second << 8) | third; - - result += BASE64_ALPHABET[(chunk >> 18) & 63]; - result += BASE64_ALPHABET[(chunk >> 12) & 63]; - result += hasSecond ? BASE64_ALPHABET[(chunk >> 6) & 63] : '='; - result += hasThird ? BASE64_ALPHABET[chunk & 63] : '='; - } - - return result.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); -} - -function decodeBase64Url(value) { - const normalized = value.replace(/-/g, '+').replace(/_/g, '/').replace(/\s/g, ''); - const outputLength = Math.floor(normalized.length * 6 / 8); - const output = new Uint8Array(outputLength); - let buffer = 0; - let bits = 0; - let outputIndex = 0; - - for (let index = 0; index < normalized.length; index++) { - const code = BASE64_ALPHABET.indexOf(normalized[index]); - - if (code < 0) { - continue; - } - - buffer = (buffer << 6) | code; - bits += 6; - - if (bits >= 8) { - bits -= 8; - output[outputIndex++] = (buffer >> bits) & 255; - } - } - - return outputIndex === output.length ? output : output.slice(0, outputIndex); -} - -function decodeSecurePayload(raw, obfuscationVersion) { - if (!obfuscationVersion) { - return JSON.parse(raw); - } - - const bytes = decodeBase64Url(raw); - - if (obfuscationVersion === '2') { - for (let index = 0; index < bytes.length; index++) { - bytes[index] ^= PIPE_OBFUSCATION_KEY[index % PIPE_OBFUSCATION_KEY.length]; - } + bytes[index] ^= CATALOG_OBFUSCATION_KEY.charCodeAt(index % CATALOG_OBFUSCATION_KEY.length); } return JSON.parse(strFromU8(gunzipSync(bytes))); @@ -98,6 +44,21 @@ function mergeHeaders(headers) { }; } +function buildQuery(query) { + const parts = []; + + for (const [key, value] of Object.entries(query || {})) { + if (value === undefined || value === null || value === '') { + continue; + } + + const serialized = Array.isArray(value) ? value.join(',') : String(value); + parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(serialized)}`); + } + + return parts.length > 0 ? `?${parts.join('&')}` : ''; +} + /** Fetch text and throw a useful error for non-success responses. */ export async function fetchText(url, options = {}) { const response = await fetch(url, { @@ -119,35 +80,52 @@ export async function fetchJson(url, options = {}) { } /** - * Perform the compact, read-only request used by Miruro's secure API. Miruro - * returns base64url-encoded gzip JSON, optionally XOR-obfuscated. + * Perform a read-only request against Miruro's catalog API. + * + * @param {string} path Path below /api/v1 (for example "anime"). + * @param {Record} [query] */ -export async function secureGet(path, query = {}) { - const envelope = encodeBase64Url(JSON.stringify({ - path, - method: 'GET', - query, - body: null - })); - const url = `${BASE_URL}/api/secure/pipe?e=${envelope}`; +export async function apiGet(path, query = {}) { + const url = `${BASE_URL}/api/v1/${path.replace(/^\/+/, '')}${buildQuery(query)}`; let response; + let lastError; - for (let attempt = 0; attempt < 2; attempt++) { - response = await fetch(url, { headers: HEADERS }); + for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { + try { + response = await fetch(url, { headers: HEADERS }); + lastError = undefined; + } catch (error) { + response = undefined; + lastError = error; + } - if (response.ok || (response.status !== 403 && response.status !== 429 && response.status < 500)) { + if (response && !RETRY_STATUSES.has(response.status)) { break; } + + if (attempt < MAX_ATTEMPTS - 1) { + await new Promise((resolve) => setTimeout(resolve, 400 * (attempt + 1))); + } } - if (!response || !response.ok) { - throw new Error(`Miruro API returned HTTP ${response ? response.status : 'unknown'}`); + if (!response) { + throw lastError || new Error(`Miruro API request failed for ${url}`); } - const raw = await response.text(); - const version = response.headers && response.headers.get - ? response.headers.get('x-obfuscated') - : null; + if (!response.ok) { + const error = new Error(`Miruro API returned HTTP ${response.status}`); + error.status = response.status; + throw error; + } - return decodeSecurePayload(raw.trim(), version); + const contentType = String( + response.headers && response.headers.get ? response.headers.get('content-type') || '' : '' + ).toLowerCase(); + + if (contentType.includes('application/octet-stream')) { + return decodeCatalogPayload(new Uint8Array(await response.arrayBuffer())); + } + + const raw = (await response.text()).trim(); + return raw ? JSON.parse(raw) : null; }