diff --git a/anidub/anidub.js b/anidub/anidub.js new file mode 100644 index 0000000..3e910b9 --- /dev/null +++ b/anidub/anidub.js @@ -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(//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 (_) {} \ No newline at end of file diff --git a/anidub/anidub.json b/anidub/anidub.json new file mode 100644 index 0000000..a6cb267 --- /dev/null +++ b/anidub/anidub.json @@ -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 +} diff --git a/aniliberty/aniliberty.js b/aniliberty/aniliberty.js index 442da45..d08e1aa 100644 --- a/aniliberty/aniliberty.js +++ b/aniliberty/aniliberty.js @@ -24,6 +24,24 @@ function pickBestHls(ep) { 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 // ------------------------------------------------------------ @@ -117,8 +135,11 @@ async function extractEpisodes(url) { const eps = Array.isArray(data?.episodes) ? data.episodes : []; const out = eps.map((ep, idx) => { - const href = pickBestHls(ep); - if (!href) return null; + const url1080 = ep?.hls_1080 || 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) ? ep.ordinal @@ -137,7 +158,12 @@ async function extractEpisodes(url) { : undefined; const entry = { - href, + href: _packEpisode({ + url1080, + url720, + url480, + fallback: best + }), number: num, title, image @@ -162,7 +188,68 @@ async function extractEpisodes(url) { // ------------------------------------------------------------ async function extractStreamUrl(url) { 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) { return null; } diff --git a/aniliberty/aniliberty.json b/aniliberty/aniliberty.json index 25a6c70..81549e1 100644 --- a/aniliberty/aniliberty.json +++ b/aniliberty/aniliberty.json @@ -5,7 +5,7 @@ "name": "emp0ry", "icon": "https://avatars.githubusercontent.com/u/64217088" }, - "version": "1.0.3", + "version": "1.0.4", "language": "Russian", "streamType": "HLS", "quality": "1080p", @@ -20,8 +20,5 @@ "supportsMojuru": true, "supportsDartotsu": true, "supportsSora": true, - "supportsLuna": true, - "supportsAnymex": true, - "supportsTsumi": true, - "supportsHiyoku": true + "supportsLuna": true } \ No newline at end of file diff --git a/animevost/animevost.js b/animevost/animevost.js index dcb96d9..1531357 100644 --- a/animevost/animevost.js +++ b/animevost/animevost.js @@ -1,9 +1,11 @@ // AnimeVost for Sora (AsyncJS) // Author: emp0ry -// Version: 1.0.0 +// Version: 1.0.2 const API_BASE = "https://api.animevost.org/v1/"; const FORM_CT = "application/x-www-form-urlencoded; charset=UTF-8"; +const SITE_BASE = "https://animevost.org"; +const DEFAULT_SUBTITLE = "https://none.com"; // --- utils --- function encodeForm(fields) { @@ -14,27 +16,39 @@ function encodeForm(fields) { async function postForm(url, fields) { const bodyStr = encodeForm(fields); + try { const resA = await fetchv2(url, { "Content-Type": FORM_CT }, "POST", bodyStr); if (resA && typeof resA.text === "function") return resA; } 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) { 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) { if (!raw || typeof raw !== "string") return "Unknown title"; + let t = raw.split(" /")[0]; return t.replace(/\s*\[.*?\]\s*$/g, "").trim() || "Unknown title"; } function htmlToText(html) { if (!html || typeof html !== "string") return ""; + return html .replace(//gi, "\n") .replace(/<\/p>/gi, "\n") @@ -43,27 +57,72 @@ function htmlToText(html) { .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. function makeHrefFromPayload(obj) { - return `animevost://payload/${encodeURIComponent(JSON.stringify(obj))}`; + return `animevost://payload/${encodeURIComponent(JSON.stringify(obj || {}))}`; } + function readPayloadFromHref(href) { - const m = String(href).match(/^animevost:\/\/payload\/(.+)$/); + const m = String(href || "").match(/^animevost:\/\/payload\/(.+)$/); 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) { const p = readPayloadFromHref(hrefOrId); 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); - const m2 = String(hrefOrId).match(/[?&]id=(\d+)/); + + const m2 = String(hrefOrId || "").match(/[?&]id=(\d+)/); 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; } -// --- 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) { try { let res = await postForm(API_BASE + "search", { name: String(keyword) }); @@ -92,39 +151,43 @@ async function searchResults(keyword) { type: item.type || "", image: item.urlImagePreview || "" }; + return { title: payload.title, image: payload.image, - href: makeHrefFromPayload(payload) + href: makeHrefFromPayload(payload) }; }); return JSON.stringify(tiles); - } catch (e) { + } catch (_) { return JSON.stringify([]); } } -// --- details (from payload) --- +// --- details: from payload --- async function extractDetails(href) { try { const p = readPayloadFromHref(href); + const out = [{ description: p?.description || "No description available.", - aliases: `Type: ${p?.type || "Unknown"}`, - airdate: p?.year ? String(p.year) : "Unknown" + aliases: `Type: ${p?.type || "Unknown"}`, + airdate: p?.year ? String(p.year) : "Unknown" }]; + return JSON.stringify(out); - } catch (e) { + } catch (_) { return JSON.stringify([]); } } -// --- episodes (POST /playlist) --- +// --- episodes: POST /playlist --- async function extractEpisodes(href) { try { const p = readPayloadFromHref(href); const id = p?.id ?? parseIdFromAny(href); + if (!id) { return JSON.stringify([]); } @@ -132,30 +195,115 @@ async function extractEpisodes(href) { const res = await postForm(API_BASE + "playlist", { id: String(id) }); 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 name = ep?.name || ""; const m = name.match(/(\d+)/); 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 { + href: _packEpisode({ + url1080, + url720, + url480, + fallback + }), number: num, - title: name || `Episode ${num}`, - image: ep?.preview || "", - href: link + title: name || `Episode ${num}`, + image: ep?.preview || "" }; }).filter(Boolean); return JSON.stringify(out); - } catch (e) { + } catch (_) { return JSON.stringify([]); } } -// --- stream --- -async function extractStreamUrl(url) { - try { return url; } catch { return null; } -} +// --- stream: returns quality picker JSON --- +async function extractStreamUrl(href) { + 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 + }); + } +} \ No newline at end of file diff --git a/animevost/animevost.json b/animevost/animevost.json index 537ba94..c840f2b 100644 --- a/animevost/animevost.json +++ b/animevost/animevost.json @@ -5,7 +5,7 @@ "name": "emp0ry", "icon": "https://avatars.githubusercontent.com/u/64217088" }, - "version": "1.0.1", + "version": "1.0.2", "language": "Russian", "streamType": "MP4", "quality": "720p", @@ -20,8 +20,5 @@ "supportsMojuru": true, "supportsDartotsu": true, "supportsSora": true, - "supportsLuna": true, - "supportsAnymex": true, - "supportsTsumi": true, - "supportsHiyoku": true + "supportsLuna": true } \ No newline at end of file diff --git a/onwave/onwave.js b/onwave/onwave.js new file mode 100644 index 0000000..af44ead --- /dev/null +++ b/onwave/onwave.js @@ -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 (_) {} diff --git a/onwave/onwave.json b/onwave/onwave.json new file mode 100644 index 0000000..381f9dd --- /dev/null +++ b/onwave/onwave.json @@ -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 +} diff --git a/sameband/sameband.js b/sameband/sameband.js new file mode 100644 index 0000000..dc1d8cf --- /dev/null +++ b/sameband/sameband.js @@ -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(//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( + "]*" + 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 = /]*class=["'][^"']*shortstory[^"']*["'][^>]*>[\s\S]*?<\/article>/gi; + const blocks = src.match(articleRegex) || []; + + for (const block of blocks) { + const posterTag = (block.match(/]*class=["'][^"']*poster[^"']*["'][^>]*>/i) || [""])[0]; + const imageLinkTag = (block.match(/]*class=["'][^"']*image[^"']*["'][^>]*>/i) || [""])[0]; + const infoTitleMatch = block.match(/]*class=["'][^"']*info-title[^"']*["'][^>]*>([\s\S]*?)<\/div>/i); + const imgTag = (block.match(/]*>/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( + /]*class=["'][^"']*player-content[^"']*["'][^>]*>[\s\S]*?]*src=["']([^"']+)["']/i + ); + if (inPlayer && inPlayer[1]) return inPlayer[1]; + + const anyIframe = src.match(/]*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( + /]*class=["'][^"']*description[^"']*["'][^>]*>[\s\S]*?]*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(/]*>/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" }); + } +} diff --git a/sameband/sameband.json b/sameband/sameband.json new file mode 100644 index 0000000..a2792d7 --- /dev/null +++ b/sameband/sameband.json @@ -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 +} diff --git a/shizaproject/shizaproject.js b/shizaproject/shizaproject.js new file mode 100644 index 0000000..843740c --- /dev/null +++ b/shizaproject/shizaproject.js @@ -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 (_) {} \ No newline at end of file diff --git a/shizaproject/shizaproject.json b/shizaproject/shizaproject.json new file mode 100644 index 0000000..7d19b06 --- /dev/null +++ b/shizaproject/shizaproject.json @@ -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 +} diff --git a/yummyanime/yummyanime.js b/yummyanime/yummyanime.js index c69025f..450a2a5 100644 --- a/yummyanime/yummyanime.js +++ b/yummyanime/yummyanime.js @@ -314,11 +314,20 @@ async function extractStreamUrl(href) { const qualities = _safeJsonParse(qualitiesJson, {}); let bestUrl = ""; let bestQ = 0; + let url1080 = null; + let url720 = null; + let url480 = null; 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; 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) { bestQ = n; bestUrl = src; @@ -327,11 +336,19 @@ async function extractStreamUrl(href) { 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({ - title: `${opt.dubbing}${bestQ ? ` (${bestQ}p)` : ""} (Kodik)`, + title: `${opt.dubbing} (Kodik)`, streamUrl: finalUrl, + url1080, + url720, + url480, headers: { "User-Agent": _ua(), "Referer": IMAGE_REFERER diff --git a/yummyanime/yummyanime.json b/yummyanime/yummyanime.json index 22f5c2c..925f7a4 100644 --- a/yummyanime/yummyanime.json +++ b/yummyanime/yummyanime.json @@ -5,7 +5,7 @@ "name": "emp0ry", "icon": "https://avatars.githubusercontent.com/u/64217088" }, - "version": "1.0.3", + "version": "1.0.4", "language": "Russian", "streamType": "HLS", "quality": "1080p", @@ -20,8 +20,5 @@ "supportsMojuru": true, "supportsDartotsu": true, "supportsSora": true, - "supportsLuna": true, - "supportsAnymex": true, - "supportsTsumi": true, - "supportsHiyoku": true -} \ No newline at end of file + "supportsLuna": true +}