New source: tr.turkanime

This commit is contained in:
jmir1 2023-04-29 01:56:31 +02:00
parent 7804c2f258
commit fe53f7a8c1
14 changed files with 784 additions and 4 deletions

View File

@ -20,7 +20,7 @@ import javax.crypto.spec.SecretKeySpec
/** /**
* Conforming with CryptoJS AES method * Conforming with CryptoJS AES method
*/ */
@Suppress("unused", "FunctionName") @Suppress("unused")
object CryptoAES { object CryptoAES {
private const val KEY_SIZE = 256 private const val KEY_SIZE = 256
@ -39,19 +39,41 @@ object CryptoAES {
* @param password passphrase * @param password passphrase
*/ */
fun decrypt(cipherText: String, password: String): String { fun decrypt(cipherText: String, password: String): String {
try { return try {
val ctBytes = Base64.decode(cipherText, Base64.DEFAULT) val ctBytes = Base64.decode(cipherText, Base64.DEFAULT)
val saltBytes = Arrays.copyOfRange(ctBytes, 8, 16) val saltBytes = Arrays.copyOfRange(ctBytes, 8, 16)
val cipherTextBytes = Arrays.copyOfRange(ctBytes, 16, ctBytes.size) val cipherTextBytes = Arrays.copyOfRange(ctBytes, 16, ctBytes.size)
val md5: MessageDigest = MessageDigest.getInstance("MD5") val md5: MessageDigest = MessageDigest.getInstance("MD5")
val keyAndIV = generateKeyAndIV(32, 16, 1, saltBytes, password.toByteArray(Charsets.UTF_8), md5) val keyAndIV = generateKeyAndIV(32, 16, 1, saltBytes, password.toByteArray(Charsets.UTF_8), md5)
return decryptAES( decryptAES(
cipherTextBytes, cipherTextBytes,
keyAndIV?.get(0) ?: ByteArray(32), keyAndIV?.get(0) ?: ByteArray(32),
keyAndIV?.get(1) ?: ByteArray(16), keyAndIV?.get(1) ?: ByteArray(16),
) )
} catch (e: Exception) { } catch (e: Exception) {
return "" ""
}
}
fun decryptWithSalt(cipherText: String, salt: String, password: String): String {
return try {
val ctBytes = Base64.decode(cipherText, Base64.DEFAULT)
val md5: MessageDigest = MessageDigest.getInstance("MD5")
val keyAndIV = generateKeyAndIV(
32,
16,
1,
salt.decodeHex(),
password.toByteArray(Charsets.UTF_8),
md5
)
decryptAES(
ctBytes,
keyAndIV?.get(0) ?: ByteArray(32),
keyAndIV?.get(1) ?: ByteArray(16),
)
} catch (e: Exception) {
""
} }
} }

View File

@ -0,0 +1,19 @@
plugins {
id("com.android.library")
kotlin("android")
}
android {
compileSdk = AndroidConfig.compileSdk
namespace = "eu.kanade.tachiyomi.lib.synchrony"
defaultConfig {
minSdk = AndroidConfig.minSdk
targetSdk = AndroidConfig.targetSdk
}
}
dependencies {
compileOnly(libs.kotlin.stdlib)
compileOnly(libs.quickjs)
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,38 @@
package eu.kanade.tachiyomi.lib.synchrony
import app.cash.quickjs.QuickJs
/**
* Helper class to deobfuscate JavaScript strings with synchrony.
*/
object Deobfuscator {
fun deobfuscateScript(source: String): String? {
val engine = QuickJs.create()
val originalScript = javaClass.getResource("/assets/$SCRIPT_NAME")
?.readText() ?: return null
// Sadly needed until QuickJS properly supports module imports:
// Regex for finding one and two in "export{one as Deobfuscator,two as Transformer};"
val regex = """export\{(.*) as Deobfuscator,(.*) as Transformer\};""".toRegex()
val synchronyScript = regex.find(originalScript)!!.let { match ->
val (deob, trans) = match.destructured
val replacement = "const Deobfuscator = $deob, Transformer = $trans;"
originalScript.replace(match.value, replacement)
}
engine.evaluate("globalThis.console = { log: () => {}, warn: () => {}, error: () => {}, trace: () => {} };")
engine.evaluate(synchronyScript)
engine.set("source", TestInterface::class.java, object: TestInterface { override fun getValue() = source })
val result = engine.evaluate("new Deobfuscator().deobfuscateSource(source.getValue())") as? String
engine.close()
return result
}
@Suppress("unused")
private interface TestInterface {
fun getValue(): String
}
}
// Update this when the script is updated!
private const val SCRIPT_NAME = "synchrony-v2.4.2.1.js"

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="eu.kanade.tachiyomi.animeextension">
</manifest>

View File

@ -0,0 +1,22 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.serialization)
}
dependencies {
implementation(project(":lib-cryptoaes"))
implementation(project(":lib-synchrony"))
implementation(project(":lib-voe-extractor"))
implementation(project(":lib-streamsb-extractor"))
}
ext {
extName = 'Türk Anime TV'
pkgNameSuffix = 'tr.turkanime'
extClass = '.TurkAnime'
extVersionCode = 1
libVersion = '13'
}
apply from: "$rootDir/common.gradle"

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

View File

@ -0,0 +1,65 @@
package eu.kanade.tachiyomi.animeextension.tr.turkanime
import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.network.GET
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.Headers
import okhttp3.OkHttpClient
class AlucardExtractor(private val client: OkHttpClient, private val json: Json, private val baseUrl: String) {
private val refererHeader = Headers.headersOf("referer", baseUrl)
fun extractVideos(hosterLink: String, subber: String): List<Video> {
return try {
val sourcesId = hosterLink.substringBeforeLast("/true").substringAfterLast("/")
val playerJs = client.newCall(GET("$baseUrl/js/player.js"))
.execute().body.string()
val csrf = "(?<=')[a-zA-Z]{64}(?=')".toRegex().find(playerJs)!!.value
val sourcesResponse = client.newCall(
GET(
"$baseUrl/sources/$sourcesId/true",
Headers.headersOf(
"Referer",
hosterLink,
"X-Requested-With",
"XMLHttpRequest",
"Cookie",
"__",
"csrf-token",
csrf,
"User-Agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
"(KHTML, like Gecko) Chrome/91.0.4472.114 Safari/537.36",
),
),
)
.execute().body.string()
val sources = json.decodeFromString<JsonObject>(sourcesResponse)["response"]!!
.jsonObject["sources"]!!
.jsonArray.first()
.jsonObject["file"]!!
.jsonPrimitive.content
val masterPlaylist = client.newCall(GET(sources, refererHeader))
.execute().body.string()
val separator = "#EXT-X-STREAM-INF"
masterPlaylist.substringAfter(separator).split(separator).map {
val quality = it.substringAfter("RESOLUTION=")
.substringAfter("x")
.substringBefore("\n") + "p"
val videoUrl = it.substringAfter("\n")
.substringBefore("\n")
// TODO: This gives 403 in MPV
Video(videoUrl, "$subber: Alucard: $quality", videoUrl, refererHeader)
}
} catch (e: Throwable) {
emptyList()
}
}
}

View File

@ -0,0 +1,321 @@
package eu.kanade.tachiyomi.animeextension.tr.turkanime
import android.app.Application
import android.content.SharedPreferences
import android.util.Base64
import androidx.preference.ListPreference
import androidx.preference.PreferenceScreen
import eu.kanade.tachiyomi.animesource.ConfigurableAnimeSource
import eu.kanade.tachiyomi.animesource.model.AnimeFilterList
import eu.kanade.tachiyomi.animesource.model.SAnime
import eu.kanade.tachiyomi.animesource.model.SEpisode
import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.animesource.online.ParsedAnimeHttpSource
import eu.kanade.tachiyomi.lib.cryptoaes.CryptoAES
import eu.kanade.tachiyomi.lib.streamsbextractor.StreamSBExtractor
import eu.kanade.tachiyomi.lib.synchrony.Deobfuscator
import eu.kanade.tachiyomi.lib.voeextractor.VoeExtractor
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.util.asJsoup
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonPrimitive
import okhttp3.FormBody
import okhttp3.Headers
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Request
import okhttp3.Response
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class TurkAnime : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
override val name = "Türk Anime TV"
override val baseUrl = "https://www.turkanime.co"
override val lang = "tr"
override val supportsLatest = true
override val client = network.cloudflareClient
private val json = Json {
ignoreUnknownKeys = true
}
private val preferences: SharedPreferences by lazy {
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
}
private val key: String
get() = preferences.getString(PREF_KEY_KEY, DEFAULT_KEY)!!
// ============================== Popular ===============================
override fun popularAnimeRequest(page: Int) = GET("$baseUrl/ajax/rankagore?sayfa=$page", xmlHeader)
override fun popularAnimeSelector() = "div.panel-visible"
override fun popularAnimeNextPageSelector() = "button.btn-default[data-loading-text*=Sonraki]"
override fun popularAnimeFromElement(element: Element): SAnime {
val animeTitle = element.select("div.panel-title > a").first()!!
val name = animeTitle.attr("title")
.substringBefore(" izle")
val img = element.select("img.media-object")
val animeId = element.select("a.reactions").first()!!.attr("data-unique-id")
val animeUrl = ("https:" + animeTitle.attr("href")).toHttpUrl()
.newBuilder()
.addQueryParameter("animeId", animeId)
.build().toString()
return SAnime.create().apply {
setUrlWithoutDomain(animeUrl)
title = name
thumbnail_url = "https:" + img.attr("data-src")
}
}
// =========================== Anime Details ============================
override fun animeDetailsParse(document: Document): SAnime {
val img = document.select("div.imaj > img.media-object").ifEmpty { null }
val studio = document.select("div#animedetay > table tr:contains(Stüdyo) > td:last-child a").ifEmpty { null }
val desc = document.select("div#animedetay p.ozet").ifEmpty { null }
val genres = document.select("div#animedetay > table tr:contains(Anime Türü) > td:last-child a")
.ifEmpty { null }
return SAnime.create().apply {
title = document.select("div#detayPaylas div.panel-title").text()
thumbnail_url = img?.let { "https:" + it.attr("data-src") }
author = studio?.text()
description = desc?.text()
genre = genres?.joinToString { it.text() }
}
}
// ============================== Episodes ==============================
override fun episodeListRequest(anime: SAnime): Request {
val animeId = (baseUrl + anime.url).toHttpUrl().queryParameter("animeId")!!
return GET("https://www.turkanime.co/ajax/bolumler?animeId=$animeId", xmlHeader)
}
override fun episodeListSelector() = "ul.menum li"
override fun episodeFromElement(element: Element): SEpisode {
val a = element.select("a:has(span.bolumAdi)")
val title = a.attr("title")
val substring = title.substringBefore(". Bölüm")
val numIdx = substring.indexOfLast { !it.isDigit() } + 1
val numbers = substring.slice(numIdx..substring.lastIndex)
return SEpisode.create().apply {
setUrlWithoutDomain("https:" + a.attr("href"))
name = title
episode_number = numbers.toFloatOrNull() ?: 1F
}
}
override fun episodeListParse(response: Response): List<SEpisode> {
return super.episodeListParse(response).reversed()
}
// ============================ Video Links =============================
override fun videoListParse(response: Response): List<Video> {
val document = response.asJsoup()
val fansubbers = document.select("div#videodetay div.pull-right button")
return if (fansubbers.size == 1) {
getVideosFromHosters(document, fansubbers.first()!!.text().trim())
} else {
val videoList = mutableListOf<Video>()
fansubbers.parallelMap {
val url = it.attr("onclick").trimOnClick()
val subDoc = client.newCall(GET(url, xmlHeader)).execute().asJsoup()
videoList.addAll(getVideosFromHosters(subDoc, it.text().trim()))
}
videoList
}
}
private fun getVideosFromHosters(document: Document, subber: String): List<Video> {
val selectedHoster = document.select("div#videodetay div.btn-group:not(.pull-right) > button.btn-danger")
val hosters = document.select("div#videodetay div.btn-group:not(.pull-right) > button.btn-default[onclick*=videosec]")
val videoList = mutableListOf<Video>()
val selectedHosterName = selectedHoster.text().trim()
if (selectedHosterName in SUPPORTED_HOSTERS) {
val src = document.select("iframe").attr("src")
videoList.addAll(getVideosFromSource(src, selectedHosterName, subber))
}
hosters.parallelMap {
val hosterName = it.text().trim()
if (hosterName !in SUPPORTED_HOSTERS) return@parallelMap
val url = it.attr("onclick").trimOnClick()
val videoDoc = client.newCall(GET(url, xmlHeader)).execute().asJsoup()
val src = videoDoc.select("iframe").attr("src")
videoList.addAll(getVideosFromSource(src, hosterName, subber))
}
return videoList
}
private fun getVideosFromSource(src: String, hosterName: String, subber: String): List<Video> {
val videoList = mutableListOf<Video>()
val cipherParamsEncoded = src
.substringAfter("/embed/#/url/")
.substringBefore("?status")
val cipherParams = json.decodeFromString<CipherParams>(
String(
Base64.decode(cipherParamsEncoded, Base64.DEFAULT),
),
)
val hosterLink = "https:" + json.decodeFromString<JsonPrimitive>(
CryptoAES.decryptWithSalt(
cipherParams.ct,
cipherParams.s,
key,
),
).content
when (hosterName) {
"STREAMSB" -> {
videoList.addAll(StreamSBExtractor(client).videosFromUrl(hosterLink, refererHeader, "$subber:"))
}
"VOE" -> {
VoeExtractor(client).videoFromUrl(hosterLink, "$subber: VOE")?.let { video -> videoList.add(video) }
}
"ALUCARD(BETA)" -> {
videoList.addAll(AlucardExtractor(client, json, baseUrl).extractVideos(hosterLink, subber))
}
}
return videoList
}
@Serializable
private data class CipherParams(
@Serializable
val ct: String,
@Serializable
val iv: String,
@Serializable
val s: String,
)
private fun String.trimOnClick() = baseUrl + "/" + this.substringAfter("IndexIcerik('").substringBefore("'")
override fun videoFromElement(element: Element): Video = throw Exception("not used")
override fun videoListSelector(): String = throw Exception("not used")
override fun videoUrlParse(document: Document): String = throw Exception("not used")
// =============================== Search ===============================
override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList) =
POST(
"$baseUrl/arama?sayfa=$page",
Headers.headersOf("content-type", "application/x-www-form-urlencoded"),
FormBody.Builder().add("arama", query).build(),
)
override fun searchAnimeSelector() = popularAnimeSelector()
override fun searchAnimeNextPageSelector() = popularAnimeNextPageSelector()
override fun searchAnimeFromElement(element: Element) = popularAnimeFromElement(element)
// =============================== Latest ===============================
override fun latestUpdatesRequest(page: Int) = GET("$baseUrl/ajax/yenieklenenseriler?sayfa=$page", xmlHeader)
override fun latestUpdatesSelector() = popularAnimeSelector()
override fun latestUpdatesNextPageSelector() = popularAnimeNextPageSelector()
override fun latestUpdatesFromElement(element: Element) = popularAnimeFromElement(element)
// =============================== Preferences ===============================
override fun setupPreferenceScreen(screen: PreferenceScreen) {
val videoQualityPref = ListPreference(screen.context).apply {
key = "preferred_quality"
title = "Preferred quality"
entries = arrayOf("1080p", "720p", "480p", "360p")
entryValues = arrayOf("1080", "720", "480", "360")
setDefaultValue("1080")
summary = "%s"
setOnPreferenceChangeListener { _, newValue ->
val selected = newValue as String
val index = findIndexOfValue(selected)
val entry = entryValues[index] as String
preferences.edit().putString(key, entry).commit()
}
}
screen.addPreference(videoQualityPref)
}
override fun List<Video>.sort(): List<Video> {
val quality = preferences.getString("preferred_quality", "1080")
if (quality != null) {
val newList = mutableListOf<Video>()
var preferred = 0
for (video in this) {
if (video.quality.contains(quality)) {
newList.add(preferred, video)
preferred++
} else {
newList.add(video)
}
}
return newList
}
return this
}
// ============================= Utilities ==============================
private val xmlHeader = Headers.headersOf("X-Requested-With", "XMLHttpRequest")
private val refererHeader = Headers.headersOf("Referer", baseUrl)
private fun <A, B> Iterable<A>.parallelMap(f: suspend (A) -> B): List<B> =
runBlocking(Dispatchers.Default) {
map { async { f(it) } }.awaitAll()
}
private fun getKey() {
val script4 = client.newCall(GET("$baseUrl/embed/#/")).execute().asJsoup()
.select("script[defer]").getOrNull(1)
?.attr("src") ?: return
val embeds4 = client.newCall(GET(baseUrl + script4)).execute().body.string()
val name = "(?<=')[0-9a-f]{16}(?=')".toRegex().findAll(embeds4).toList().firstOrNull()?.value
val file5 = client.newCall(GET("$baseUrl/embed/js/embeds.$name.js")).execute().body.string()
val embeds5 = Deobfuscator.deobfuscateScript(file5) ?: return
val key = "(?<=')\\S{100}(?=')".toRegex().find(embeds5)?.value ?: return
preferences.edit().putString(PREF_KEY_KEY, key).apply()
}
init {
@OptIn(DelicateCoroutinesApi::class)
GlobalScope.launch {
withContext(Dispatchers.IO) { getKey() }
}
}
}
private val SUPPORTED_HOSTERS = listOf(
// TODO: Fix Alucard
// "ALUCARD(BETA)",
"STREAMSB",
"VOE")
private const val PREF_KEY_KEY = "key"
private const val DEFAULT_KEY = "710^8A@3@>T2}#zN5xK?kR7KNKb@-A!LzYL5~M1qU0UfdWsZoBm4UUat%}ueUv6E--*hDPPbH7K2bp9^3o41hw,khL:}Kx8080@M"