fix miruro
This commit is contained in:
1 parent
1b8cb7f6e9
commit
8c0fd70b7b
5 files changed
+955
-641
No files matched your search
@@ -0,0 +1 @@
|
||||
node_modules
|
||||
+3
-3
@@ -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"
|
||||
|
||||
+396
-276
@@ -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
|
||||
|
||||
+487
-272
@@ -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);
|
||||
}
|
||||
+68
-90
@@ -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<string, unknown>} [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;
|
||||
}
|
||||
Reference in new issue
Block a user