Skip to content

Commit 40ddb1a

Browse files
committed
Add ChikiAni2dProvider/src/main/kotlin/com/chiki2d/ChikiAni2dProvider.kt
1 parent d45a975 commit 40ddb1a

1 file changed

Lines changed: 384 additions & 0 deletions

File tree

Lines changed: 384 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,384 @@
1+
package com.chiki2d
2+
3+
import android.util.Base64
4+
import com.lagradost.cloudstream3.*
5+
import com.lagradost.cloudstream3.utils.ExtractorLink
6+
import com.lagradost.cloudstream3.utils.ExtractorLinkType
7+
import com.lagradost.cloudstream3.utils.M3u8Helper
8+
import com.lagradost.cloudstream3.utils.Qualities
9+
import com.lagradost.cloudstream3.utils.loadExtractor
10+
import com.lagradost.cloudstream3.utils.newExtractorLink
11+
import org.jsoup.Jsoup
12+
import org.jsoup.nodes.Element
13+
import org.json.JSONObject
14+
import java.net.URI
15+
import java.security.MessageDigest
16+
import javax.crypto.Cipher
17+
import javax.crypto.spec.IvParameterSpec
18+
import javax.crypto.spec.SecretKeySpec
19+
20+
class ChikiAni2dProvider : MainAPI() {
21+
override var mainUrl = "https://chikianimation.com"
22+
override var name = "ChikiAni2d"
23+
override val hasMainPage = true
24+
override var lang = "en"
25+
override val hasDownloadSupport = true
26+
override val supportedTypes = setOf(TvType.Anime, TvType.AnimeMovie)
27+
28+
companion object {
29+
private const val UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
30+
private const val GX = "https://galaxydonghua.xyz"
31+
// GDPlayer's playerConfig is encrypted with CryptoJS AES-JSON using a static passphrase
32+
// (EVP_BytesToKey(MD5) + salt -> AES-256-CBC). Verified against the GDPlayer v3 assets.
33+
private const val GDP_KEY = "F1r3b4Ll_GDP~5H"
34+
}
35+
36+
override val mainPage = mainPageOf(
37+
"$mainUrl/anime/?status=&type=&order=update" to "Latest Release",
38+
"$mainUrl/anime/?status=&type=&order=popular" to "Popular",
39+
"$mainUrl/anime/?status=completed&type=&order=update" to "Completed",
40+
)
41+
42+
override suspend fun getMainPage(page: Int, request: MainPageRequest): HomePageResponse {
43+
val url = if (page == 1) request.data else request.data.replace("?", "page/$page/?")
44+
val document = app.get(url).document
45+
val home = document.select("article.bs > div.bsx").mapNotNull { it.toSearchResult() }
46+
return newHomePageResponse(request.name, home)
47+
}
48+
49+
private fun Element.toSearchResult(): SearchResponse? {
50+
val linkEl = this.selectFirst("a") ?: return null
51+
val href = fixUrlNull(linkEl.attr("href")) ?: return null
52+
val title = linkEl.attr("title").ifBlank { this.selectFirst("div.tt")?.text() }?.trim() ?: return null
53+
val img = this.selectFirst("img")
54+
val rawPoster = img?.attr("data-lazy-src")?.ifBlank { null }
55+
?: img?.attr("data-src")?.ifBlank { null }
56+
?: img?.attr("src")
57+
val posterUrl = fixUrlNull(
58+
rawPoster?.substringBefore("?")?.replace(Regex("https?://i\\d+\\.wp\\.com/"), "https://")
59+
)
60+
return newAnimeSearchResponse(title, href, TvType.Anime) { this.posterUrl = posterUrl }
61+
}
62+
63+
override suspend fun search(query: String): List<SearchResponse> {
64+
val document = app.get("$mainUrl/?s=$query").document
65+
return document.select("article.bs > div.bsx").mapNotNull { it.toSearchResult() }
66+
}
67+
68+
override suspend fun load(url: String): LoadResponse {
69+
val document = app.get(url).document
70+
val title = document.selectFirst("h1.entry-title, h1")?.text()?.trim()
71+
?.replace(Regex("(?i)(episode|ep)\\s*\\d+.*"), "") ?: ""
72+
val posterElement = document.selectFirst(
73+
".bigcontent .thumb img, .bixbox .thumb img, article .thumb img, .infox .imgbox img, .ts-post-image"
74+
)
75+
val rawPoster = posterElement?.attr("data-lazy-src")?.ifBlank { null }
76+
?: posterElement?.attr("data-src")?.ifBlank { null }
77+
?: posterElement?.attr("src")
78+
var poster = fixUrlNull(
79+
rawPoster?.substringBefore("?")?.replace(Regex("https?://i\\d+\\.wp\\.com/"), "https://")
80+
)
81+
if (poster.isNullOrBlank()) {
82+
val ogImage = document.selectFirst("meta[property=og:image]")?.attr("content")
83+
if (ogImage != null && !ogImage.contains("logo", true) && !ogImage.contains("banner", true)) {
84+
poster = fixUrlNull(ogImage)
85+
}
86+
}
87+
val synopsis = document.selectFirst(".synp .entry-content, .entry-content")?.text()
88+
val genres = document.select("a[href*=/genres/], .genxed a").map { it.text() }
89+
90+
val episodes = document
91+
.select(".eplister ul li, div.episodelist ul li, ul.episodelist li, .bixbox.bxcl ul li")
92+
.mapNotNull { li ->
93+
val epLink = li.selectFirst("a")
94+
val epHref = if (epLink != null && epLink.hasAttr("href")) fixUrlNull(epLink.attr("href")) else null
95+
if (epHref == null) return@mapNotNull null
96+
val epTitle = (epLink.attr("title").ifBlank { epLink.text() } ?: li.text()).trim()
97+
val epNumText = li.selectFirst(".epl-num")?.text() ?: epTitle
98+
val epNum = Regex("(?i)(?:episode|ep)\\s*(\\d+)").find(epNumText)?.groupValues?.get(1)?.toIntOrNull()
99+
?: Regex("\\d+").find(epNumText)?.value?.toIntOrNull()
100+
newEpisode(epHref) {
101+
this.name = epNumText.ifBlank { "Episode $epNum" }
102+
this.episode = epNum
103+
}
104+
}
105+
.distinctBy { it.data }
106+
.reversed()
107+
108+
return newAnimeLoadResponse(title, url, TvType.Anime) {
109+
this.posterUrl = poster
110+
this.plot = synopsis
111+
this.tags = genres
112+
addEpisodes(DubStatus.Subbed, episodes)
113+
}
114+
}
115+
116+
override suspend fun loadLinks(
117+
data: String,
118+
isCasting: Boolean,
119+
subtitleCallback: (SubtitleFile) -> Unit,
120+
callback: (ExtractorLink) -> Unit
121+
): Boolean {
122+
var found = false
123+
val pageHtml = try {
124+
app.get(data, headers = mapOf("User-Agent" to UA)).text
125+
} catch (e: Exception) {
126+
""
127+
}
128+
if (pageHtml.isBlank()) return false
129+
val document = Jsoup.parse(pageHtml)
130+
131+
fun unescape(s: String): String = s
132+
.replace("\\/", "/")
133+
.replace("\\u002F", "/").replace("\\u002f", "/")
134+
.replace("\\u0026", "&").replace("&amp;", "&")
135+
136+
fun extractStreamUrl(text: String): String? {
137+
Regex("""https?://[^\s"'<>\\]+\.m3u8[^\s"'<>\\]*""").find(text)?.value?.let { return it }
138+
Regex("""https?://[^\s"'<>\\]*chunklist[^\s"'<>\\]*""").find(text)?.value?.let { return it }
139+
return null
140+
}
141+
142+
// 1) Default "All sub player" - galaxydonghua (GDPlayer) iframe directly in the page
143+
val gxIframe = document.select("#pembed iframe[src], #embed_holder iframe[src], iframe[src]").firstOrNull {
144+
it.attr("src").contains("galaxydonghua", true) || it.attr("data-src").contains("galaxydonghua", true)
145+
}
146+
if (gxIframe != null) {
147+
val src = fixRelativeUrl(gxIframe.attr("src").ifBlank { gxIframe.attr("data-src") }, data)
148+
if (src != null) {
149+
try { if (handleGdplayer(src, data, subtitleCallback, callback)) found = true } catch (e: Exception) { }
150+
}
151+
}
152+
153+
// 2) Dailymotion player (mirror /v/2/ and/or geo.dailymotion embeds anywhere)
154+
val dmIds = LinkedHashSet<String>()
155+
Regex("geo\\.dailymotion\\.com/player/[a-zA-Z0-9_]+\\.html\\?video=([a-zA-Z0-9_]+)")
156+
.findAll(pageHtml).forEach { dmIds.add(it.groupValues[1]) }
157+
Regex("dailymotion\\.com/(?:embed/)?video/([a-zA-Z0-9_]+)")
158+
.findAll(pageHtml).forEach { dmIds.add(it.groupValues[1]) }
159+
160+
// 3) Mirror switcher options (load extra servers when the default one fails)
161+
for (option in document.select("select.mirror option[value]")) {
162+
val v = option.attr("value").trim()
163+
if (v.isBlank() || !v.contains("/v/")) continue
164+
val mirrorUrl = fixRelativeUrl(v, data) ?: continue
165+
val mirrorHtml = try {
166+
app.get(mirrorUrl, headers = mapOf("User-Agent" to UA)).text
167+
} catch (e: Exception) {
168+
continue
169+
}
170+
if (v.contains("/v/2/")) {
171+
Regex("geo\\.dailymotion\\.com/player/[a-zA-Z0-9_]+\\.html\\?video=([a-zA-Z0-9_]+)")
172+
.find(mirrorHtml)?.let { dmIds.add(it.groupValues[1]) }
173+
} else if (v.contains("/v/1/") && !found) {
174+
// Retry the galaxydonghua embed with the mirror page as referer
175+
val src = Regex("galaxydonghua\\.xyz/embed/[^\"'\\s>]+").find(mirrorHtml)?.value
176+
if (src != null) {
177+
try { if (handleGdplayer("https://$src", mirrorUrl, subtitleCallback, callback)) found = true } catch (e: Exception) { }
178+
}
179+
}
180+
}
181+
182+
// 4) Dailymotion fallback links
183+
for (id in dmIds) {
184+
if (found) break
185+
try { if (handleDailymotion(id, data, subtitleCallback, callback)) found = true } catch (e: Exception) { }
186+
}
187+
188+
// 5) Raw stream URL anywhere in the page
189+
if (!found) {
190+
extractStreamUrl(unescape(pageHtml))?.let { m3u8 ->
191+
try {
192+
if (M3u8Helper.generateM3u8(name, m3u8, "$mainUrl/").isNotEmpty()) found = true
193+
} catch (e: Exception) { }
194+
}
195+
}
196+
197+
return found
198+
}
199+
200+
private suspend fun handleGdplayer(
201+
embedUrl: String,
202+
refererPage: String,
203+
subtitleCallback: (SubtitleFile) -> Unit,
204+
callback: (ExtractorLink) -> Unit
205+
): Boolean {
206+
val embedHost = if (embedUrl.contains("galaxydonghua", true)) {
207+
embedUrl.substringBefore("/embed").ifBlank { GX }.let { if (it.startsWith("http")) it else "https:$it" }
208+
} else {
209+
embedUrl.substringBefore("/embed").ifBlank { GX }
210+
}
211+
val gxBase = if (embedHost.startsWith("http")) embedHost else GX
212+
213+
// The /embed/ route is gated: only browsers/iframe-like requests are served.
214+
val headers = mapOf(
215+
"User-Agent" to UA,
216+
"Referer" to refererPage,
217+
"Accept" to "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
218+
"Accept-Language" to "en-US,en;q=0.9",
219+
"sec-fetch-dest" to "iframe",
220+
"sec-fetch-mode" to "navigate",
221+
"sec-fetch-site" to "cross-site",
222+
)
223+
val page = try { app.get(embedUrl, headers = headers).text } catch (e: Exception) { return false }
224+
val configMatch = Regex("playerConfig\\s*=\\s*(\\{[^;]+?\\})\\s*;").find(page) ?: return false
225+
val configJson = try { JSONObject(configMatch.groupValues[1]) } catch (e: Exception) { return false }
226+
val ct = configJson.optString("ct")
227+
val saltHex = configJson.optString("s")
228+
if (ct.isBlank() || saltHex.isBlank()) return false
229+
230+
val salt = hexToBytes(saltHex) ?: return false
231+
val derived = evpBytesToKey(GDP_KEY.toByteArray(Charsets.UTF_8), salt, 48) ?: return false
232+
val plain = aesDecrypt(
233+
Base64.decode(ct, Base64.DEFAULT),
234+
derived.copyOfRange(0, 32),
235+
derived.copyOfRange(32, 48)
236+
) ?: return false
237+
val pConf = try { JSONObject(plain) } catch (e: Exception) { return false }
238+
239+
val apiURL = pConf.optString("apiURL").ifBlank { pConf.optString("baseURL") }
240+
val apiQuery = pConf.optString("apiQuery").ifBlank { pConf.optString("query") }
241+
if (apiURL.isBlank() || apiQuery.isBlank()) return false
242+
val fixedApi = fixStreamUrl(apiURL, gxBase) ?: return false
243+
244+
val apiHeaders = mapOf(
245+
"User-Agent" to UA,
246+
"Referer" to gxBase,
247+
"Accept" to "application/json, text/javascript, */*; q=0.01",
248+
"X-Requested-With" to "XMLHttpRequest",
249+
)
250+
val apiRes = try {
251+
app.get("${fixedApi.trimEnd('/')}/api/?$apiQuery", headers = apiHeaders).text
252+
} catch (e: Exception) { return false }
253+
val resJson = try { JSONObject(apiRes) } catch (e: Exception) { return false }
254+
val sources = resJson.optJSONArray("sources") ?: return false
255+
if (sources.length() == 0) return false
256+
257+
val baseURL = pConf.optString("baseURL").ifBlank { gxBase }
258+
var any = false
259+
for (i in 0 until sources.length()) {
260+
val src = sources.optJSONObject(i) ?: continue
261+
val file = fixStreamUrl(src.optString("file"), baseURL) ?: continue
262+
val label = src.optString("label").ifBlank { "Stream" }
263+
val type = src.optString("type")
264+
val isM3u8 = type.contains("mpegurl", true) || type.contains("hls", true) || file.contains(".m3u8", true)
265+
val quality = label.replace("p", "").toIntOrNull() ?: Qualities.Unknown.value
266+
try {
267+
if (isM3u8) {
268+
val links = M3u8Helper.generateM3u8("$name - $label", file, gxBase)
269+
if (links.isEmpty()) {
270+
callback(
271+
newExtractorLink(source = name, name = label, url = file, type = ExtractorLinkType.M3U8) {
272+
this.referer = gxBase
273+
this.quality = quality
274+
}
275+
)
276+
} else {
277+
for (l in links) callback(l)
278+
}
279+
} else {
280+
callback(
281+
newExtractorLink(source = name, name = label, url = file, type = ExtractorLinkType.VIDEO) {
282+
this.referer = gxBase
283+
this.quality = quality
284+
}
285+
)
286+
}
287+
any = true
288+
} catch (e: Exception) { }
289+
}
290+
291+
val tracks = resJson.optJSONArray("tracks")
292+
if (tracks != null) {
293+
for (i in 0 until tracks.length()) {
294+
val tr = tracks.optJSONObject(i) ?: continue
295+
val file = fixStreamUrl(tr.optString("file"), baseURL) ?: continue
296+
try {
297+
subtitleCallback(SubtitleFile(tr.optString("label").ifBlank { "Subtitle" }, file))
298+
} catch (e: Exception) { }
299+
}
300+
}
301+
302+
return any
303+
}
304+
305+
private suspend fun handleDailymotion(
306+
videoId: String,
307+
referer: String,
308+
subtitleCallback: (SubtitleFile) -> Unit,
309+
callback: (ExtractorLink) -> Unit
310+
): Boolean {
311+
return try {
312+
loadExtractor("https://www.dailymotion.com/video/$videoId", referer, subtitleCallback, callback)
313+
} catch (e: Exception) { false }
314+
}
315+
316+
private fun fixRelativeUrl(url: String?, baseUrl: String): String? {
317+
if (url.isNullOrBlank()) return null
318+
val trimmed = url.trim()
319+
return when {
320+
trimmed.startsWith("http://") || trimmed.startsWith("https://") -> trimmed
321+
trimmed.startsWith("//") -> "https:$trimmed"
322+
trimmed.startsWith("/") -> runCatching {
323+
val uri = URI(baseUrl); "${uri.scheme}://${uri.host}$trimmed"
324+
}.getOrNull() ?: trimmed
325+
else -> runCatching {
326+
val uri = URI(baseUrl); val path = uri.path.substringBeforeLast("/", "")
327+
"${uri.scheme}://${uri.host}$path/${trimmed.removePrefix("./")}"
328+
}.getOrNull() ?: trimmed
329+
}
330+
}
331+
332+
private fun fixStreamUrl(url: String, base: String): String? {
333+
val u = url.trim()
334+
if (u.isBlank()) return null
335+
if (u.startsWith("http://") || u.startsWith("https://")) return u
336+
if (u.startsWith("//")) return "https:$u"
337+
val host = runCatching { val uri = URI(base); "${uri.scheme}://${uri.host}" }.getOrNull()
338+
return when {
339+
u.startsWith("/") -> if (host != null) "$host$u" else "${base.trimEnd('/')}$u"
340+
else -> if (host != null) "$host/$u" else "${base.trimEnd('/')}/$u"
341+
}
342+
}
343+
344+
private fun hexToBytes(hex: String): ByteArray? {
345+
val h = hex.trim()
346+
if (h.length % 2 != 0) return null
347+
if (!h.all { it in "0123456789abcdefABCDEF" }) return null
348+
return try {
349+
ByteArray(h.length / 2) { i ->
350+
((Character.digit(h[i * 2], 16) shl 4) + Character.digit(h[i * 2 + 1], 16)).toByte()
351+
}
352+
} catch (e: Exception) { null }
353+
}
354+
355+
// OpenSSL EVP_BytesToKey (MD5, single iteration) as used by CryptoJS password-based AES.
356+
private fun evpBytesToKey(password: ByteArray, salt: ByteArray, numBytes: Int): ByteArray? {
357+
return try {
358+
val out = ByteArray(numBytes)
359+
val md = MessageDigest.getInstance("MD5")
360+
var prev: ByteArray? = null
361+
var filled = 0
362+
while (filled < numBytes) {
363+
md.reset()
364+
prev?.let { md.update(it) }
365+
md.update(password)
366+
md.update(salt)
367+
val digest = md.digest()
368+
digest.copyInto(out, filled)
369+
filled += digest.size
370+
prev = digest
371+
}
372+
out
373+
} catch (e: Exception) { null }
374+
}
375+
376+
private fun aesDecrypt(blob: ByteArray, key: ByteArray, iv: ByteArray): String? {
377+
if (key.size !in intArrayOf(16, 24, 32) || iv.size != 16) return null
378+
return try {
379+
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
380+
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), IvParameterSpec(iv))
381+
String(cipher.doFinal(blob), Charsets.UTF_8)
382+
} catch (e: Exception) { null }
383+
}
384+
}

0 commit comments

Comments
 (0)