class AniCrush { constructor() { this.type = "anime-board"; this.version = "1.0.0"; this.baseUrl = "https://anicrush.to"; this.apiBase = "https://api.anicrush.to"; } getSettings() { return { episodeServers: ["Southcloud-1", "Southcloud-2", "Southcloud-3"], supportsDub: true }; } _nativeFetch(url, method, headers, body) { const raw = Native.fetch(String(url), method || "GET", JSON.stringify(headers || {}), body == null ? "" : String(body)); try { return JSON.parse(raw || "{}"); } catch (e) { return { ok: false, status: 0, headers: {}, body: "" }; } } _getText(url, headers) { const res = this._nativeFetch(url, "GET", headers, ""); return String(res.body || ""); } _getJson(url, headers) { const res = this._nativeFetch(url, "GET", headers, ""); try { return JSON.parse(String(res.body || "{}")); } catch (e) { return {}; } } _postJson(url, headers, obj) { const res = this._nativeFetch(url, "POST", headers, JSON.stringify(obj || {})); try { return JSON.parse(String(res.body || "{}")); } catch (e) { return {}; } } _safeStr(v) { return typeof v === "string" ? v : (v == null ? "" : String(v)); } _normalizeTitle(t) { return this._safeStr(t) .toLowerCase() .replace(/(season|cour|part|uncensored)/g, "") .replace(/\d+(st|nd|rd|th)/g, (m) => m.replace(/st|nd|rd|th/, "")) .replace(/[^a-z0-9]+/g, ""); } _normalizeDate(dateStr) { const s = this._safeStr(dateStr); if (!s) return null; const months = { Jan: "01", Feb: "02", Mar: "03", Apr: "04", May: "05", Jun: "06", Jul: "07", Aug: "08", Sep: "09", Oct: "10", Nov: "11", Dec: "12" }; const m = s.match(/([A-Za-z]+)\s+\d{1,2},\s*(\d{4})/); if (!m) return null; const mm = months[m[1]]; if (!mm) return null; return { year: parseInt(m[2], 10), month: parseInt(mm, 10) }; } _levSim(a, b) { a = this._safeStr(a); b = this._safeStr(b); const lenA = a.length; const lenB = b.length; if (!lenA && !lenB) return 1; if (!lenA || !lenB) return 0; const dp = []; for (let i = 0; i <= lenA; i++) { dp[i] = []; dp[i][0] = i; } for (let j = 0; j <= lenB; j++) dp[0][j] = j; for (let i = 1; i <= lenA; i++) { for (let j = 1; j <= lenB; j++) { if (a[i - 1] === b[j - 1]) dp[i][j] = dp[i - 1][j - 1]; else dp[i][j] = 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]); } } const dist = dp[lenA][lenB]; const maxLen = Math.max(lenA, lenB); return 1 - dist / maxLen; } _headers() { return { "User-Agent": "Mozilla/5.0", "Accept": "application/json", "Referer": this.baseUrl + "/", "Origin": this.baseUrl, "X-Site": "anicrush" }; } _parseQuery(q) { if (typeof q === "string") { const s = q.trim(); if (s.startsWith("{") || s.startsWith("[")) { try { return JSON.parse(s); } catch (e) { return { query: s }; } } return { query: s }; } return q || {}; } search(query) { query = this._parseQuery(query); const q = this._safeStr(query.query).trim(); if (!q) return []; const media = query.media || {}; const start = (media.startDate || {}); const wantYear = start.year || 0; const wantMonth = start.month || 0; const targetNormJP = this._normalizeTitle(media.romajiTitle); const targetNorm = media.englishTitle ? this._normalizeTitle(media.englishTitle) : targetNormJP; const url = `${this.apiBase}/shared/v2/movie/list?keyword=${encodeURIComponent(q)}&limit=48&page=1`; const json = this._getJson(url, this._headers()); const list = (((json || {}).result || {}).movies) || []; if (!Array.isArray(list) || !list.length) return []; let matches = list.map((movie) => { const id = movie && movie.id != null ? String(movie.id) : ""; const slug = movie && movie.slug ? String(movie.slug) : ""; if (!id || !slug) return null; const titleJP = movie && movie.name ? String(movie.name) : ""; const titleEN = movie && movie.name_english ? String(movie.name_english) : ""; const title = titleEN || titleJP || "Unknown"; return { id, slug, title, titleJP, normTitle: this._normalizeTitle(title), normTitleJP: this._normalizeTitle(titleJP), dub: !!(movie && movie.has_dub), startDate: this._normalizeDate(movie && movie.aired_from ? String(movie.aired_from) : "") }; }).filter(Boolean); if (query.dub) matches = matches.filter(m => m.dub); let filtered = matches; if (wantYear) { filtered = matches.filter(m => { const tMatch = (m.normTitle === targetNorm) || (m.normTitleJP === targetNormJP); const d = m.startDate || {}; const dMatch = wantMonth ? ((d.year === wantYear) && (d.month === wantMonth)) : (d.year === wantYear); return tMatch && dMatch; }); if (!filtered.length) { filtered = matches.filter(m => { const a = m.normTitle; const b = targetNorm; const aj = m.normTitleJP; const bj = targetNormJP; const fuzzy = (a && b && (a.includes(b) || b.includes(a) || this._levSim(a, b) > 0.72)) || (aj && bj && (aj.includes(bj) || bj.includes(aj) || this._levSim(aj, bj) > 0.72)); const d = m.startDate || {}; const dMatch = wantMonth ? ((d.year === wantYear) && (d.month === wantMonth)) : (d.year === wantYear); return fuzzy && dMatch; }); } } else { const qn = this._normalizeTitle(q); filtered = matches.filter(m => { const a = this._normalizeTitle(m.title); const aj = this._normalizeTitle(m.titleJP); return (a === qn) || (aj === qn) || a.includes(qn) || aj.includes(qn) || qn.includes(a) || qn.includes(aj); }); filtered.sort((x, y) => { const A = this._normalizeTitle(x.title); const B = this._normalizeTitle(y.title); if (A.length !== B.length) return A.length - B.length; return A.localeCompare(B); }); } const subOrDub = query.dub ? "dub" : "sub"; return filtered.map(m => ({ id: `${m.id}/${subOrDub}`, title: m.title, url: `${this.baseUrl}/detail/${m.slug}.${m.id}`, subOrDub })); } findEpisodes(Id) { const parts = String(Id || "").split("/"); const id = parts[0]; const subOrDub = parts[1] || "sub"; if (!id) throw new Error("Missing id"); const url = `${this.apiBase}/shared/v2/episode/list?_movieId=${encodeURIComponent(id)}`; const epJson = this._getJson(url, this._headers()); const groups = (epJson && epJson.result) ? epJson.result : {}; const episodes = []; const keys = Object.keys(groups || {}); for (let i = 0; i < keys.length; i++) { const group = groups[keys[i]]; if (!Array.isArray(group)) continue; for (let j = 0; j < group.length; j++) { const ep = group[j] || {}; const num = Number(ep.number); if (!Number.isFinite(num)) continue; episodes.push({ id: `${id}/${subOrDub}`, number: num, title: ep.name_english ? String(ep.name_english) : (ep.name ? String(ep.name) : `Episode ${num}`), url: "" }); } } episodes.sort((a, b) => (a.number || 0) - (b.number || 0)); return episodes; } findEpisodeServer(episodeOrId, _server) { let ep = episodeOrId; if (typeof ep === "string") { try { ep = JSON.parse(ep); } catch (e) {} } ep = ep || {}; const parts = String(ep.id || "").split("/"); const id = parts[0]; const subOrDub = parts[1] || "sub"; if (!id) throw new Error("Missing id"); const num = Number(ep.number); if (!Number.isFinite(num)) throw new Error("Missing episode number"); let server = String(_server || "").trim(); if (!server || server === "default") server = "Southcloud-1"; if (server === "HD-1") server = "Southcloud-1"; if (server === "HD-2") server = "Southcloud-2"; if (server === "HD-3") server = "Southcloud-3"; const serverMap = { "Southcloud-1": 4, "Southcloud-2": 1, "Southcloud-3": 6 }; const sv = serverMap[server] != null ? serverMap[server] : 4; const linkUrl = `${this.apiBase}/shared/v2/episode/sources?_movieId=${encodeURIComponent(id)}` + `&ep=${encodeURIComponent(String(num))}&sv=${encodeURIComponent(String(sv))}&sc=${encodeURIComponent(subOrDub)}`; // ✅ LOG: confirm SUB/DUB + final API URL used try { Native.log("AniCrush", `findEpisodeServer: sc=${subOrDub} movieId=${id} ep=${num} sv=${sv} url=${linkUrl}`); } catch (e) {} const json = this._getJson(linkUrl, this._headers()); const encryptedIframe = (((json || {}).result || {}).link) ? String(json.result.link) : ""; if (!encryptedIframe) throw new Error("Missing encrypted iframe link"); let decryptData = null; let requiredHeaders = null; try { decryptData = this.extractMegaCloudSync(encryptedIframe); requiredHeaders = decryptData && decryptData.headersProvided ? decryptData.headersProvided : null; } catch (e) {} if (!decryptData) { decryptData = this._getJson( `https://ac-api.ofchaos.com/api/anime/embed/convert/v2?embedUrl=${encodeURIComponent(encryptedIframe)}`, {} ); requiredHeaders = { Referer: "https://megacloud.club/", Origin: "https://megacloud.club", "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36", "X-Requested-With": "XMLHttpRequest" }; } if (!decryptData) throw new Error("No video sources"); const sources = decryptData.sources || []; const streamSource = sources.find((s) => s && s.type === "hls") || sources.find((s) => s && s.type === "mp4"); if (!streamSource || !streamSource.file) throw new Error("No valid stream file found"); const tracks = decryptData.tracks || []; const subtitles = tracks .filter((t) => t && t.kind === "captions" && t.file) .map((track, index) => ({ id: `sub-${index}`, language: track.label || "Unknown", url: track.file, isDefault: !!track.default })); const st = String(streamSource.type || ""); const outType = (st === "hls" || st === "m3u8") ? "m3u8" : "mp4"; return { server: server, headers: requiredHeaders || {}, videoSources: [{ url: streamSource.file, type: outType, quality: "auto", subtitles: subtitles }] }; } extractMegaCloudSync(embedUrl) { const s = String(embedUrl || ""); const mm = s.match(/^(https?):\/\/([^\/]+)(\/.*)?$/i); if (!mm) throw new Error("Invalid embedUrl"); const protocol = mm[1].toLowerCase(); const host = mm[2]; const baseDomain = `${protocol}://${host}/`; const headers = { Accept: "*/*", "X-Requested-With": "XMLHttpRequest", Referer: baseDomain, Origin: `${protocol}://${host}`, "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36" }; const html = this._getText(embedUrl, headers); const fileIdMatch = html.match(/\s*File\s+#([a-zA-Z0-9]+)\s*-/i); if (!fileIdMatch) throw new Error("file_id not found"); const fileId = fileIdMatch[1]; let nonce = null; const match48 = html.match(/\b[a-zA-Z0-9]{48}\b/); if (match48) nonce = match48[0]; else { const match3x16 = []; const re = /["']([A-Za-z0-9]{16})["']/g; let m; while ((m = re.exec(html)) !== null) match3x16.push(m[1]); if (match3x16.length >= 3) nonce = match3x16[0] + match3x16[1] + match3x16[2]; } if (!nonce) throw new Error("nonce not found"); const sourcesJson = this._getJson(`${baseDomain}embed-2/v3/e-1/getSources?id=${fileId}&_k=${nonce}`, headers); return { sources: sourcesJson.sources || [], tracks: sourcesJson.tracks || [], intro: sourcesJson.intro || null, outro: sourcesJson.outro || null, server: sourcesJson.server || null, headersProvided: headers }; } } module.exports = AniCrush;