Add modules by emp0ry
This commit is contained in:
@@ -0,0 +1,697 @@
|
|||||||
|
// AniDub module for Sora (AsyncJS)
|
||||||
|
// Author: emp0ry
|
||||||
|
// Version: 1.0.0
|
||||||
|
|
||||||
|
const BASE_URL = "https://anidub.org";
|
||||||
|
const SEARCH_URL = BASE_URL + "/index.php?do=search&subaction=search";
|
||||||
|
|
||||||
|
const PLAYLIST_API = "https://plapi.cdnvideohub.com/api/v1/player/sv/playlist";
|
||||||
|
const VIDEO_API = "https://plapi.cdnvideohub.com/api/v1/player/sv/video/";
|
||||||
|
|
||||||
|
const DEFAULT_PUB = "19";
|
||||||
|
const DEFAULT_AGGR = "mali";
|
||||||
|
const DEFAULT_SUBTITLE = "https://none.com";
|
||||||
|
|
||||||
|
function _ua() {
|
||||||
|
return "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36";
|
||||||
|
}
|
||||||
|
|
||||||
|
function _safeJsonParse(value, fallback) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(value);
|
||||||
|
} catch (_) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _htmlDecode(value) {
|
||||||
|
const s = String(value || "");
|
||||||
|
if (!s) return "";
|
||||||
|
|
||||||
|
const named = {
|
||||||
|
"&": "&",
|
||||||
|
"<": "<",
|
||||||
|
">": ">",
|
||||||
|
""": '"',
|
||||||
|
"'": "'",
|
||||||
|
"'": "'",
|
||||||
|
" ": " ",
|
||||||
|
"«": "«",
|
||||||
|
"»": "»"
|
||||||
|
};
|
||||||
|
|
||||||
|
const withNamed = s.replace(/&(amp|lt|gt|quot|#39|apos|nbsp|laquo|raquo);/g, m => named[m] || m);
|
||||||
|
|
||||||
|
const withDec = withNamed.replace(/&#(\d+);/g, (_, d) => {
|
||||||
|
const code = parseInt(d, 10);
|
||||||
|
return Number.isFinite(code) ? String.fromCharCode(code) : "";
|
||||||
|
});
|
||||||
|
|
||||||
|
return withDec.replace(/&#x([0-9a-f]+);/gi, (_, h) => {
|
||||||
|
const code = parseInt(h, 16);
|
||||||
|
return Number.isFinite(code) ? String.fromCharCode(code) : "";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function _stripTags(value) {
|
||||||
|
return _htmlDecode(String(value || "")
|
||||||
|
.replace(/<br\s*\/?>/gi, "\n")
|
||||||
|
.replace(/<\/p>/gi, "\n")
|
||||||
|
.replace(/<[^>]+>/g, " ")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function _attr(tag, name) {
|
||||||
|
const block = String(tag || "");
|
||||||
|
if (!block) return "";
|
||||||
|
|
||||||
|
const quoted = new RegExp(name + "\\s*=\\s*(['\"])(.*?)\\1", "i").exec(block);
|
||||||
|
if (quoted && quoted[2]) return _htmlDecode(quoted[2]);
|
||||||
|
|
||||||
|
const plain = new RegExp(name + "\\s*=\\s*([^\\s>]+)", "i").exec(block);
|
||||||
|
return plain && plain[1] ? _htmlDecode(plain[1]) : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function _extractMeta(html, attrName, attrValue) {
|
||||||
|
const src = String(html || "");
|
||||||
|
|
||||||
|
const re = new RegExp(
|
||||||
|
"<meta[^>]*" + attrName + "=['\"]" + attrValue + "['\"][^>]*content=['\"]([^'\"]+)['\"][^>]*>",
|
||||||
|
"i"
|
||||||
|
);
|
||||||
|
|
||||||
|
const m = src.match(re);
|
||||||
|
return m && m[1] ? _htmlDecode(m[1]).trim() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function _absUrl(url, base) {
|
||||||
|
const raw = String(url || "").trim();
|
||||||
|
if (!raw) return "";
|
||||||
|
if (raw.startsWith("http://") || raw.startsWith("https://")) return raw;
|
||||||
|
if (raw.startsWith("//")) return "https:" + raw;
|
||||||
|
|
||||||
|
const root = String(base || BASE_URL).replace(/\/+$/, "");
|
||||||
|
return root + "/" + raw.replace(/^\/+/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function _cleanUrl(url) {
|
||||||
|
const raw = String(url || "").trim();
|
||||||
|
if (!raw) return null;
|
||||||
|
if (raw.startsWith("//")) return "https:" + raw;
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _scoreTitle(title, keyword) {
|
||||||
|
const t = String(title || "").toLowerCase().trim();
|
||||||
|
const k = String(keyword || "").toLowerCase().trim();
|
||||||
|
|
||||||
|
if (!t || !k) return 99;
|
||||||
|
if (t === k) return 0;
|
||||||
|
if (t.startsWith(k)) return 1;
|
||||||
|
if (t.includes(k)) return 2;
|
||||||
|
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _voiceRank(name) {
|
||||||
|
const s = String(name || "").toLowerCase();
|
||||||
|
|
||||||
|
const order = [
|
||||||
|
"anidub online",
|
||||||
|
"anidub",
|
||||||
|
"aniliberty",
|
||||||
|
"anilibria",
|
||||||
|
"shiza",
|
||||||
|
"onwave",
|
||||||
|
"anistar",
|
||||||
|
"animevost",
|
||||||
|
"студийная банда",
|
||||||
|
"studio band",
|
||||||
|
"dream cast",
|
||||||
|
"dreamcast",
|
||||||
|
"jam club",
|
||||||
|
"jam",
|
||||||
|
"субтит",
|
||||||
|
"sub"
|
||||||
|
];
|
||||||
|
|
||||||
|
for (let i = 0; i < order.length; i++) {
|
||||||
|
if (s.includes(order[i])) return i;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 999;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _packAnime(payload) {
|
||||||
|
return "anidub-release:" + encodeURIComponent(JSON.stringify(payload || {}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function _unpackAnime(href) {
|
||||||
|
const raw = String(href || "");
|
||||||
|
if (!raw.startsWith("anidub-release:")) return null;
|
||||||
|
|
||||||
|
return _safeJsonParse(decodeURIComponent(raw.slice("anidub-release:".length)), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _packEpisode(payload) {
|
||||||
|
return "anidub:" + encodeURIComponent(JSON.stringify(payload || {}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function _unpackEpisode(href) {
|
||||||
|
const raw = String(href || "");
|
||||||
|
if (!raw.startsWith("anidub:")) return null;
|
||||||
|
|
||||||
|
return _safeJsonParse(decodeURIComponent(raw.slice("anidub:".length)), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _searchHeaders() {
|
||||||
|
return {
|
||||||
|
"User-Agent": _ua(),
|
||||||
|
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||||
|
"Accept-Language": "ru-RU,ru;q=0.9,en;q=0.8",
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
|
||||||
|
"Origin": BASE_URL,
|
||||||
|
"Referer": BASE_URL + "/"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function _htmlHeaders(referer) {
|
||||||
|
return {
|
||||||
|
"User-Agent": _ua(),
|
||||||
|
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||||
|
"Accept-Language": "ru-RU,ru;q=0.9,en;q=0.8",
|
||||||
|
"Referer": referer || BASE_URL + "/",
|
||||||
|
"Origin": BASE_URL
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function _jsonHeaders(referer) {
|
||||||
|
return {
|
||||||
|
"User-Agent": _ua(),
|
||||||
|
"Accept": "application/json, text/plain, */*",
|
||||||
|
"Accept-Language": "ru-RU,ru;q=0.9,en;q=0.8",
|
||||||
|
"Referer": referer || BASE_URL + "/"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function _streamHeaders() {
|
||||||
|
return {
|
||||||
|
"User-Agent": _ua(),
|
||||||
|
"Referer": BASE_URL + "/"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _postSearch(query) {
|
||||||
|
const q = encodeURIComponent(String(query || ""));
|
||||||
|
const withParams = `${SEARCH_URL}&story=${q}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const r = await fetchv2(withParams, _searchHeaders(), "POST", "");
|
||||||
|
const txt = await r.text();
|
||||||
|
|
||||||
|
if (txt && txt.includes("all__item")) return txt;
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
const body = "do=search&subaction=search&story=" + q;
|
||||||
|
const fallback = await fetchv2(BASE_URL + "/index.php?do=search", _searchHeaders(), "POST", body);
|
||||||
|
return fallback.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
function _parseSearchResults(html) {
|
||||||
|
const src = String(html || "");
|
||||||
|
const out = [];
|
||||||
|
const seen = new Set();
|
||||||
|
|
||||||
|
const blocks = src.match(/<a\b[^>]*class=["'][^"']*all__item[^"']*["'][^>]*>[\s\S]*?<\/a>/gi) || [];
|
||||||
|
|
||||||
|
for (const block of blocks) {
|
||||||
|
const openTag = (block.match(/<a\b[^>]*>/i) || [""])[0];
|
||||||
|
const imgTag = (block.match(/<img\b[^>]*>/i) || [""])[0];
|
||||||
|
|
||||||
|
const titleMatch = block.match(
|
||||||
|
/<span\b[^>]*class=["'][^"']*all__item-title[^"']*["'][^>]*>([\s\S]*?)<\/span>/i
|
||||||
|
);
|
||||||
|
|
||||||
|
const href = _absUrl(_attr(openTag, "href"), BASE_URL);
|
||||||
|
const title = _stripTags((titleMatch && titleMatch[1]) || _attr(openTag, "title") || "Unknown title");
|
||||||
|
const image = _absUrl(_attr(imgTag, "src") || _attr(imgTag, "data-src"), BASE_URL);
|
||||||
|
|
||||||
|
if (!href || !title || seen.has(href)) continue;
|
||||||
|
seen.add(href);
|
||||||
|
|
||||||
|
out.push({
|
||||||
|
title,
|
||||||
|
image,
|
||||||
|
href: _packAnime({
|
||||||
|
animeUrl: href,
|
||||||
|
title,
|
||||||
|
image
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _extractAnimeUrl(input) {
|
||||||
|
const packed = _unpackAnime(input);
|
||||||
|
if (packed?.animeUrl) return _absUrl(packed.animeUrl, BASE_URL);
|
||||||
|
|
||||||
|
const ep = _unpackEpisode(input);
|
||||||
|
if (ep?.animeUrl) return _absUrl(ep.animeUrl, BASE_URL);
|
||||||
|
|
||||||
|
return _absUrl(input, BASE_URL);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _extractTitleId(html) {
|
||||||
|
const src = String(html || "");
|
||||||
|
|
||||||
|
const m1 = src.match(/data-title-id=["'](\d+)["']/i);
|
||||||
|
if (m1 && m1[1]) return m1[1];
|
||||||
|
|
||||||
|
const m2 = src.match(/[?&]titleId=(\d+)/i);
|
||||||
|
if (m2 && m2[1]) return m2[1];
|
||||||
|
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function _extractPublisherId(html) {
|
||||||
|
const src = String(html || "");
|
||||||
|
|
||||||
|
const m = src.match(/data-publisher-id=["'](\d+)["']/i);
|
||||||
|
return m && m[1] ? m[1] : DEFAULT_PUB;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _extractAggregator(html) {
|
||||||
|
const src = String(html || "");
|
||||||
|
|
||||||
|
const m1 = src.match(/data-aggregator=["']([^"']+)["']/i);
|
||||||
|
if (m1 && m1[1]) return m1[1];
|
||||||
|
|
||||||
|
const m2 = src.match(/[?&]aggr=([^"'&<>\s]+)/i);
|
||||||
|
return m2 && m2[1] ? decodeURIComponent(m2[1]) : DEFAULT_AGGR;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _getAnimePage(animeUrl) {
|
||||||
|
const r = await fetchv2(animeUrl, _htmlHeaders(BASE_URL + "/"));
|
||||||
|
return r.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _getPlaylist(titleId, pub, aggr, referer) {
|
||||||
|
const url =
|
||||||
|
`${PLAYLIST_API}?pub=${encodeURIComponent(pub || DEFAULT_PUB)}` +
|
||||||
|
`&aggr=${encodeURIComponent(aggr || DEFAULT_AGGR)}` +
|
||||||
|
`&id=${encodeURIComponent(titleId)}`;
|
||||||
|
|
||||||
|
const r = await fetchv2(url, _jsonHeaders(referer || BASE_URL + "/"));
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _getVideo(vkId, referer) {
|
||||||
|
const url = VIDEO_API + encodeURIComponent(String(vkId || ""));
|
||||||
|
const r = await fetchv2(url, _jsonHeaders(referer || BASE_URL + "/"));
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function _detailsDescription(html) {
|
||||||
|
const title =
|
||||||
|
_stripTags((html.match(/<h1\b[^>]*class=["'][^"']*movie__title[^"']*["'][^>]*>([\s\S]*?)<\/h1>/i) || [null, ""])[1]) ||
|
||||||
|
_extractMeta(html, "property", "og:title") ||
|
||||||
|
_extractMeta(html, "name", "title") ||
|
||||||
|
"AniDub";
|
||||||
|
|
||||||
|
const subtitle = _stripTags(
|
||||||
|
(html.match(/<span\b[^>]*class=["'][^"']*movie__subtitle[^"']*["'][^>]*>([\s\S]*?)<\/span>/i) || [null, ""])[1]
|
||||||
|
);
|
||||||
|
|
||||||
|
const desc =
|
||||||
|
_stripTags((html.match(/<div\b[^>]*class=["'][^"']*movie__description[^"']*["'][^>]*>([\s\S]*?)<\/div>/i) || [null, ""])[1]) ||
|
||||||
|
_extractMeta(html, "property", "og:description") ||
|
||||||
|
_extractMeta(html, "name", "description") ||
|
||||||
|
"No description available.";
|
||||||
|
|
||||||
|
return [title, subtitle, desc].filter(Boolean).join("\n\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function _detailsAliases(html, titleId, pub, aggr) {
|
||||||
|
const parts = [];
|
||||||
|
|
||||||
|
const addField = (display, pattern) => {
|
||||||
|
const re = new RegExp(
|
||||||
|
"<div[^>]*class=[\"'][^\"']*movie__type[^\"']*[\"'][^>]*>\\s*<span>" +
|
||||||
|
pattern +
|
||||||
|
":<\\/span>\\s*([\\s\\S]*?)<\\/div>",
|
||||||
|
"i"
|
||||||
|
);
|
||||||
|
|
||||||
|
const m = html.match(re);
|
||||||
|
const val = m && m[1] ? _stripTags(m[1]) : "";
|
||||||
|
|
||||||
|
if (val) parts.push(`${display}: ${val}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
addField("Жанр", "Жанр");
|
||||||
|
addField("Тип", "Тип");
|
||||||
|
addField("Статус", "Статус");
|
||||||
|
addField("Год выхода", "Год выхода");
|
||||||
|
addField("Длительность", "Длительность \\(мин\\.\\)");
|
||||||
|
addField("Кол-во эпизодов", "Кол-во эпизодов");
|
||||||
|
addField("Другие названия", "Другие названия");
|
||||||
|
|
||||||
|
if (titleId) parts.push(`titleId: ${titleId}`);
|
||||||
|
if (pub) parts.push(`pub: ${pub}`);
|
||||||
|
if (aggr) parts.push(`aggr: ${aggr}`);
|
||||||
|
|
||||||
|
return parts.join(" | ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function _airdate(html) {
|
||||||
|
const premiere = html.match(
|
||||||
|
/<div[^>]*class=["'][^"']*movie__type[^"']*["'][^>]*>\s*<span>Премьера:<\/span>\s*([\s\S]*?)<\/div>/i
|
||||||
|
);
|
||||||
|
|
||||||
|
const added = html.match(
|
||||||
|
/<div[^>]*class=["'][^"']*movie__added[^"']*["'][^>]*>[\s\S]*?<span>Добавлено<\/span>[\s\S]*?<span>([\s\S]*?)<\/span>/i
|
||||||
|
);
|
||||||
|
|
||||||
|
return _stripTags((premiere && premiere[1]) || (added && added[1]) || "Unknown");
|
||||||
|
}
|
||||||
|
|
||||||
|
function _episodeKey(season, episode) {
|
||||||
|
return `${season || 1}:${episode || 0}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _episodeTitle(season, episode) {
|
||||||
|
if (season && season > 1) return `S${season}E${episode}`;
|
||||||
|
return `Episode ${episode}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _voiceName(item) {
|
||||||
|
const studio = String(item?.voiceStudio || "").trim();
|
||||||
|
const type = String(item?.voiceType || "").trim();
|
||||||
|
|
||||||
|
if (studio && type) return `${studio} · ${type}`;
|
||||||
|
if (studio) return studio;
|
||||||
|
if (type) return type;
|
||||||
|
|
||||||
|
return "Unknown voiceover";
|
||||||
|
}
|
||||||
|
|
||||||
|
function _sourceUrl(sources, key) {
|
||||||
|
return _cleanUrl(sources?.[key] || "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function _buildQualityUrls(videoJson) {
|
||||||
|
const sources = videoJson?.sources || {};
|
||||||
|
|
||||||
|
const url2160 = _sourceUrl(sources, "mpeg4kUrl");
|
||||||
|
const url1440 = _sourceUrl(sources, "mpeg2kUrl") || _sourceUrl(sources, "mpegQhdUrl");
|
||||||
|
const url1080 = _sourceUrl(sources, "mpegFullHdUrl");
|
||||||
|
const url720 = _sourceUrl(sources, "mpegHighUrl");
|
||||||
|
const url480 = _sourceUrl(sources, "mpegMediumUrl");
|
||||||
|
const url360 = _sourceUrl(sources, "mpegLowUrl");
|
||||||
|
const url240 = _sourceUrl(sources, "mpegLowestUrl");
|
||||||
|
const url144 = _sourceUrl(sources, "mpegTinyUrl");
|
||||||
|
|
||||||
|
return {
|
||||||
|
url2160,
|
||||||
|
url1440,
|
||||||
|
url1080,
|
||||||
|
url720,
|
||||||
|
url480,
|
||||||
|
url360,
|
||||||
|
url240,
|
||||||
|
url144
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function _pickBestMp4(qualityUrls) {
|
||||||
|
const ordered = [
|
||||||
|
{ quality: 2160, url: qualityUrls.url2160 },
|
||||||
|
{ quality: 1440, url: qualityUrls.url1440 },
|
||||||
|
{ quality: 1080, url: qualityUrls.url1080 },
|
||||||
|
{ quality: 720, url: qualityUrls.url720 },
|
||||||
|
{ quality: 480, url: qualityUrls.url480 },
|
||||||
|
{ quality: 360, url: qualityUrls.url360 },
|
||||||
|
{ quality: 240, url: qualityUrls.url240 },
|
||||||
|
{ quality: 144, url: qualityUrls.url144 }
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const item of ordered) {
|
||||||
|
if (item.url) return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _hasAnyQuality(qualityUrls) {
|
||||||
|
return !!(
|
||||||
|
qualityUrls.url2160 ||
|
||||||
|
qualityUrls.url1440 ||
|
||||||
|
qualityUrls.url1080 ||
|
||||||
|
qualityUrls.url720 ||
|
||||||
|
qualityUrls.url480 ||
|
||||||
|
qualityUrls.url360 ||
|
||||||
|
qualityUrls.url240 ||
|
||||||
|
qualityUrls.url144
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _buildVoiceoverStream(voiceName, videoJson) {
|
||||||
|
const qualityUrls = _buildQualityUrls(videoJson);
|
||||||
|
|
||||||
|
if (!_hasAnyQuality(qualityUrls)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const best = _pickBestMp4(qualityUrls);
|
||||||
|
if (!best?.url) return null;
|
||||||
|
|
||||||
|
const headers = _streamHeaders();
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: voiceName || "Voiceover",
|
||||||
|
streamUrl: best.url,
|
||||||
|
|
||||||
|
// Main Sora quality-picker fields.
|
||||||
|
url1080: qualityUrls.url1080,
|
||||||
|
url720: qualityUrls.url720,
|
||||||
|
url480: qualityUrls.url480,
|
||||||
|
|
||||||
|
// Extra qualities. Harmless if Sora ignores them.
|
||||||
|
url2160: qualityUrls.url2160,
|
||||||
|
url1440: qualityUrls.url1440,
|
||||||
|
url360: qualityUrls.url360,
|
||||||
|
url240: qualityUrls.url240,
|
||||||
|
url144: qualityUrls.url144,
|
||||||
|
|
||||||
|
headers
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// Search
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
async function searchResults(keyword) {
|
||||||
|
try {
|
||||||
|
const query = String(keyword || "").trim();
|
||||||
|
if (!query) return JSON.stringify([]);
|
||||||
|
|
||||||
|
const html = await _postSearch(query);
|
||||||
|
const results = _parseSearchResults(html);
|
||||||
|
|
||||||
|
for (const item of results) {
|
||||||
|
item._score = _scoreTitle(item.title, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
results.sort((a, b) => a._score - b._score);
|
||||||
|
|
||||||
|
return JSON.stringify(results.map(({ _score, ...rest }) => rest));
|
||||||
|
} catch (_) {
|
||||||
|
return JSON.stringify([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// Details
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
async function extractDetails(href) {
|
||||||
|
try {
|
||||||
|
const animeUrl = _extractAnimeUrl(href);
|
||||||
|
if (!animeUrl) return JSON.stringify([]);
|
||||||
|
|
||||||
|
const html = await _getAnimePage(animeUrl);
|
||||||
|
|
||||||
|
const titleId = _extractTitleId(html);
|
||||||
|
const pub = _extractPublisherId(html);
|
||||||
|
const aggr = _extractAggregator(html);
|
||||||
|
|
||||||
|
return JSON.stringify([{
|
||||||
|
description: _detailsDescription(html),
|
||||||
|
aliases: _detailsAliases(html, titleId, pub, aggr) || "AniDub",
|
||||||
|
airdate: _airdate(html)
|
||||||
|
}]);
|
||||||
|
} catch (_) {
|
||||||
|
return JSON.stringify([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// Episodes
|
||||||
|
// Unique episodes only.
|
||||||
|
// Store all voiceovers in href payload.
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
async function extractEpisodes(href) {
|
||||||
|
try {
|
||||||
|
const animeUrl = _extractAnimeUrl(href);
|
||||||
|
if (!animeUrl) return JSON.stringify([]);
|
||||||
|
|
||||||
|
const html = await _getAnimePage(animeUrl);
|
||||||
|
|
||||||
|
const titleId = _extractTitleId(html);
|
||||||
|
const pub = _extractPublisherId(html);
|
||||||
|
const aggr = _extractAggregator(html);
|
||||||
|
|
||||||
|
if (!titleId) return JSON.stringify([]);
|
||||||
|
|
||||||
|
const playlist = await _getPlaylist(titleId, pub, aggr, animeUrl);
|
||||||
|
const items = Array.isArray(playlist?.items) ? playlist.items : [];
|
||||||
|
|
||||||
|
const byEpisode = new Map();
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const vkId = String(item?.vkId || "").trim();
|
||||||
|
if (!vkId) continue;
|
||||||
|
|
||||||
|
const season = Number.isFinite(item?.season) ? item.season : 1;
|
||||||
|
const episode = Number.isFinite(item?.episode) ? item.episode : 0;
|
||||||
|
if (!episode) continue;
|
||||||
|
|
||||||
|
const key = _episodeKey(season, episode);
|
||||||
|
|
||||||
|
if (!byEpisode.has(key)) {
|
||||||
|
byEpisode.set(key, {
|
||||||
|
season,
|
||||||
|
episode,
|
||||||
|
options: []
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const ep = byEpisode.get(key);
|
||||||
|
|
||||||
|
ep.options.push({
|
||||||
|
vkId,
|
||||||
|
cvhId: String(item?.cvhId || ""),
|
||||||
|
voiceStudio: String(item?.voiceStudio || "").trim(),
|
||||||
|
voiceType: String(item?.voiceType || "").trim(),
|
||||||
|
voiceName: _voiceName(item),
|
||||||
|
name: String(item?.name || "").trim(),
|
||||||
|
season,
|
||||||
|
episode
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const out = Array.from(byEpisode.values())
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (a.season !== b.season) return a.season - b.season;
|
||||||
|
return a.episode - b.episode;
|
||||||
|
})
|
||||||
|
.map(ep => {
|
||||||
|
ep.options.sort((a, b) => _voiceRank(a.voiceName) - _voiceRank(b.voiceName));
|
||||||
|
|
||||||
|
return {
|
||||||
|
href: _packEpisode({
|
||||||
|
animeUrl,
|
||||||
|
titleId,
|
||||||
|
pub,
|
||||||
|
aggr,
|
||||||
|
season: ep.season,
|
||||||
|
episode: ep.episode,
|
||||||
|
options: ep.options
|
||||||
|
}),
|
||||||
|
number: ep.episode,
|
||||||
|
title: _episodeTitle(ep.season, ep.episode)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return JSON.stringify(out);
|
||||||
|
} catch (_) {
|
||||||
|
return JSON.stringify([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// Stream
|
||||||
|
// Correct voiceover + quality picker:
|
||||||
|
// - one stream per voiceover
|
||||||
|
// - each stream carries url1080/url720/url480/etc.
|
||||||
|
// - HLS skipped.
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
async function extractStreamUrl(href) {
|
||||||
|
try {
|
||||||
|
const payload = _unpackEpisode(href);
|
||||||
|
const options = Array.isArray(payload?.options) ? payload.options : [];
|
||||||
|
|
||||||
|
if (!options.length) {
|
||||||
|
return JSON.stringify({
|
||||||
|
streams: [],
|
||||||
|
subtitle: DEFAULT_SUBTITLE
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
options.sort((a, b) => _voiceRank(a.voiceName) - _voiceRank(b.voiceName));
|
||||||
|
|
||||||
|
const streams = [];
|
||||||
|
const seen = new Set();
|
||||||
|
|
||||||
|
for (const opt of options) {
|
||||||
|
const vkId = String(opt?.vkId || "").trim();
|
||||||
|
if (!vkId || seen.has(vkId)) continue;
|
||||||
|
seen.add(vkId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const videoJson = await _getVideo(vkId, payload?.animeUrl || BASE_URL + "/");
|
||||||
|
|
||||||
|
const stream = _buildVoiceoverStream(
|
||||||
|
String(opt?.voiceName || "Voiceover").trim(),
|
||||||
|
videoJson
|
||||||
|
);
|
||||||
|
|
||||||
|
if (stream) streams.push(stream);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.stringify({
|
||||||
|
streams,
|
||||||
|
subtitle: DEFAULT_SUBTITLE
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
return JSON.stringify({
|
||||||
|
streams: [],
|
||||||
|
subtitle: DEFAULT_SUBTITLE
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _defaultExport() {
|
||||||
|
return {
|
||||||
|
searchResults,
|
||||||
|
extractDetails,
|
||||||
|
extractEpisodes,
|
||||||
|
extractStreamUrl
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
globalThis.default = _defaultExport;
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.default = _defaultExport;
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
globalThis.module = globalThis.module || {};
|
||||||
|
globalThis.module.exports = { default: _defaultExport };
|
||||||
|
} catch (_) {}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"sourceName": "AniDub",
|
||||||
|
"iconUrl": "https://anidub.org/templates/CinemaBlue/images/jutfun.png",
|
||||||
|
"author": {
|
||||||
|
"name": "emp0ry",
|
||||||
|
"icon": "https://avatars.githubusercontent.com/u/64217088"
|
||||||
|
},
|
||||||
|
"version": "1.0.0",
|
||||||
|
"language": "Russian",
|
||||||
|
"streamType": "HLS",
|
||||||
|
"quality": "1080p",
|
||||||
|
"baseUrl": "https://anidub.org/",
|
||||||
|
"searchBaseUrl": "https://anidub.org/index.php?do=search&subaction=search&story=%s",
|
||||||
|
"scriptUrl": "https://git.luna-app.eu/50n50/sources/raw/branch/main/anidub/anidub.js",
|
||||||
|
"asyncJS": true,
|
||||||
|
"streamAsyncJS": true,
|
||||||
|
"softsub": false,
|
||||||
|
"type": "anime",
|
||||||
|
"downloadSupport": false,
|
||||||
|
"supportsMojuru": true,
|
||||||
|
"supportsDartotsu": true,
|
||||||
|
"supportsSora": true,
|
||||||
|
"supportsLuna": true
|
||||||
|
}
|
||||||
@@ -24,6 +24,24 @@ function pickBestHls(ep) {
|
|||||||
return ep?.hls_1080 || ep?.hls_720 || ep?.hls_480 || null;
|
return ep?.hls_1080 || ep?.hls_720 || ep?.hls_480 || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _packEpisode(payload) {
|
||||||
|
return "aniliberty:" + encodeURIComponent(JSON.stringify(payload || {}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function _unpackEpisode(href) {
|
||||||
|
const s = String(href || "");
|
||||||
|
if (!s.startsWith("aniliberty:")) return null;
|
||||||
|
return _safeJsonParse(decodeURIComponent(s.slice("aniliberty:".length)), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _safeJsonParse(s, fallback) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(s);
|
||||||
|
} catch (_) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------
|
// ------------------------------------------------------------
|
||||||
// Detect working API domain
|
// Detect working API domain
|
||||||
// ------------------------------------------------------------
|
// ------------------------------------------------------------
|
||||||
@@ -117,8 +135,11 @@ async function extractEpisodes(url) {
|
|||||||
const eps = Array.isArray(data?.episodes) ? data.episodes : [];
|
const eps = Array.isArray(data?.episodes) ? data.episodes : [];
|
||||||
|
|
||||||
const out = eps.map((ep, idx) => {
|
const out = eps.map((ep, idx) => {
|
||||||
const href = pickBestHls(ep);
|
const url1080 = ep?.hls_1080 || null;
|
||||||
if (!href) return null;
|
const url720 = ep?.hls_720 || null;
|
||||||
|
const url480 = ep?.hls_480 || null;
|
||||||
|
const best = url1080 || url720 || url480;
|
||||||
|
if (!best) return null;
|
||||||
|
|
||||||
const num = Number.isFinite(ep?.ordinal)
|
const num = Number.isFinite(ep?.ordinal)
|
||||||
? ep.ordinal
|
? ep.ordinal
|
||||||
@@ -137,7 +158,12 @@ async function extractEpisodes(url) {
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const entry = {
|
const entry = {
|
||||||
href,
|
href: _packEpisode({
|
||||||
|
url1080,
|
||||||
|
url720,
|
||||||
|
url480,
|
||||||
|
fallback: best
|
||||||
|
}),
|
||||||
number: num,
|
number: num,
|
||||||
title,
|
title,
|
||||||
image
|
image
|
||||||
@@ -162,7 +188,68 @@ async function extractEpisodes(url) {
|
|||||||
// ------------------------------------------------------------
|
// ------------------------------------------------------------
|
||||||
async function extractStreamUrl(url) {
|
async function extractStreamUrl(url) {
|
||||||
try {
|
try {
|
||||||
return url; // direct HLS
|
const payload = _unpackEpisode(url);
|
||||||
|
if (!payload) {
|
||||||
|
return url; // backward compatibility for old episode href format
|
||||||
|
}
|
||||||
|
|
||||||
|
const url1080 = (payload.url1080 || "").toString().trim() || null;
|
||||||
|
const url720 = (payload.url720 || "").toString().trim() || null;
|
||||||
|
const url480 = (payload.url480 || "").toString().trim() || null;
|
||||||
|
const fallback = (payload.fallback || "").toString().trim() || null;
|
||||||
|
|
||||||
|
const streams = [];
|
||||||
|
|
||||||
|
if (url1080) {
|
||||||
|
streams.push({
|
||||||
|
title: "1080p",
|
||||||
|
streamUrl: url1080,
|
||||||
|
url1080,
|
||||||
|
url720,
|
||||||
|
url480,
|
||||||
|
headers: {
|
||||||
|
"User-Agent": "Mozilla/5.0",
|
||||||
|
"Referer": DEFAULT_IMAGE_HOST + "/"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url720) {
|
||||||
|
streams.push({
|
||||||
|
title: "720p",
|
||||||
|
streamUrl: url720,
|
||||||
|
url1080,
|
||||||
|
url720,
|
||||||
|
url480,
|
||||||
|
headers: {
|
||||||
|
"User-Agent": "Mozilla/5.0",
|
||||||
|
"Referer": DEFAULT_IMAGE_HOST + "/"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url480) {
|
||||||
|
streams.push({
|
||||||
|
title: "480p",
|
||||||
|
streamUrl: url480,
|
||||||
|
url1080,
|
||||||
|
url720,
|
||||||
|
url480,
|
||||||
|
headers: {
|
||||||
|
"User-Agent": "Mozilla/5.0",
|
||||||
|
"Referer": DEFAULT_IMAGE_HOST + "/"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!streams.length) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.stringify({
|
||||||
|
streams,
|
||||||
|
subtitle: "https://none.com"
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"name": "emp0ry",
|
"name": "emp0ry",
|
||||||
"icon": "https://avatars.githubusercontent.com/u/64217088"
|
"icon": "https://avatars.githubusercontent.com/u/64217088"
|
||||||
},
|
},
|
||||||
"version": "1.0.3",
|
"version": "1.0.4",
|
||||||
"language": "Russian",
|
"language": "Russian",
|
||||||
"streamType": "HLS",
|
"streamType": "HLS",
|
||||||
"quality": "1080p",
|
"quality": "1080p",
|
||||||
@@ -20,8 +20,5 @@
|
|||||||
"supportsMojuru": true,
|
"supportsMojuru": true,
|
||||||
"supportsDartotsu": true,
|
"supportsDartotsu": true,
|
||||||
"supportsSora": true,
|
"supportsSora": true,
|
||||||
"supportsLuna": true,
|
"supportsLuna": true
|
||||||
"supportsAnymex": true,
|
|
||||||
"supportsTsumi": true,
|
|
||||||
"supportsHiyoku": true
|
|
||||||
}
|
}
|
||||||
+177
-29
@@ -1,9 +1,11 @@
|
|||||||
// AnimeVost for Sora (AsyncJS)
|
// AnimeVost for Sora (AsyncJS)
|
||||||
// Author: emp0ry
|
// Author: emp0ry
|
||||||
// Version: 1.0.0
|
// Version: 1.0.2
|
||||||
|
|
||||||
const API_BASE = "https://api.animevost.org/v1/";
|
const API_BASE = "https://api.animevost.org/v1/";
|
||||||
const FORM_CT = "application/x-www-form-urlencoded; charset=UTF-8";
|
const FORM_CT = "application/x-www-form-urlencoded; charset=UTF-8";
|
||||||
|
const SITE_BASE = "https://animevost.org";
|
||||||
|
const DEFAULT_SUBTITLE = "https://none.com";
|
||||||
|
|
||||||
// --- utils ---
|
// --- utils ---
|
||||||
function encodeForm(fields) {
|
function encodeForm(fields) {
|
||||||
@@ -14,27 +16,39 @@ function encodeForm(fields) {
|
|||||||
|
|
||||||
async function postForm(url, fields) {
|
async function postForm(url, fields) {
|
||||||
const bodyStr = encodeForm(fields);
|
const bodyStr = encodeForm(fields);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resA = await fetchv2(url, { "Content-Type": FORM_CT }, "POST", bodyStr);
|
const resA = await fetchv2(url, { "Content-Type": FORM_CT }, "POST", bodyStr);
|
||||||
if (resA && typeof resA.text === "function") return resA;
|
if (resA && typeof resA.text === "function") return resA;
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
return await fetchv2(url, { method: "POST", headers: { "Content-Type": FORM_CT }, body: bodyStr });
|
|
||||||
|
return await fetchv2(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": FORM_CT },
|
||||||
|
body: bodyStr
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function parseJsonSafe(res) {
|
async function parseJsonSafe(res) {
|
||||||
const txt = await res.text();
|
const txt = await res.text();
|
||||||
try { return JSON.parse(txt); }
|
|
||||||
catch { return JSON.parse(txt.replace(/^\uFEFF/, "").trim()); }
|
try {
|
||||||
|
return JSON.parse(txt);
|
||||||
|
} catch (_) {
|
||||||
|
return JSON.parse(txt.replace(/^\uFEFF/, "").trim());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanTitle(raw) {
|
function cleanTitle(raw) {
|
||||||
if (!raw || typeof raw !== "string") return "Unknown title";
|
if (!raw || typeof raw !== "string") return "Unknown title";
|
||||||
|
|
||||||
let t = raw.split(" /")[0];
|
let t = raw.split(" /")[0];
|
||||||
return t.replace(/\s*\[.*?\]\s*$/g, "").trim() || "Unknown title";
|
return t.replace(/\s*\[.*?\]\s*$/g, "").trim() || "Unknown title";
|
||||||
}
|
}
|
||||||
|
|
||||||
function htmlToText(html) {
|
function htmlToText(html) {
|
||||||
if (!html || typeof html !== "string") return "";
|
if (!html || typeof html !== "string") return "";
|
||||||
|
|
||||||
return html
|
return html
|
||||||
.replace(/<br\s*\/?>/gi, "\n")
|
.replace(/<br\s*\/?>/gi, "\n")
|
||||||
.replace(/<\/p>/gi, "\n")
|
.replace(/<\/p>/gi, "\n")
|
||||||
@@ -43,27 +57,72 @@ function htmlToText(html) {
|
|||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cleanUrl(url) {
|
||||||
|
const s = String(url || "").trim();
|
||||||
|
return s || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _safeJsonParse(value, fallback) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(value);
|
||||||
|
} catch (_) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Pack payload into href to avoid relying on global state.
|
// Pack payload into href to avoid relying on global state.
|
||||||
function makeHrefFromPayload(obj) {
|
function makeHrefFromPayload(obj) {
|
||||||
return `animevost://payload/${encodeURIComponent(JSON.stringify(obj))}`;
|
return `animevost://payload/${encodeURIComponent(JSON.stringify(obj || {}))}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function readPayloadFromHref(href) {
|
function readPayloadFromHref(href) {
|
||||||
const m = String(href).match(/^animevost:\/\/payload\/(.+)$/);
|
const m = String(href || "").match(/^animevost:\/\/payload\/(.+)$/);
|
||||||
if (!m) return null;
|
if (!m) return null;
|
||||||
try { return JSON.parse(decodeURIComponent(m[1])); } catch { return null; }
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(decodeURIComponent(m[1]));
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseIdFromAny(hrefOrId) {
|
function parseIdFromAny(hrefOrId) {
|
||||||
const p = readPayloadFromHref(hrefOrId);
|
const p = readPayloadFromHref(hrefOrId);
|
||||||
if (p && p.id) return parseInt(p.id, 10);
|
if (p && p.id) return parseInt(p.id, 10);
|
||||||
const m1 = String(hrefOrId).match(/^animevost:\/\/release\/(\d+)$/);
|
|
||||||
|
const m1 = String(hrefOrId || "").match(/^animevost:\/\/release\/(\d+)$/);
|
||||||
if (m1) return parseInt(m1[1], 10);
|
if (m1) return parseInt(m1[1], 10);
|
||||||
const m2 = String(hrefOrId).match(/[?&]id=(\d+)/);
|
|
||||||
|
const m2 = String(hrefOrId || "").match(/[?&]id=(\d+)/);
|
||||||
if (m2) return parseInt(m2[1], 10);
|
if (m2) return parseInt(m2[1], 10);
|
||||||
if (/^\d+$/.test(String(hrefOrId))) return parseInt(hrefOrId, 10);
|
|
||||||
|
if (/^\d+$/.test(String(hrefOrId || ""))) {
|
||||||
|
return parseInt(hrefOrId, 10);
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- search (POST /search) ---
|
// Same packing style as AniLiberty / SameBand.
|
||||||
|
function _packEpisode(payload) {
|
||||||
|
return "animevost:" + encodeURIComponent(JSON.stringify(payload || {}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function _unpackEpisode(href) {
|
||||||
|
const raw = String(href || "");
|
||||||
|
if (!raw.startsWith("animevost:")) return null;
|
||||||
|
|
||||||
|
return _safeJsonParse(decodeURIComponent(raw.slice("animevost:".length)), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _streamHeaders() {
|
||||||
|
return {
|
||||||
|
"User-Agent": "Mozilla/5.0",
|
||||||
|
"Referer": SITE_BASE + "/"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- search: POST /search ---
|
||||||
async function searchResults(keyword) {
|
async function searchResults(keyword) {
|
||||||
try {
|
try {
|
||||||
let res = await postForm(API_BASE + "search", { name: String(keyword) });
|
let res = await postForm(API_BASE + "search", { name: String(keyword) });
|
||||||
@@ -92,39 +151,43 @@ async function searchResults(keyword) {
|
|||||||
type: item.type || "",
|
type: item.type || "",
|
||||||
image: item.urlImagePreview || ""
|
image: item.urlImagePreview || ""
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: payload.title,
|
title: payload.title,
|
||||||
image: payload.image,
|
image: payload.image,
|
||||||
href: makeHrefFromPayload(payload)
|
href: makeHrefFromPayload(payload)
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
return JSON.stringify(tiles);
|
return JSON.stringify(tiles);
|
||||||
} catch (e) {
|
} catch (_) {
|
||||||
return JSON.stringify([]);
|
return JSON.stringify([]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- details (from payload) ---
|
// --- details: from payload ---
|
||||||
async function extractDetails(href) {
|
async function extractDetails(href) {
|
||||||
try {
|
try {
|
||||||
const p = readPayloadFromHref(href);
|
const p = readPayloadFromHref(href);
|
||||||
|
|
||||||
const out = [{
|
const out = [{
|
||||||
description: p?.description || "No description available.",
|
description: p?.description || "No description available.",
|
||||||
aliases: `Type: ${p?.type || "Unknown"}`,
|
aliases: `Type: ${p?.type || "Unknown"}`,
|
||||||
airdate: p?.year ? String(p.year) : "Unknown"
|
airdate: p?.year ? String(p.year) : "Unknown"
|
||||||
}];
|
}];
|
||||||
|
|
||||||
return JSON.stringify(out);
|
return JSON.stringify(out);
|
||||||
} catch (e) {
|
} catch (_) {
|
||||||
return JSON.stringify([]);
|
return JSON.stringify([]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- episodes (POST /playlist) ---
|
// --- episodes: POST /playlist ---
|
||||||
async function extractEpisodes(href) {
|
async function extractEpisodes(href) {
|
||||||
try {
|
try {
|
||||||
const p = readPayloadFromHref(href);
|
const p = readPayloadFromHref(href);
|
||||||
const id = p?.id ?? parseIdFromAny(href);
|
const id = p?.id ?? parseIdFromAny(href);
|
||||||
|
|
||||||
if (!id) {
|
if (!id) {
|
||||||
return JSON.stringify([]);
|
return JSON.stringify([]);
|
||||||
}
|
}
|
||||||
@@ -132,30 +195,115 @@ async function extractEpisodes(href) {
|
|||||||
const res = await postForm(API_BASE + "playlist", { id: String(id) });
|
const res = await postForm(API_BASE + "playlist", { id: String(id) });
|
||||||
const arr = await parseJsonSafe(res);
|
const arr = await parseJsonSafe(res);
|
||||||
|
|
||||||
if (!Array.isArray(arr)) return JSON.stringify([]);
|
if (!Array.isArray(arr)) {
|
||||||
|
return JSON.stringify([]);
|
||||||
|
}
|
||||||
|
|
||||||
const out = arr.map((ep, idx) => {
|
const out = arr.map((ep, idx) => {
|
||||||
const name = ep?.name || "";
|
const name = ep?.name || "";
|
||||||
const m = name.match(/(\d+)/);
|
const m = name.match(/(\d+)/);
|
||||||
const num = m ? parseInt(m[1], 10) : (idx + 1);
|
const num = m ? parseInt(m[1], 10) : (idx + 1);
|
||||||
const link = ep?.hd || ep?.std || null;
|
|
||||||
if (!link) return null;
|
// AnimeVost usually provides only hd/std.
|
||||||
|
// Match Sora picker format used in AniLiberty/SameBand:
|
||||||
|
// hd -> 720p
|
||||||
|
// std -> 480p
|
||||||
|
const url1080 = null;
|
||||||
|
const url720 = cleanUrl(ep?.hd);
|
||||||
|
const url480 = cleanUrl(ep?.std);
|
||||||
|
|
||||||
|
const fallback = url720 || url480;
|
||||||
|
if (!fallback) return null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
href: _packEpisode({
|
||||||
|
url1080,
|
||||||
|
url720,
|
||||||
|
url480,
|
||||||
|
fallback
|
||||||
|
}),
|
||||||
number: num,
|
number: num,
|
||||||
title: name || `Episode ${num}`,
|
title: name || `Episode ${num}`,
|
||||||
image: ep?.preview || "",
|
image: ep?.preview || ""
|
||||||
href: link
|
|
||||||
};
|
};
|
||||||
}).filter(Boolean);
|
}).filter(Boolean);
|
||||||
|
|
||||||
return JSON.stringify(out);
|
return JSON.stringify(out);
|
||||||
} catch (e) {
|
} catch (_) {
|
||||||
return JSON.stringify([]);
|
return JSON.stringify([]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- stream ---
|
// --- stream: returns quality picker JSON ---
|
||||||
async function extractStreamUrl(url) {
|
async function extractStreamUrl(href) {
|
||||||
try { return url; } catch { return null; }
|
try {
|
||||||
}
|
const payload = _unpackEpisode(href);
|
||||||
|
|
||||||
|
// Backward compatibility for old AnimeVost episode href format.
|
||||||
|
if (!payload) {
|
||||||
|
return href;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url1080 = cleanUrl(payload.url1080);
|
||||||
|
const url720 = cleanUrl(payload.url720);
|
||||||
|
const url480 = cleanUrl(payload.url480);
|
||||||
|
const fallback = cleanUrl(payload.fallback);
|
||||||
|
|
||||||
|
const headers = _streamHeaders();
|
||||||
|
const streams = [];
|
||||||
|
|
||||||
|
if (url1080) {
|
||||||
|
streams.push({
|
||||||
|
title: "1080p",
|
||||||
|
streamUrl: url1080,
|
||||||
|
url1080,
|
||||||
|
url720,
|
||||||
|
url480,
|
||||||
|
headers
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url720) {
|
||||||
|
streams.push({
|
||||||
|
title: "720p",
|
||||||
|
streamUrl: url720,
|
||||||
|
url1080,
|
||||||
|
url720,
|
||||||
|
url480,
|
||||||
|
headers
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url480) {
|
||||||
|
streams.push({
|
||||||
|
title: "480p",
|
||||||
|
streamUrl: url480,
|
||||||
|
url1080,
|
||||||
|
url720,
|
||||||
|
url480,
|
||||||
|
headers
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!streams.length && fallback) {
|
||||||
|
streams.push({
|
||||||
|
title: "720p",
|
||||||
|
streamUrl: fallback,
|
||||||
|
url1080: null,
|
||||||
|
url720: fallback,
|
||||||
|
url480: null,
|
||||||
|
headers
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.stringify({
|
||||||
|
streams,
|
||||||
|
subtitle: DEFAULT_SUBTITLE
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
return JSON.stringify({
|
||||||
|
streams: [],
|
||||||
|
subtitle: DEFAULT_SUBTITLE
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
"name": "emp0ry",
|
"name": "emp0ry",
|
||||||
"icon": "https://avatars.githubusercontent.com/u/64217088"
|
"icon": "https://avatars.githubusercontent.com/u/64217088"
|
||||||
},
|
},
|
||||||
"version": "1.0.1",
|
"version": "1.0.2",
|
||||||
"language": "Russian",
|
"language": "Russian",
|
||||||
"streamType": "MP4",
|
"streamType": "MP4",
|
||||||
"quality": "720p",
|
"quality": "720p",
|
||||||
@@ -20,8 +20,5 @@
|
|||||||
"supportsMojuru": true,
|
"supportsMojuru": true,
|
||||||
"supportsDartotsu": true,
|
"supportsDartotsu": true,
|
||||||
"supportsSora": true,
|
"supportsSora": true,
|
||||||
"supportsLuna": true,
|
"supportsLuna": true
|
||||||
"supportsAnymex": true,
|
|
||||||
"supportsTsumi": true,
|
|
||||||
"supportsHiyoku": true
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,460 @@
|
|||||||
|
const API_BASE = "https://onwavedub.org/api/v1";
|
||||||
|
const SITE_BASE = "https://onwavedub.org";
|
||||||
|
const DEFAULT_SUBTITLE = "https://none.com";
|
||||||
|
// Set to "voiceover-only" or "server-only" depending on preferred app UI grouping.
|
||||||
|
const STREAM_PICKER_MODE = "voiceover-only";
|
||||||
|
|
||||||
|
function _streamTitle(kind, quality) {
|
||||||
|
const q = Number.isFinite(quality) && quality > 0 ? `${quality}p` : "";
|
||||||
|
|
||||||
|
if (STREAM_PICKER_MODE === "server-only") {
|
||||||
|
if (kind === "onwave") return "OnWave - Main";
|
||||||
|
if (kind === "mirror") return "OnWave - Mirror";
|
||||||
|
if (kind === "kodik") return q ? `OnWave - Kodik ${q}` : "OnWave - Kodik";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind === "onwave") return "OnWave";
|
||||||
|
if (kind === "mirror") return "OnWave зеркало";
|
||||||
|
if (kind === "kodik") return q ? `Kodik ${q}` : "Kodik";
|
||||||
|
return "Stream";
|
||||||
|
}
|
||||||
|
|
||||||
|
function _ua() {
|
||||||
|
return "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36";
|
||||||
|
}
|
||||||
|
|
||||||
|
function _safeJsonParse(value, fallback) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(value);
|
||||||
|
} catch (_) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _absUrl(url, base) {
|
||||||
|
if (!url) return "";
|
||||||
|
const raw = String(url).trim();
|
||||||
|
if (!raw) return "";
|
||||||
|
if (raw.startsWith("http://") || raw.startsWith("https://")) return raw;
|
||||||
|
if (raw.startsWith("//")) return "https:" + raw;
|
||||||
|
|
||||||
|
const origin = String(base || SITE_BASE).replace(/\/+$/, "");
|
||||||
|
return origin + "/" + raw.replace(/^\/+/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function _scoreTitle(title, keyword) {
|
||||||
|
const t = String(title || "").toLowerCase().trim();
|
||||||
|
const k = String(keyword || "").toLowerCase().trim();
|
||||||
|
if (!t || !k) return 99;
|
||||||
|
if (t === k) return 0;
|
||||||
|
if (t.startsWith(k)) return 1;
|
||||||
|
if (t.includes(k)) return 2;
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _packEpisode(payload) {
|
||||||
|
return "onwave:" + encodeURIComponent(JSON.stringify(payload || {}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function _unpackEpisode(href) {
|
||||||
|
const raw = String(href || "");
|
||||||
|
if (!raw.startsWith("onwave:")) return null;
|
||||||
|
return _safeJsonParse(decodeURIComponent(raw.slice("onwave:".length)), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _extractAnimeId(input) {
|
||||||
|
const raw = String(input || "").trim();
|
||||||
|
if (!raw) return "";
|
||||||
|
|
||||||
|
const packed = _unpackEpisode(raw);
|
||||||
|
if (packed && packed.animeId != null) return String(packed.animeId);
|
||||||
|
|
||||||
|
if (/^\d+$/.test(raw)) return raw;
|
||||||
|
|
||||||
|
const m = raw.match(/\/anime\/(\d+)/i);
|
||||||
|
if (m && m[1]) return m[1];
|
||||||
|
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _playerPriority(player) {
|
||||||
|
return Number.isFinite(player?.priority) ? player.priority : 999;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _pickPlayer(players, predicate) {
|
||||||
|
const list = (Array.isArray(players) ? players : []).filter(predicate);
|
||||||
|
if (!list.length) return null;
|
||||||
|
list.sort((a, b) => _playerPriority(a) - _playerPriority(b));
|
||||||
|
return list[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _apiGet(url) {
|
||||||
|
return fetchv2(url, {
|
||||||
|
"User-Agent": _ua(),
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Accept-Language": "ru-RU,ru;q=0.9,en;q=0.8",
|
||||||
|
"Referer": SITE_BASE + "/",
|
||||||
|
"Origin": SITE_BASE
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function _buildOnWaveMp4(playerLink) {
|
||||||
|
const abs = _absUrl(playerLink, "https://anisurf.site");
|
||||||
|
if (!abs) return "";
|
||||||
|
|
||||||
|
if (/\/videos\/[^/]+\/streams\/source\.mp4/i.test(abs)) {
|
||||||
|
return abs.split("?")[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const u = new URL(abs);
|
||||||
|
const id = u.searchParams.get("id");
|
||||||
|
if (!id) return "";
|
||||||
|
|
||||||
|
const origin =
|
||||||
|
u.hostname.toLowerCase().includes("anisurf.ru")
|
||||||
|
? "https://anisurf.ru"
|
||||||
|
: "https://anisurf.site";
|
||||||
|
|
||||||
|
return `${origin}/videos/${id}/streams/source.mp4`;
|
||||||
|
} catch (_) {
|
||||||
|
const m = abs.match(/[?&]id=([a-z0-9-]+)/i);
|
||||||
|
if (!m || !m[1]) return "";
|
||||||
|
|
||||||
|
const isRu = /anisurf\.ru/i.test(abs);
|
||||||
|
const origin = isRu ? "https://anisurf.ru" : "https://anisurf.site";
|
||||||
|
return `${origin}/videos/${m[1]}/streams/source.mp4`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _appendBestKodikStream(streams, qualities) {
|
||||||
|
let bestUrl = "";
|
||||||
|
let bestQuality = 0;
|
||||||
|
let url1080 = null;
|
||||||
|
let url720 = null;
|
||||||
|
let url480 = null;
|
||||||
|
|
||||||
|
for (const quality in qualities || {}) {
|
||||||
|
const srcRaw = qualities?.[quality]?.src;
|
||||||
|
const src = srcRaw ? (String(srcRaw).startsWith("//") ? "https:" + String(srcRaw) : String(srcRaw)) : "";
|
||||||
|
if (!src) continue;
|
||||||
|
|
||||||
|
const numeric = parseInt(String(quality).replace(/[^\d]/g, ""), 10) || 0;
|
||||||
|
if (numeric >= 1080 && !url1080) url1080 = src;
|
||||||
|
if (numeric === 720 && !url720) url720 = src;
|
||||||
|
if (numeric === 480 && !url480) url480 = src;
|
||||||
|
|
||||||
|
if (numeric > bestQuality) {
|
||||||
|
bestQuality = numeric;
|
||||||
|
bestUrl = src;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!bestUrl) return;
|
||||||
|
|
||||||
|
const finalUrl = bestUrl;
|
||||||
|
if (!url1080 && bestQuality >= 1080) url1080 = finalUrl;
|
||||||
|
if (!url720 && bestQuality >= 720 && bestQuality < 1080) url720 = finalUrl;
|
||||||
|
if (!url480 && bestQuality >= 480 && bestQuality < 720) url480 = finalUrl;
|
||||||
|
|
||||||
|
streams.push({
|
||||||
|
title: _streamTitle("kodik", bestQuality),
|
||||||
|
streamUrl: finalUrl,
|
||||||
|
url1080,
|
||||||
|
url720,
|
||||||
|
url480,
|
||||||
|
headers: {
|
||||||
|
"User-Agent": _ua(),
|
||||||
|
"Referer": SITE_BASE + "/"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchResults(keyword) {
|
||||||
|
try {
|
||||||
|
const query = String(keyword || "").trim();
|
||||||
|
if (!query) return JSON.stringify([]);
|
||||||
|
|
||||||
|
const url = `${API_BASE}/catalog?query=${encodeURIComponent(query)}`;
|
||||||
|
const response = await _apiGet(url);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
const items = Array.isArray(data?.items) ? data.items : [];
|
||||||
|
const out = items.map(item => ({
|
||||||
|
title: String(item?.title || "Unknown"),
|
||||||
|
image: _absUrl(item?.poster || "", SITE_BASE),
|
||||||
|
href: String(item?.id || ""),
|
||||||
|
_score: _scoreTitle(item?.title || "", query)
|
||||||
|
}));
|
||||||
|
|
||||||
|
out.sort((a, b) => a._score - b._score);
|
||||||
|
return JSON.stringify(out.map(({ _score, ...rest }) => rest));
|
||||||
|
} catch (_) {
|
||||||
|
return JSON.stringify([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function extractDetails(animeIdOrUrl) {
|
||||||
|
try {
|
||||||
|
const animeId = _extractAnimeId(animeIdOrUrl);
|
||||||
|
if (!animeId) return JSON.stringify([]);
|
||||||
|
|
||||||
|
const response = await _apiGet(`${API_BASE}/anime/${encodeURIComponent(animeId)}`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
const aliases = [];
|
||||||
|
if (data?.originalTitle) aliases.push(`Original: ${data.originalTitle}`);
|
||||||
|
if (data?.alternateTitle) aliases.push(`Alt: ${data.alternateTitle}`);
|
||||||
|
if (Number.isFinite(data?.length)) aliases.push(`Duration: ${data.length}m`);
|
||||||
|
if (Number.isFinite(data?.episodesTotal)) aliases.push(`Episodes: ${data.episodesTotal}`);
|
||||||
|
if (Array.isArray(data?.studios) && data.studios.length) {
|
||||||
|
aliases.push(`Studios: ${data.studios.join(", ")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.stringify([
|
||||||
|
{
|
||||||
|
description: data?.description || "No description available",
|
||||||
|
aliases: aliases.join(" | "),
|
||||||
|
airdate: data?.airedAt ? String(data.airedAt).slice(0, 10) : "Unknown"
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
} catch (_) {
|
||||||
|
return JSON.stringify([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function extractEpisodes(animeIdOrUrl) {
|
||||||
|
try {
|
||||||
|
const animeId = _extractAnimeId(animeIdOrUrl);
|
||||||
|
if (!animeId) return JSON.stringify([]);
|
||||||
|
|
||||||
|
const response = await _apiGet(`${API_BASE}/anime/${encodeURIComponent(animeId)}`);
|
||||||
|
const data = await response.json();
|
||||||
|
const episodes = Array.isArray(data?.episodes) ? data.episodes : [];
|
||||||
|
|
||||||
|
const out = episodes
|
||||||
|
.map((episode, index) => {
|
||||||
|
const players = Array.isArray(episode?.players) ? episode.players : [];
|
||||||
|
|
||||||
|
const onwave = _pickPlayer(players, player => {
|
||||||
|
const name = String(player?.name || "").toLowerCase();
|
||||||
|
return name === "onwave" || (name.includes("onwave") && !name.includes("зеркало"));
|
||||||
|
});
|
||||||
|
|
||||||
|
const mirror = _pickPlayer(players, player => {
|
||||||
|
const name = String(player?.name || "").toLowerCase();
|
||||||
|
return name.includes("зеркало") || name.includes("mirror");
|
||||||
|
});
|
||||||
|
|
||||||
|
const kodik = _pickPlayer(players, player => {
|
||||||
|
const name = String(player?.name || "").toLowerCase();
|
||||||
|
const link = String(player?.link || "").toLowerCase();
|
||||||
|
return name.includes("kodik") || link.includes("kodik");
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!onwave && !mirror && !kodik) return null;
|
||||||
|
|
||||||
|
const number = Number.isFinite(episode?.episode)
|
||||||
|
? episode.episode
|
||||||
|
: (Number.isFinite(episode?.index) ? episode.index : index + 1);
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
animeId: String(animeId),
|
||||||
|
season: Number.isFinite(episode?.season) ? episode.season : 1,
|
||||||
|
episode: number,
|
||||||
|
label: String(episode?.label || ""),
|
||||||
|
players: {
|
||||||
|
onwave: onwave?.link ? String(onwave.link) : "",
|
||||||
|
onwaveMirror: mirror?.link ? String(mirror.link) : "",
|
||||||
|
kodik: kodik?.link ? String(kodik.link) : ""
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
href: _packEpisode(payload),
|
||||||
|
number,
|
||||||
|
title: episode?.label ? String(episode.label) : `Episode ${number}`
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort((a, b) => Number(a.number) - Number(b.number));
|
||||||
|
|
||||||
|
return JSON.stringify(out);
|
||||||
|
} catch (_) {
|
||||||
|
return JSON.stringify([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function extractStreamUrl(href) {
|
||||||
|
try {
|
||||||
|
const payload = _unpackEpisode(href);
|
||||||
|
if (!payload) {
|
||||||
|
return JSON.stringify({ streams: [], subtitle: DEFAULT_SUBTITLE });
|
||||||
|
}
|
||||||
|
|
||||||
|
const streams = [];
|
||||||
|
|
||||||
|
const onwaveMp4 = _buildOnWaveMp4(payload?.players?.onwave);
|
||||||
|
if (onwaveMp4) {
|
||||||
|
streams.push({
|
||||||
|
title: _streamTitle("onwave"),
|
||||||
|
streamUrl: onwaveMp4,
|
||||||
|
headers: {
|
||||||
|
"User-Agent": _ua(),
|
||||||
|
"Referer": "https://anisurf.site/"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const mirrorMp4 = _buildOnWaveMp4(payload?.players?.onwaveMirror);
|
||||||
|
if (mirrorMp4) {
|
||||||
|
streams.push({
|
||||||
|
title: _streamTitle("mirror"),
|
||||||
|
streamUrl: mirrorMp4,
|
||||||
|
headers: {
|
||||||
|
"User-Agent": _ua(),
|
||||||
|
"Referer": "https://anisurf.ru/"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const kodikLink = _absUrl(payload?.players?.kodik || "", SITE_BASE);
|
||||||
|
if (kodikLink && (kodikLink.includes("kodikplayer.com") || kodikLink.includes("kodik.info"))) {
|
||||||
|
try {
|
||||||
|
const qualitiesJson = await kodikParser(kodikLink);
|
||||||
|
const qualities = _safeJsonParse(qualitiesJson, {});
|
||||||
|
_appendBestKodikStream(streams, qualities);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.stringify({
|
||||||
|
streams,
|
||||||
|
subtitle: DEFAULT_SUBTITLE
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
return JSON.stringify({ streams: [], subtitle: DEFAULT_SUBTITLE });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kodik parser adapted from yummyanime.js approach.
|
||||||
|
async function kodikParser(url) {
|
||||||
|
try {
|
||||||
|
const headers = {
|
||||||
|
"Referer": SITE_BASE + "/",
|
||||||
|
"User-Agent": _ua()
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetchv2(url, headers);
|
||||||
|
const htmlText = await response.text();
|
||||||
|
|
||||||
|
const urlParamsMatch = htmlText.match(/var\s+urlParams\s*=\s*'([^']+)'/);
|
||||||
|
const videoInfoTypeMatch = htmlText.match(/vInfo\.type\s*=\s*'([^']+)'/);
|
||||||
|
const videoInfoHashMatch = htmlText.match(/vInfo\.hash\s*=\s*'([^']+)'/);
|
||||||
|
const videoInfoIdMatch = htmlText.match(/vInfo\.id\s*=\s*'([^']+)'/);
|
||||||
|
|
||||||
|
const urlParams = urlParamsMatch ? _safeJsonParse(urlParamsMatch[1], {}) : {};
|
||||||
|
const videoInfoType = videoInfoTypeMatch ? videoInfoTypeMatch[1] : "";
|
||||||
|
const videoInfoHash = videoInfoHashMatch ? videoInfoHashMatch[1] : "";
|
||||||
|
const videoInfoId = videoInfoIdMatch ? videoInfoIdMatch[1] : "";
|
||||||
|
|
||||||
|
const finalData =
|
||||||
|
`d=${urlParams.d || ""}` +
|
||||||
|
`&d_sign=${urlParams.d_sign || ""}` +
|
||||||
|
`&pd=${urlParams.pd || ""}` +
|
||||||
|
`&pd_sign=${urlParams.pd_sign || ""}` +
|
||||||
|
`&ref=${urlParams.ref || ""}` +
|
||||||
|
`&ref_sign=${urlParams.ref_sign || ""}` +
|
||||||
|
`&bad_user=false&cdn_is_working=true` +
|
||||||
|
`&type=${videoInfoType}&hash=${videoInfoHash}&id=${videoInfoId}&info=%7B%7D`;
|
||||||
|
|
||||||
|
const headers2 = {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
|
||||||
|
"Referer": SITE_BASE + "/",
|
||||||
|
"User-Agent": _ua(),
|
||||||
|
"X-Requested-With": "XMLHttpRequest"
|
||||||
|
};
|
||||||
|
|
||||||
|
const apiResponse = await fetchv2("https://kodikplayer.com/ftor", headers2, "POST", finalData);
|
||||||
|
const apiJson = await apiResponse.json();
|
||||||
|
|
||||||
|
const qualities = {};
|
||||||
|
if (apiJson?.links) {
|
||||||
|
for (const quality in apiJson.links) {
|
||||||
|
const qualityArray = apiJson.links[quality];
|
||||||
|
const first = Array.isArray(qualityArray) ? qualityArray[0] : null;
|
||||||
|
if (!first?.src) continue;
|
||||||
|
|
||||||
|
qualities[quality] = {
|
||||||
|
src: decode(first.src),
|
||||||
|
type: first.type || "application/x-mpegURL"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.stringify(qualities, null, 2);
|
||||||
|
} catch (_) {
|
||||||
|
return JSON.stringify({ error: "kodik_parse_failed" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function decode(input) {
|
||||||
|
const map = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||||
|
let out = "";
|
||||||
|
let buffer = 0;
|
||||||
|
let count = 0;
|
||||||
|
|
||||||
|
const rotated = [];
|
||||||
|
const source = String(input || "");
|
||||||
|
|
||||||
|
for (let i = 0; i < source.length; i++) {
|
||||||
|
const ch = source[i];
|
||||||
|
if (/[a-zA-Z]/.test(ch)) {
|
||||||
|
const code = ch.charCodeAt(0);
|
||||||
|
const max = ch <= "Z" ? 90 : 122;
|
||||||
|
const shifted = code + 18;
|
||||||
|
rotated.push(String.fromCharCode(shifted <= max ? shifted : shifted - 26));
|
||||||
|
} else {
|
||||||
|
rotated.push(ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rot = rotated.join("");
|
||||||
|
for (let j = 0; j < rot.length; j++) {
|
||||||
|
const ch = rot[j];
|
||||||
|
if (ch === "=") break;
|
||||||
|
const val = map.indexOf(ch);
|
||||||
|
if (val === -1) continue;
|
||||||
|
|
||||||
|
buffer = (buffer << 6) | val;
|
||||||
|
count += 6;
|
||||||
|
|
||||||
|
if (count >= 8) {
|
||||||
|
count -= 8;
|
||||||
|
out += String.fromCharCode((buffer >> count) & 0xff);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _defaultExport() {
|
||||||
|
return {
|
||||||
|
searchResults,
|
||||||
|
extractDetails,
|
||||||
|
extractEpisodes,
|
||||||
|
extractStreamUrl
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
globalThis.default = _defaultExport;
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.default = _defaultExport;
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
globalThis.module = globalThis.module || {};
|
||||||
|
globalThis.module.exports = { default: _defaultExport };
|
||||||
|
} catch (_) {}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"sourceName": "OnWave",
|
||||||
|
"iconUrl": "https://onwavedub.org/logo.png",
|
||||||
|
"author": {
|
||||||
|
"name": "emp0ry",
|
||||||
|
"icon": "https://avatars.githubusercontent.com/u/64217088"
|
||||||
|
},
|
||||||
|
"version": "1.0.0",
|
||||||
|
"language": "Russian",
|
||||||
|
"streamType": "MP4/HLS",
|
||||||
|
"quality": "1080p",
|
||||||
|
"baseUrl": "https://onwavedub.org/api/v1",
|
||||||
|
"searchBaseUrl": "https://onwavedub.org/api/v1/catalog?query=%s",
|
||||||
|
"scriptUrl": "https://git.luna-app.eu/50n50/sources/raw/branch/main/onwave/onwave.js",
|
||||||
|
"asyncJS": true,
|
||||||
|
"streamAsyncJS": true,
|
||||||
|
"softsub": false,
|
||||||
|
"type": "anime",
|
||||||
|
"downloadSupport": false,
|
||||||
|
"supportsMojuru": true,
|
||||||
|
"supportsDartotsu": true,
|
||||||
|
"supportsSora": true,
|
||||||
|
"supportsLuna": true
|
||||||
|
}
|
||||||
@@ -0,0 +1,467 @@
|
|||||||
|
const BASE_URL = "https://sameband.studio";
|
||||||
|
const SEARCH_URL = BASE_URL + "/";
|
||||||
|
|
||||||
|
function _ua() {
|
||||||
|
return "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36";
|
||||||
|
}
|
||||||
|
|
||||||
|
function _safeJsonParse(value, fallback) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(value);
|
||||||
|
} catch (_) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _htmlDecode(value) {
|
||||||
|
const s = String(value || "");
|
||||||
|
if (!s) return "";
|
||||||
|
|
||||||
|
const named = {
|
||||||
|
"&": "&",
|
||||||
|
"<": "<",
|
||||||
|
">": ">",
|
||||||
|
""": '"',
|
||||||
|
"'": "'",
|
||||||
|
"'": "'",
|
||||||
|
" ": " ",
|
||||||
|
"«": "<<",
|
||||||
|
"»": ">>"
|
||||||
|
};
|
||||||
|
|
||||||
|
const withNamed = s.replace(/&(amp|lt|gt|quot|#39|apos|nbsp|laquo|raquo);/g, m => named[m] || m);
|
||||||
|
const withDec = withNamed.replace(/&#(\d+);/g, (_, d) => {
|
||||||
|
const code = parseInt(d, 10);
|
||||||
|
return Number.isFinite(code) ? String.fromCharCode(code) : "";
|
||||||
|
});
|
||||||
|
|
||||||
|
return withDec.replace(/&#x([0-9a-f]+);/gi, (_, h) => {
|
||||||
|
const code = parseInt(h, 16);
|
||||||
|
return Number.isFinite(code) ? String.fromCharCode(code) : "";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function _stripTags(value) {
|
||||||
|
return _htmlDecode(String(value || "")
|
||||||
|
.replace(/<br\s*\/?>/gi, "\n")
|
||||||
|
.replace(/<\/p>/gi, "\n")
|
||||||
|
.replace(/<[^>]+>/g, " ")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function _absUrl(url, base) {
|
||||||
|
const raw = String(url || "").trim();
|
||||||
|
if (!raw) return "";
|
||||||
|
if (raw.startsWith("http://") || raw.startsWith("https://")) return raw;
|
||||||
|
if (raw.startsWith("//")) return "https:" + raw;
|
||||||
|
|
||||||
|
const root = String(base || BASE_URL).replace(/\/+$/, "");
|
||||||
|
return root + "/" + raw.replace(/^\/+/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function _attr(tag, name) {
|
||||||
|
const block = String(tag || "");
|
||||||
|
if (!block) return "";
|
||||||
|
|
||||||
|
const quoted = new RegExp(name + "\\s*=\\s*(['\"])(.*?)\\1", "i").exec(block);
|
||||||
|
if (quoted && quoted[2]) return quoted[2];
|
||||||
|
|
||||||
|
const plain = new RegExp(name + "\\s*=\\s*([^\\s>]+)", "i").exec(block);
|
||||||
|
return plain && plain[1] ? plain[1] : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function _extractMeta(html, attrName, attrValue) {
|
||||||
|
const src = String(html || "");
|
||||||
|
const re = new RegExp(
|
||||||
|
"<meta[^>]*" + attrName + "=['\"]" + attrValue + "['\"][^>]*content=['\"]([^'\"]+)['\"][^>]*>",
|
||||||
|
"i"
|
||||||
|
);
|
||||||
|
const m = src.match(re);
|
||||||
|
return m && m[1] ? _htmlDecode(m[1]).trim() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function _packEpisode(payload) {
|
||||||
|
return "sameband:" + encodeURIComponent(JSON.stringify(payload || {}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function _unpackEpisode(href) {
|
||||||
|
const raw = String(href || "");
|
||||||
|
if (!raw.startsWith("sameband:")) return null;
|
||||||
|
return _safeJsonParse(decodeURIComponent(raw.slice("sameband:".length)), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _searchHeaders() {
|
||||||
|
return {
|
||||||
|
"User-Agent": _ua(),
|
||||||
|
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||||
|
"Accept-Language": "ru-RU,ru;q=0.9,en;q=0.8",
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
|
||||||
|
"Origin": BASE_URL,
|
||||||
|
"Referer": SEARCH_URL
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function _htmlHeaders(referer) {
|
||||||
|
return {
|
||||||
|
"User-Agent": _ua(),
|
||||||
|
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||||
|
"Accept-Language": "ru-RU,ru;q=0.9,en;q=0.8",
|
||||||
|
"Referer": referer || SEARCH_URL,
|
||||||
|
"Origin": BASE_URL
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _postSearch(query) {
|
||||||
|
const body =
|
||||||
|
"story=" + encodeURIComponent(String(query || "")) +
|
||||||
|
"&do=search&subaction=search";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const r = await fetchv2(SEARCH_URL, _searchHeaders(), "POST", body);
|
||||||
|
const txt = await r.text();
|
||||||
|
if (txt && txt.includes("class=\"poster\"")) return txt;
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
const fallback = await fetchv2(BASE_URL + "/index.php?do=search", _searchHeaders(), "POST", body);
|
||||||
|
return fallback.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
function _parseSearchResults(html) {
|
||||||
|
const src = String(html || "");
|
||||||
|
const out = [];
|
||||||
|
const seen = new Set();
|
||||||
|
|
||||||
|
const articleRegex = /<article[^>]*class=["'][^"']*shortstory[^"']*["'][^>]*>[\s\S]*?<\/article>/gi;
|
||||||
|
const blocks = src.match(articleRegex) || [];
|
||||||
|
|
||||||
|
for (const block of blocks) {
|
||||||
|
const posterTag = (block.match(/<div[^>]*class=["'][^"']*poster[^"']*["'][^>]*>/i) || [""])[0];
|
||||||
|
const imageLinkTag = (block.match(/<a[^>]*class=["'][^"']*image[^"']*["'][^>]*>/i) || [""])[0];
|
||||||
|
const infoTitleMatch = block.match(/<div[^>]*class=["'][^"']*info-title[^"']*["'][^>]*>([\s\S]*?)<\/div>/i);
|
||||||
|
const imgTag = (block.match(/<img[^>]*>/i) || [""])[0];
|
||||||
|
|
||||||
|
const href = _absUrl(_attr(imageLinkTag, "href"), BASE_URL);
|
||||||
|
const titleRaw = _attr(posterTag, "title") || (infoTitleMatch ? infoTitleMatch[1] : "");
|
||||||
|
const title = _stripTags(titleRaw);
|
||||||
|
const image = _absUrl(_attr(imgTag, "src") || _attr(imgTag, "data-src"), BASE_URL);
|
||||||
|
|
||||||
|
if (!href || !title || seen.has(href)) continue;
|
||||||
|
seen.add(href);
|
||||||
|
|
||||||
|
out.push({
|
||||||
|
title,
|
||||||
|
image,
|
||||||
|
href
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _extractAnimeUrl(input) {
|
||||||
|
const raw = String(input || "").trim();
|
||||||
|
if (!raw) return "";
|
||||||
|
|
||||||
|
const packed = _unpackEpisode(raw);
|
||||||
|
if (packed && packed.animeUrl) return _absUrl(packed.animeUrl, BASE_URL);
|
||||||
|
|
||||||
|
return _absUrl(raw, BASE_URL);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _bestYearCandidate(html) {
|
||||||
|
const src = String(html || "");
|
||||||
|
const years = [];
|
||||||
|
const regex = /\b(19\d{2}|20\d{2})\b/g;
|
||||||
|
let m;
|
||||||
|
|
||||||
|
while ((m = regex.exec(src)) !== null) {
|
||||||
|
const year = parseInt(m[1], 10);
|
||||||
|
if (year >= 1950 && year <= (new Date().getFullYear() + 1)) {
|
||||||
|
years.push(year);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!years.length) return "Unknown";
|
||||||
|
return String(Math.min.apply(null, years));
|
||||||
|
}
|
||||||
|
|
||||||
|
function _extractIframeSrc(html) {
|
||||||
|
const src = String(html || "");
|
||||||
|
|
||||||
|
const inPlayer = src.match(
|
||||||
|
/<div[^>]*class=["'][^"']*player-content[^"']*["'][^>]*>[\s\S]*?<iframe[^>]*src=["']([^"']+)["']/i
|
||||||
|
);
|
||||||
|
if (inPlayer && inPlayer[1]) return inPlayer[1];
|
||||||
|
|
||||||
|
const anyIframe = src.match(/<iframe[^>]*src=["']([^"']+)["']/i);
|
||||||
|
return anyIframe && anyIframe[1] ? anyIframe[1] : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function _buildListCandidates(iframeSrc) {
|
||||||
|
const iframeAbs = _absUrl(iframeSrc, BASE_URL);
|
||||||
|
const candidates = [];
|
||||||
|
|
||||||
|
const add = (u) => {
|
||||||
|
const v = String(u || "").trim();
|
||||||
|
if (!v) return;
|
||||||
|
if (!candidates.includes(v)) candidates.push(v);
|
||||||
|
};
|
||||||
|
|
||||||
|
const playMatch = iframeAbs.match(/\/v\/play\/([^/?#]+)\.html/i);
|
||||||
|
if (playMatch && playMatch[1]) {
|
||||||
|
const fileName = decodeURIComponent(playMatch[1]);
|
||||||
|
const withUnderscores = `${BASE_URL}/v/list/${fileName}_list.txt`;
|
||||||
|
const withSpaces = `${BASE_URL}/v/list/${fileName.replace(/_/g, " ")}_list.txt`;
|
||||||
|
|
||||||
|
add(withUnderscores);
|
||||||
|
add(encodeURI(withUnderscores));
|
||||||
|
add(withSpaces);
|
||||||
|
add(encodeURI(withSpaces));
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidates;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _fetchPlaylistArray(candidates, referer) {
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
try {
|
||||||
|
const r = await fetchv2(candidate, _htmlHeaders(referer || SEARCH_URL));
|
||||||
|
const txt = await r.text();
|
||||||
|
const parsed = _safeJsonParse(txt, null);
|
||||||
|
if (Array.isArray(parsed) && parsed.length) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function _extractEpisodeTitle(titleHtml, fallbackNum) {
|
||||||
|
const clean = _stripTags(titleHtml || "").replace(/\b\d{1,2}:\d{2}\b/g, "").trim();
|
||||||
|
return clean || `Episode ${fallbackNum}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _extractEpisodeNumber(label, fallbackNum) {
|
||||||
|
const m = String(label || "").match(/(\d{1,4})/);
|
||||||
|
if (!m || !m[1]) return fallbackNum;
|
||||||
|
|
||||||
|
const n = parseInt(m[1], 10);
|
||||||
|
return Number.isFinite(n) ? n : fallbackNum;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _parseFileVariants(fileField) {
|
||||||
|
const raw = String(fileField || "").trim();
|
||||||
|
if (!raw) return [];
|
||||||
|
|
||||||
|
const out = [];
|
||||||
|
const parts = raw.split(",");
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
const item = String(part || "").trim();
|
||||||
|
if (!item) continue;
|
||||||
|
|
||||||
|
const m = item.match(/^\[([^\]]+)\](.+)$/);
|
||||||
|
const qualityLabel = m && m[1] ? String(m[1]).trim() : "";
|
||||||
|
const path = m && m[2] ? String(m[2]).trim() : item;
|
||||||
|
|
||||||
|
const abs = _absUrl(path, BASE_URL);
|
||||||
|
if (!abs) continue;
|
||||||
|
|
||||||
|
const q = parseInt(qualityLabel.replace(/[^\d]/g, ""), 10) || 0;
|
||||||
|
out.push({
|
||||||
|
quality: q,
|
||||||
|
label: qualityLabel,
|
||||||
|
url: encodeURI(abs)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
out.sort((a, b) => b.quality - a.quality);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchResults(keyword) {
|
||||||
|
try {
|
||||||
|
const query = String(keyword || "").trim();
|
||||||
|
if (!query) return JSON.stringify([]);
|
||||||
|
|
||||||
|
const html = await _postSearch(query);
|
||||||
|
const results = _parseSearchResults(html);
|
||||||
|
return JSON.stringify(results);
|
||||||
|
} catch (_) {
|
||||||
|
return JSON.stringify([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function extractDetails(url) {
|
||||||
|
try {
|
||||||
|
const animeUrl = _extractAnimeUrl(url);
|
||||||
|
if (!animeUrl) return JSON.stringify([]);
|
||||||
|
|
||||||
|
const r = await fetchv2(animeUrl, _htmlHeaders(SEARCH_URL));
|
||||||
|
const html = await r.text();
|
||||||
|
|
||||||
|
const descMatch = html.match(
|
||||||
|
/<div[^>]*class=["'][^"']*description[^"']*["'][^>]*>[\s\S]*?<div[^>]*class=["'][^"']*limiter[^"']*["'][^>]*>([\s\S]*?)<\/div>/i
|
||||||
|
);
|
||||||
|
|
||||||
|
const description = _stripTags(
|
||||||
|
(descMatch && descMatch[1]) || _extractMeta(html, "name", "description") || "No description available"
|
||||||
|
) || "No description available";
|
||||||
|
|
||||||
|
let aliases = _extractMeta(html, "property", "og:title") || "";
|
||||||
|
aliases = aliases.replace(/\s*(?:>|\u00BB)+\s*SameBand\s*$/i, "").trim();
|
||||||
|
|
||||||
|
return JSON.stringify([
|
||||||
|
{
|
||||||
|
description,
|
||||||
|
aliases: aliases || "SameBand",
|
||||||
|
airdate: _bestYearCandidate(html)
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
} catch (_) {
|
||||||
|
return JSON.stringify([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function extractEpisodes(url) {
|
||||||
|
try {
|
||||||
|
const animeUrl = _extractAnimeUrl(url);
|
||||||
|
if (!animeUrl) return JSON.stringify([]);
|
||||||
|
|
||||||
|
const page = await fetchv2(animeUrl, _htmlHeaders(SEARCH_URL));
|
||||||
|
const html = await page.text();
|
||||||
|
|
||||||
|
const iframeSrc = _extractIframeSrc(html);
|
||||||
|
if (!iframeSrc) return JSON.stringify([]);
|
||||||
|
|
||||||
|
const candidates = _buildListCandidates(iframeSrc);
|
||||||
|
const playlist = await _fetchPlaylistArray(candidates, animeUrl);
|
||||||
|
if (!playlist.length) return JSON.stringify([]);
|
||||||
|
|
||||||
|
const episodes = playlist.map((item, index) => {
|
||||||
|
const titleHtml = String(item?.title || "");
|
||||||
|
const title = _extractEpisodeTitle(titleHtml, index + 1);
|
||||||
|
const number = _extractEpisodeNumber(title, index + 1);
|
||||||
|
|
||||||
|
const imgTag = (titleHtml.match(/<img[^>]*>/i) || [""])[0];
|
||||||
|
const image = _absUrl(_attr(imgTag, "src"), BASE_URL);
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
animeUrl,
|
||||||
|
file: String(item?.file || ""),
|
||||||
|
thumbnails: String(item?.thumbnails || ""),
|
||||||
|
title,
|
||||||
|
image
|
||||||
|
};
|
||||||
|
|
||||||
|
const out = {
|
||||||
|
href: _packEpisode(payload),
|
||||||
|
number,
|
||||||
|
title
|
||||||
|
};
|
||||||
|
|
||||||
|
if (image) out.image = image;
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
|
||||||
|
episodes.sort((a, b) => Number(a.number) - Number(b.number));
|
||||||
|
return JSON.stringify(episodes);
|
||||||
|
} catch (_) {
|
||||||
|
return JSON.stringify([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function extractStreamUrl(href) {
|
||||||
|
try {
|
||||||
|
const payload = _unpackEpisode(href);
|
||||||
|
const fileField = payload?.file ? String(payload.file) : "";
|
||||||
|
if (!fileField) {
|
||||||
|
return JSON.stringify({ streams: [], subtitle: "https://none.com" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const variants = _parseFileVariants(fileField);
|
||||||
|
if (!variants.length) {
|
||||||
|
return JSON.stringify({ streams: [], subtitle: "https://none.com" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const byQuality = {
|
||||||
|
1080: "",
|
||||||
|
720: "",
|
||||||
|
480: ""
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const v of variants) {
|
||||||
|
if ((v.quality === 1080 || v.quality === 720 || v.quality === 480) && !byQuality[v.quality]) {
|
||||||
|
byQuality[v.quality] = v.url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const url1080 = byQuality[1080] || null;
|
||||||
|
const url720 = byQuality[720] || null;
|
||||||
|
const url480 = byQuality[480] || null;
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
"User-Agent": _ua(),
|
||||||
|
"Referer": payload?.animeUrl ? String(payload.animeUrl) : (BASE_URL + "/"),
|
||||||
|
"Origin": BASE_URL
|
||||||
|
};
|
||||||
|
|
||||||
|
const streams = [];
|
||||||
|
|
||||||
|
if (url1080) {
|
||||||
|
streams.push({
|
||||||
|
title: "1080p",
|
||||||
|
streamUrl: url1080,
|
||||||
|
url1080,
|
||||||
|
url720,
|
||||||
|
url480,
|
||||||
|
headers
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url720) {
|
||||||
|
streams.push({
|
||||||
|
title: "720p",
|
||||||
|
streamUrl: url720,
|
||||||
|
url1080,
|
||||||
|
url720,
|
||||||
|
url480,
|
||||||
|
headers
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url480) {
|
||||||
|
streams.push({
|
||||||
|
title: "480p",
|
||||||
|
streamUrl: url480,
|
||||||
|
url1080,
|
||||||
|
url720,
|
||||||
|
url480,
|
||||||
|
headers
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!streams.length) {
|
||||||
|
const first = variants[0];
|
||||||
|
if (first?.url) {
|
||||||
|
streams.push({
|
||||||
|
title: "1080p",
|
||||||
|
streamUrl: first.url,
|
||||||
|
url1080: first.url,
|
||||||
|
url720: null,
|
||||||
|
url480: null,
|
||||||
|
headers
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.stringify({
|
||||||
|
streams,
|
||||||
|
subtitle: "https://none.com"
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
return JSON.stringify({ streams: [], subtitle: "https://none.com" });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"sourceName": "SameBand",
|
||||||
|
"iconUrl": "https://sameband.studio/templates/sameband/dleimages/favicon/touch-icon-ipad.png",
|
||||||
|
"author": {
|
||||||
|
"name": "emp0ry",
|
||||||
|
"icon": "https://avatars.githubusercontent.com/u/64217088"
|
||||||
|
},
|
||||||
|
"version": "1.0.0",
|
||||||
|
"language": "Russian",
|
||||||
|
"streamType": "HLS",
|
||||||
|
"quality": "1080p",
|
||||||
|
"baseUrl": "https://sameband.studio/",
|
||||||
|
"searchBaseUrl": "https://sameband.studio/",
|
||||||
|
"scriptUrl": "https://git.luna-app.eu/50n50/sources/raw/branch/main/sameband/sameband.js",
|
||||||
|
"asyncJS": true,
|
||||||
|
"streamAsyncJS": true,
|
||||||
|
"softsub": false,
|
||||||
|
"type": "anime",
|
||||||
|
"downloadSupport": false,
|
||||||
|
"supportsMojuru": true,
|
||||||
|
"supportsDartotsu": true,
|
||||||
|
"supportsSora": true,
|
||||||
|
"supportsLuna": true
|
||||||
|
}
|
||||||
@@ -0,0 +1,687 @@
|
|||||||
|
// ShizaProject module for Sora (AsyncJS)
|
||||||
|
// Author: emp0ry
|
||||||
|
// Version: 1.0.0
|
||||||
|
|
||||||
|
const SITE_BASES = [
|
||||||
|
"https://shizaproject.com",
|
||||||
|
"https://shiza-project.com"
|
||||||
|
];
|
||||||
|
|
||||||
|
const SITE_BASE = SITE_BASES[0];
|
||||||
|
const DEFAULT_SUBTITLE = "https://none.com";
|
||||||
|
|
||||||
|
function _ua() {
|
||||||
|
return "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36";
|
||||||
|
}
|
||||||
|
|
||||||
|
function _safeJsonParse(value, fallback) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(value);
|
||||||
|
} catch (_) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _cleanBase(base) {
|
||||||
|
const raw = String(base || SITE_BASE).trim().replace(/\/+$/, "");
|
||||||
|
return raw || SITE_BASE;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _normalizeUrl(url) {
|
||||||
|
const raw = String(url || "").trim();
|
||||||
|
if (!raw) return "";
|
||||||
|
if (raw.startsWith("//")) return "https:" + raw;
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _packRelease(payload) {
|
||||||
|
return "shizaproject-release:" + encodeURIComponent(JSON.stringify(payload || {}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function _unpackRelease(href) {
|
||||||
|
const raw = String(href || "");
|
||||||
|
if (!raw.startsWith("shizaproject-release:")) return null;
|
||||||
|
return _safeJsonParse(decodeURIComponent(raw.slice("shizaproject-release:".length)), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _packEpisode(payload) {
|
||||||
|
return "shizaproject:" + encodeURIComponent(JSON.stringify(payload || {}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function _unpackEpisode(href) {
|
||||||
|
const raw = String(href || "");
|
||||||
|
if (!raw.startsWith("shizaproject:")) return null;
|
||||||
|
return _safeJsonParse(decodeURIComponent(raw.slice("shizaproject:".length)), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _extractSlug(input) {
|
||||||
|
const packed = _unpackRelease(input);
|
||||||
|
if (packed?.slug) return String(packed.slug);
|
||||||
|
|
||||||
|
const raw = String(input || "").trim();
|
||||||
|
if (!raw) return "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const u = new URL(raw);
|
||||||
|
const parts = u.pathname.split("/").filter(Boolean);
|
||||||
|
return parts[parts.length - 1] || raw;
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _extractPreferredBase(input) {
|
||||||
|
const packed = _unpackRelease(input);
|
||||||
|
if (packed?.siteBase) return _cleanBase(packed.siteBase);
|
||||||
|
|
||||||
|
const raw = String(input || "").trim();
|
||||||
|
if (!raw) return SITE_BASE;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const u = new URL(raw);
|
||||||
|
return _cleanBase(u.origin);
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
return SITE_BASE;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _orderedBases(preferredBase) {
|
||||||
|
const out = [];
|
||||||
|
|
||||||
|
const add = (base) => {
|
||||||
|
const clean = _cleanBase(base);
|
||||||
|
if (clean && !out.includes(clean)) out.push(clean);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (preferredBase) add(preferredBase);
|
||||||
|
for (const base of SITE_BASES) add(base);
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _graphqlHeaders(base) {
|
||||||
|
const site = _cleanBase(base);
|
||||||
|
|
||||||
|
return {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json",
|
||||||
|
"User-Agent": _ua(),
|
||||||
|
"Origin": site,
|
||||||
|
"Referer": site + "/"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _graphqlOnBase(base, operationName, variables, query) {
|
||||||
|
const site = _cleanBase(base);
|
||||||
|
const body = JSON.stringify({ operationName, variables, query });
|
||||||
|
|
||||||
|
const response = await fetchv2(site + "/graphql", _graphqlHeaders(site), "POST", body);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data?.errors?.length) throw new Error("GraphQL error");
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: data?.data || {},
|
||||||
|
siteBase: site
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _graphql(operationName, variables, query, preferredBase) {
|
||||||
|
let lastError = null;
|
||||||
|
|
||||||
|
for (const base of _orderedBases(preferredBase)) {
|
||||||
|
try {
|
||||||
|
return await _graphqlOnBase(base, operationName, variables, query);
|
||||||
|
} catch (e) {
|
||||||
|
lastError = e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError || new Error("All ShizaProject domains failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEARCH_QUERY = `
|
||||||
|
query search($query: String!, $type: SearchType!) {
|
||||||
|
search(query: $query, type: $type, first: 10) {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
id
|
||||||
|
__typename
|
||||||
|
... on Release {
|
||||||
|
id
|
||||||
|
slug
|
||||||
|
name
|
||||||
|
originalName
|
||||||
|
airedOn
|
||||||
|
releasedOn
|
||||||
|
publishedAt
|
||||||
|
announcement
|
||||||
|
episodesCount
|
||||||
|
episodesAired
|
||||||
|
episodeDuration
|
||||||
|
season
|
||||||
|
seasonYear
|
||||||
|
seasonNumber
|
||||||
|
status
|
||||||
|
activity
|
||||||
|
type
|
||||||
|
rating
|
||||||
|
viewCount
|
||||||
|
score
|
||||||
|
genres {
|
||||||
|
id
|
||||||
|
slug
|
||||||
|
name
|
||||||
|
__typename
|
||||||
|
}
|
||||||
|
__typename
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const RELEASE_QUERY = `
|
||||||
|
query releaseAllSafe($slug: String!) {
|
||||||
|
release(slug: $slug) {
|
||||||
|
__typename
|
||||||
|
id
|
||||||
|
slug
|
||||||
|
name
|
||||||
|
originalName
|
||||||
|
airedOn
|
||||||
|
releasedOn
|
||||||
|
publishedAt
|
||||||
|
announcement
|
||||||
|
episodesCount
|
||||||
|
episodesAired
|
||||||
|
episodeDuration
|
||||||
|
season
|
||||||
|
seasonYear
|
||||||
|
seasonNumber
|
||||||
|
status
|
||||||
|
activity
|
||||||
|
type
|
||||||
|
rating
|
||||||
|
viewCount
|
||||||
|
score
|
||||||
|
genres {
|
||||||
|
id
|
||||||
|
slug
|
||||||
|
name
|
||||||
|
__typename
|
||||||
|
}
|
||||||
|
episodes {
|
||||||
|
__typename
|
||||||
|
id
|
||||||
|
number
|
||||||
|
name
|
||||||
|
duration
|
||||||
|
videos {
|
||||||
|
id
|
||||||
|
embedUrl
|
||||||
|
__typename
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
|
||||||
|
async function _getRelease(slug, preferredBase) {
|
||||||
|
const result = await _graphql(
|
||||||
|
"releaseAllSafe",
|
||||||
|
{ slug: String(slug || "") },
|
||||||
|
RELEASE_QUERY,
|
||||||
|
preferredBase
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
release: result?.data?.release || null,
|
||||||
|
siteBase: result?.siteBase || _cleanBase(preferredBase)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function _releaseAliases(release) {
|
||||||
|
const parts = [];
|
||||||
|
|
||||||
|
if (release?.originalName) parts.push(`Original: ${release.originalName}`);
|
||||||
|
if (release?.type) parts.push(`Type: ${release.type}`);
|
||||||
|
if (Number.isFinite(release?.episodesCount)) parts.push(`Episodes: ${release.episodesCount}`);
|
||||||
|
if (Number.isFinite(release?.episodeDuration)) parts.push(`Duration: ${release.episodeDuration}m`);
|
||||||
|
if (release?.announcement) parts.push(String(release.announcement));
|
||||||
|
|
||||||
|
if (Array.isArray(release?.genres) && release.genres.length) {
|
||||||
|
parts.push(`Genres: ${release.genres.map(g => g?.name).filter(Boolean).join(", ")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join(" | ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function _scoreTitle(title, keyword) {
|
||||||
|
const t = String(title || "").toLowerCase().trim();
|
||||||
|
const k = String(keyword || "").toLowerCase().trim();
|
||||||
|
|
||||||
|
if (!t || !k) return 99;
|
||||||
|
if (t === k) return 0;
|
||||||
|
if (t.startsWith(k)) return 1;
|
||||||
|
if (t.includes(k)) return 2;
|
||||||
|
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// Search -> JSON string
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
async function searchResults(keyword) {
|
||||||
|
try {
|
||||||
|
const query = String(keyword || "").trim();
|
||||||
|
if (!query) return JSON.stringify([]);
|
||||||
|
|
||||||
|
const result = await _graphql("search", { query, type: "RELEASE" }, SEARCH_QUERY);
|
||||||
|
const siteBase = result?.siteBase || SITE_BASE;
|
||||||
|
const edges = Array.isArray(result?.data?.search?.edges) ? result.data.search.edges : [];
|
||||||
|
|
||||||
|
const out = edges
|
||||||
|
.map(edge => edge?.node)
|
||||||
|
.filter(node => node?.__typename === "Release" && node?.slug)
|
||||||
|
.map(release => {
|
||||||
|
const title = release?.name || release?.originalName || "Unknown title";
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
href: _packRelease({
|
||||||
|
slug: release.slug,
|
||||||
|
id: release.id,
|
||||||
|
title,
|
||||||
|
originalName: release?.originalName || "",
|
||||||
|
airedOn: release?.airedOn || release?.releasedOn || "",
|
||||||
|
siteBase
|
||||||
|
}),
|
||||||
|
_score: Math.min(
|
||||||
|
_scoreTitle(release?.name || "", query),
|
||||||
|
_scoreTitle(release?.originalName || "", query)
|
||||||
|
)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
out.sort((a, b) => a._score - b._score);
|
||||||
|
return JSON.stringify(out.map(({ _score, ...rest }) => rest));
|
||||||
|
} catch (_) {
|
||||||
|
return JSON.stringify([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// Details -> JSON string
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
async function extractDetails(href) {
|
||||||
|
try {
|
||||||
|
const slug = _extractSlug(href);
|
||||||
|
const preferredBase = _extractPreferredBase(href);
|
||||||
|
|
||||||
|
if (!slug) return JSON.stringify([]);
|
||||||
|
|
||||||
|
const result = await _getRelease(slug, preferredBase);
|
||||||
|
const release = result?.release;
|
||||||
|
|
||||||
|
if (!release) return JSON.stringify([]);
|
||||||
|
|
||||||
|
const titleLine = release?.originalName && release?.originalName !== release?.name
|
||||||
|
? `${release.name} / ${release.originalName}`
|
||||||
|
: (release?.name || release?.originalName || "ShizaProject");
|
||||||
|
|
||||||
|
const statusLine = [release?.status, release?.activity]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" / ");
|
||||||
|
|
||||||
|
const description = [titleLine, statusLine]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
return JSON.stringify([{
|
||||||
|
description: description || "No description available.",
|
||||||
|
aliases: _releaseAliases(release) || "ShizaProject",
|
||||||
|
airdate: release?.airedOn || release?.releasedOn || "Unknown"
|
||||||
|
}]);
|
||||||
|
} catch (_) {
|
||||||
|
return JSON.stringify([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// Episodes -> JSON string
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
async function extractEpisodes(href) {
|
||||||
|
try {
|
||||||
|
const slug = _extractSlug(href);
|
||||||
|
const preferredBase = _extractPreferredBase(href);
|
||||||
|
|
||||||
|
if (!slug) return JSON.stringify([]);
|
||||||
|
|
||||||
|
const result = await _getRelease(slug, preferredBase);
|
||||||
|
const release = result?.release;
|
||||||
|
const siteBase = result?.siteBase || preferredBase || SITE_BASE;
|
||||||
|
|
||||||
|
if (!release) return JSON.stringify([]);
|
||||||
|
|
||||||
|
const episodes = Array.isArray(release?.episodes) ? release.episodes : [];
|
||||||
|
|
||||||
|
const out = episodes
|
||||||
|
.map((ep, index) => {
|
||||||
|
const videos = Array.isArray(ep?.videos) ? ep.videos : [];
|
||||||
|
|
||||||
|
const embedUrls = videos
|
||||||
|
.map(v => _normalizeUrl(v?.embedUrl || ""))
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
if (!embedUrls.length) return null;
|
||||||
|
|
||||||
|
const number = Number.isFinite(ep?.number) ? ep.number : (index + 1);
|
||||||
|
const title = ep?.name ? String(ep.name) : `Episode ${number}`;
|
||||||
|
|
||||||
|
const entry = {
|
||||||
|
href: _packEpisode({
|
||||||
|
releaseSlug: release.slug,
|
||||||
|
episodeId: ep?.id || "",
|
||||||
|
number,
|
||||||
|
title,
|
||||||
|
embedUrls,
|
||||||
|
fallbackEmbedUrl: embedUrls[0],
|
||||||
|
siteBase
|
||||||
|
}),
|
||||||
|
number,
|
||||||
|
title
|
||||||
|
};
|
||||||
|
|
||||||
|
if (Number.isFinite(ep?.duration)) {
|
||||||
|
entry.duration = ep.duration * 60;
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry;
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort((a, b) => Number(a.number) - Number(b.number));
|
||||||
|
|
||||||
|
return JSON.stringify(out);
|
||||||
|
} catch (_) {
|
||||||
|
return JSON.stringify([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _qualityNumber(key) {
|
||||||
|
const n = parseInt(String(key || "").replace(/[^\d]/g, ""), 10);
|
||||||
|
return Number.isFinite(n) ? n : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _pickQualityUrl(qualities, target) {
|
||||||
|
for (const key in qualities || {}) {
|
||||||
|
const q = _qualityNumber(key);
|
||||||
|
const src = _normalizeUrl(qualities?.[key]?.src || "");
|
||||||
|
if (q === target && src) return src;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _streamHeaders(siteBase) {
|
||||||
|
const site = _cleanBase(siteBase || SITE_BASE);
|
||||||
|
|
||||||
|
return {
|
||||||
|
"User-Agent": _ua(),
|
||||||
|
"Referer": site + "/",
|
||||||
|
"Origin": site
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function _buildStreamsFromQualities(qualities, siteBase) {
|
||||||
|
const url1080 = _pickQualityUrl(qualities, 1080);
|
||||||
|
const url720 = _pickQualityUrl(qualities, 720);
|
||||||
|
const url480 = _pickQualityUrl(qualities, 480);
|
||||||
|
const url360 = _pickQualityUrl(qualities, 360);
|
||||||
|
|
||||||
|
const known = {
|
||||||
|
1080: url1080,
|
||||||
|
720: url720,
|
||||||
|
480: url480,
|
||||||
|
360: url360
|
||||||
|
};
|
||||||
|
|
||||||
|
const headers = _streamHeaders(siteBase);
|
||||||
|
const streams = [];
|
||||||
|
|
||||||
|
for (const quality of [1080, 720, 480, 360]) {
|
||||||
|
const streamUrl = known[quality];
|
||||||
|
if (!streamUrl) continue;
|
||||||
|
|
||||||
|
streams.push({
|
||||||
|
title: `${quality}p`,
|
||||||
|
streamUrl,
|
||||||
|
url1080,
|
||||||
|
url720,
|
||||||
|
url480,
|
||||||
|
url360,
|
||||||
|
headers
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!streams.length) {
|
||||||
|
let bestQuality = 0;
|
||||||
|
let bestUrl = "";
|
||||||
|
|
||||||
|
for (const key in qualities || {}) {
|
||||||
|
const q = _qualityNumber(key);
|
||||||
|
const src = _normalizeUrl(qualities?.[key]?.src || "");
|
||||||
|
if (!src) continue;
|
||||||
|
|
||||||
|
if (q > bestQuality) {
|
||||||
|
bestQuality = q;
|
||||||
|
bestUrl = src;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bestUrl) {
|
||||||
|
streams.push({
|
||||||
|
title: bestQuality ? `${bestQuality}p` : "Kodik",
|
||||||
|
streamUrl: bestUrl,
|
||||||
|
url1080: bestQuality >= 1080 ? bestUrl : null,
|
||||||
|
url720: bestQuality >= 720 && bestQuality < 1080 ? bestUrl : null,
|
||||||
|
url480: bestQuality >= 480 && bestQuality < 720 ? bestUrl : null,
|
||||||
|
url360: bestQuality >= 360 && bestQuality < 480 ? bestUrl : null,
|
||||||
|
headers
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return streams;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _isKodikUrl(url) {
|
||||||
|
const raw = String(url || "").toLowerCase();
|
||||||
|
return raw.includes("kodikplayer.com") || raw.includes("kodik.info");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// Stream -> quality picker JSON
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
async function extractStreamUrl(href) {
|
||||||
|
try {
|
||||||
|
const payload = _unpackEpisode(href);
|
||||||
|
|
||||||
|
if (!payload) {
|
||||||
|
return JSON.stringify({
|
||||||
|
streams: [],
|
||||||
|
subtitle: DEFAULT_SUBTITLE
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const siteBase = _cleanBase(payload?.siteBase || SITE_BASE);
|
||||||
|
|
||||||
|
const embedUrls = Array.isArray(payload?.embedUrls)
|
||||||
|
? payload.embedUrls.map(_normalizeUrl).filter(Boolean)
|
||||||
|
: [_normalizeUrl(payload?.fallbackEmbedUrl)].filter(Boolean);
|
||||||
|
|
||||||
|
const streams = [];
|
||||||
|
|
||||||
|
for (const embedUrl of embedUrls) {
|
||||||
|
if (!_isKodikUrl(embedUrl)) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const qualitiesJson = await kodikParser(embedUrl, siteBase);
|
||||||
|
const qualities = _safeJsonParse(qualitiesJson, {});
|
||||||
|
const parsedStreams = _buildStreamsFromQualities(qualities, siteBase);
|
||||||
|
streams.push(...parsedStreams);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.stringify({
|
||||||
|
streams,
|
||||||
|
subtitle: DEFAULT_SUBTITLE
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
return JSON.stringify({
|
||||||
|
streams: [],
|
||||||
|
subtitle: DEFAULT_SUBTITLE
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kodik parser adapted from yummyanime/onwave-style logic.
|
||||||
|
async function kodikParser(url, siteBase) {
|
||||||
|
try {
|
||||||
|
const site = _cleanBase(siteBase || SITE_BASE);
|
||||||
|
const embedUrl = _normalizeUrl(url);
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
"Referer": site + "/",
|
||||||
|
"User-Agent": _ua()
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetchv2(embedUrl, headers);
|
||||||
|
const htmlText = await response.text();
|
||||||
|
|
||||||
|
const urlParamsMatch = htmlText.match(/var\s+urlParams\s*=\s*'([^']+)'/);
|
||||||
|
const videoInfoTypeMatch = htmlText.match(/vInfo\.type\s*=\s*'([^']+)'/);
|
||||||
|
const videoInfoHashMatch = htmlText.match(/vInfo\.hash\s*=\s*'([^']+)'/);
|
||||||
|
const videoInfoIdMatch = htmlText.match(/vInfo\.id\s*=\s*'([^']+)'/);
|
||||||
|
|
||||||
|
const urlParams = urlParamsMatch ? _safeJsonParse(urlParamsMatch[1], {}) : {};
|
||||||
|
const videoInfoType = videoInfoTypeMatch ? videoInfoTypeMatch[1] : "";
|
||||||
|
const videoInfoHash = videoInfoHashMatch ? videoInfoHashMatch[1] : "";
|
||||||
|
const videoInfoId = videoInfoIdMatch ? videoInfoIdMatch[1] : "";
|
||||||
|
|
||||||
|
const finalData =
|
||||||
|
`d=${urlParams.d || ""}` +
|
||||||
|
`&d_sign=${urlParams.d_sign || ""}` +
|
||||||
|
`&pd=${urlParams.pd || ""}` +
|
||||||
|
`&pd_sign=${urlParams.pd_sign || ""}` +
|
||||||
|
`&ref=${urlParams.ref || ""}` +
|
||||||
|
`&ref_sign=${urlParams.ref_sign || ""}` +
|
||||||
|
`&bad_user=false&cdn_is_working=true` +
|
||||||
|
`&type=${encodeURIComponent(videoInfoType)}` +
|
||||||
|
`&hash=${encodeURIComponent(videoInfoHash)}` +
|
||||||
|
`&id=${encodeURIComponent(videoInfoId)}` +
|
||||||
|
`&info=%7B%7D`;
|
||||||
|
|
||||||
|
const headers2 = {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
|
||||||
|
"Referer": site + "/",
|
||||||
|
"User-Agent": _ua(),
|
||||||
|
"X-Requested-With": "XMLHttpRequest"
|
||||||
|
};
|
||||||
|
|
||||||
|
const apiResponse = await fetchv2(
|
||||||
|
"https://kodikplayer.com/ftor",
|
||||||
|
headers2,
|
||||||
|
"POST",
|
||||||
|
finalData
|
||||||
|
);
|
||||||
|
|
||||||
|
const apiJson = await apiResponse.json();
|
||||||
|
|
||||||
|
const qualities = {};
|
||||||
|
|
||||||
|
if (apiJson?.links) {
|
||||||
|
for (const quality in apiJson.links) {
|
||||||
|
const qualityArray = apiJson.links[quality];
|
||||||
|
const first = Array.isArray(qualityArray) ? qualityArray[0] : null;
|
||||||
|
|
||||||
|
if (!first?.src) continue;
|
||||||
|
|
||||||
|
qualities[quality] = {
|
||||||
|
src: decode(first.src),
|
||||||
|
type: first.type || "application/x-mpegURL"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.stringify(qualities, null, 2);
|
||||||
|
} catch (_) {
|
||||||
|
return JSON.stringify({});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function decode(input) {
|
||||||
|
const map = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||||
|
let out = "";
|
||||||
|
let buffer = 0;
|
||||||
|
let count = 0;
|
||||||
|
|
||||||
|
const rotated = [];
|
||||||
|
const source = String(input || "");
|
||||||
|
|
||||||
|
for (let i = 0; i < source.length; i++) {
|
||||||
|
const ch = source[i];
|
||||||
|
|
||||||
|
if (/[a-zA-Z]/.test(ch)) {
|
||||||
|
const code = ch.charCodeAt(0);
|
||||||
|
const max = ch <= "Z" ? 90 : 122;
|
||||||
|
const shifted = code + 18;
|
||||||
|
|
||||||
|
rotated.push(String.fromCharCode(shifted <= max ? shifted : shifted - 26));
|
||||||
|
} else {
|
||||||
|
rotated.push(ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rot = rotated.join("");
|
||||||
|
|
||||||
|
for (let j = 0; j < rot.length; j++) {
|
||||||
|
const ch = rot[j];
|
||||||
|
|
||||||
|
if (ch === "=") break;
|
||||||
|
|
||||||
|
const val = map.indexOf(ch);
|
||||||
|
if (val === -1) continue;
|
||||||
|
|
||||||
|
buffer = (buffer << 6) | val;
|
||||||
|
count += 6;
|
||||||
|
|
||||||
|
if (count >= 8) {
|
||||||
|
count -= 8;
|
||||||
|
out += String.fromCharCode((buffer >> count) & 0xff);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _defaultExport() {
|
||||||
|
return {
|
||||||
|
searchResults,
|
||||||
|
extractDetails,
|
||||||
|
extractEpisodes,
|
||||||
|
extractStreamUrl
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
globalThis.default = _defaultExport;
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.default = _defaultExport;
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
globalThis.module = globalThis.module || {};
|
||||||
|
globalThis.module.exports = { default: _defaultExport };
|
||||||
|
} catch (_) {}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"sourceName": "ShizaProject",
|
||||||
|
"iconUrl": "https://shizaproject.com/favicon.ico",
|
||||||
|
"author": {
|
||||||
|
"name": "emp0ry",
|
||||||
|
"icon": "https://avatars.githubusercontent.com/u/64217088"
|
||||||
|
},
|
||||||
|
"version": "1.0.0",
|
||||||
|
"language": "Russian",
|
||||||
|
"streamType": "HLS",
|
||||||
|
"quality": "720p",
|
||||||
|
"baseUrl": "https://shizaproject.com/",
|
||||||
|
"searchBaseUrl": "https://shizaproject.com/",
|
||||||
|
"scriptUrl": "https://git.luna-app.eu/50n50/sources/raw/branch/main/shizaproject/shizaproject.js",
|
||||||
|
"asyncJS": true,
|
||||||
|
"streamAsyncJS": true,
|
||||||
|
"softsub": false,
|
||||||
|
"type": "anime",
|
||||||
|
"downloadSupport": false,
|
||||||
|
"supportsMojuru": true,
|
||||||
|
"supportsDartotsu": true,
|
||||||
|
"supportsSora": true,
|
||||||
|
"supportsLuna": true
|
||||||
|
}
|
||||||
@@ -314,11 +314,20 @@ async function extractStreamUrl(href) {
|
|||||||
const qualities = _safeJsonParse(qualitiesJson, {});
|
const qualities = _safeJsonParse(qualitiesJson, {});
|
||||||
let bestUrl = "";
|
let bestUrl = "";
|
||||||
let bestQ = 0;
|
let bestQ = 0;
|
||||||
|
let url1080 = null;
|
||||||
|
let url720 = null;
|
||||||
|
let url480 = null;
|
||||||
|
|
||||||
for (const q in qualities) {
|
for (const q in qualities) {
|
||||||
const src = qualities?.[q]?.src;
|
const srcRaw = qualities?.[q]?.src;
|
||||||
|
const src = srcRaw ? (String(srcRaw).startsWith("//") ? "https:" + String(srcRaw) : String(srcRaw)) : "";
|
||||||
if (!src) continue;
|
if (!src) continue;
|
||||||
const n = parseInt(String(q).replace(/[^\d]/g, ""), 10) || 0;
|
const n = parseInt(String(q).replace(/[^\d]/g, ""), 10) || 0;
|
||||||
|
|
||||||
|
if (n >= 1080 && !url1080) url1080 = src;
|
||||||
|
if (n === 720 && !url720) url720 = src;
|
||||||
|
if (n === 480 && !url480) url480 = src;
|
||||||
|
|
||||||
if (n > bestQ) {
|
if (n > bestQ) {
|
||||||
bestQ = n;
|
bestQ = n;
|
||||||
bestUrl = src;
|
bestUrl = src;
|
||||||
@@ -327,11 +336,19 @@ async function extractStreamUrl(href) {
|
|||||||
|
|
||||||
if (!bestUrl) continue;
|
if (!bestUrl) continue;
|
||||||
|
|
||||||
const finalUrl = bestUrl.startsWith("//") ? "https:" + bestUrl : bestUrl;
|
const finalUrl = bestUrl;
|
||||||
|
|
||||||
|
// Fallback quality mapping for sources with uncommon labels.
|
||||||
|
if (!url1080 && bestQ >= 1080) url1080 = finalUrl;
|
||||||
|
if (!url720 && bestQ >= 720 && bestQ < 1080) url720 = finalUrl;
|
||||||
|
if (!url480 && bestQ >= 480 && bestQ < 720) url480 = finalUrl;
|
||||||
|
|
||||||
streams.push({
|
streams.push({
|
||||||
title: `${opt.dubbing}${bestQ ? ` (${bestQ}p)` : ""} (Kodik)`,
|
title: `${opt.dubbing} (Kodik)`,
|
||||||
streamUrl: finalUrl,
|
streamUrl: finalUrl,
|
||||||
|
url1080,
|
||||||
|
url720,
|
||||||
|
url480,
|
||||||
headers: {
|
headers: {
|
||||||
"User-Agent": _ua(),
|
"User-Agent": _ua(),
|
||||||
"Referer": IMAGE_REFERER
|
"Referer": IMAGE_REFERER
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"name": "emp0ry",
|
"name": "emp0ry",
|
||||||
"icon": "https://avatars.githubusercontent.com/u/64217088"
|
"icon": "https://avatars.githubusercontent.com/u/64217088"
|
||||||
},
|
},
|
||||||
"version": "1.0.3",
|
"version": "1.0.4",
|
||||||
"language": "Russian",
|
"language": "Russian",
|
||||||
"streamType": "HLS",
|
"streamType": "HLS",
|
||||||
"quality": "1080p",
|
"quality": "1080p",
|
||||||
@@ -20,8 +20,5 @@
|
|||||||
"supportsMojuru": true,
|
"supportsMojuru": true,
|
||||||
"supportsDartotsu": true,
|
"supportsDartotsu": true,
|
||||||
"supportsSora": true,
|
"supportsSora": true,
|
||||||
"supportsLuna": true,
|
"supportsLuna": true
|
||||||
"supportsAnymex": true,
|
}
|
||||||
"supportsTsumi": true,
|
|
||||||
"supportsHiyoku": true
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user