|
| 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("&", "&") |
| 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