// 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(//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( "]*" + 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(/]*class=["'][^"']*all__item[^"']*["'][^>]*>[\s\S]*?<\/a>/gi) || []; for (const block of blocks) { const openTag = (block.match(/]*>/i) || [""])[0]; const imgTag = (block.match(/]*>/i) || [""])[0]; const titleMatch = block.match( /]*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(/]*class=["'][^"']*movie__title[^"']*["'][^>]*>([\s\S]*?)<\/h1>/i) || [null, ""])[1]) || _extractMeta(html, "property", "og:title") || _extractMeta(html, "name", "title") || "AniDub"; const subtitle = _stripTags( (html.match(/]*class=["'][^"']*movie__subtitle[^"']*["'][^>]*>([\s\S]*?)<\/span>/i) || [null, ""])[1] ); const desc = _stripTags((html.match(/]*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( "]*class=[\"'][^\"']*movie__type[^\"']*[\"'][^>]*>\\s*" + 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( /]*class=["'][^"']*movie__type[^"']*["'][^>]*>\s*Премьера:<\/span>\s*([\s\S]*?)<\/div>/i ); const added = html.match( /]*class=["'][^"']*movie__added[^"']*["'][^>]*>[\s\S]*?Добавлено<\/span>[\s\S]*?([\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 (_) {}