Files
WaifuTV-Extensions/anime/anicrush/source.js
2025-12-25 21:38:38 +01:00

372 lines
12 KiB
JavaScript

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(/<title>\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;