diff --git a/src/es/katanime/build.gradle b/src/es/katanime/build.gradle new file mode 100644 index 0000000000..e4b5ffd6b1 --- /dev/null +++ b/src/es/katanime/build.gradle @@ -0,0 +1,19 @@ +ext { + extName = 'Katanime' + extClass = '.Katanime' + extVersionCode = 1 +} + +apply from: "$rootDir/common.gradle" + +dependencies { + implementation(project(':lib:streamwish-extractor')) + implementation(project(':lib:streamtape-extractor')) + implementation(project(':lib:filemoon-extractor')) + implementation(project(':lib:sendvid-extractor')) + implementation(project(':lib:vidguard-extractor')) + implementation(project(':lib:mp4upload-extractor')) + implementation(project(':lib:dood-extractor')) + implementation(project(':lib:playlist-utils')) + implementation "dev.datlag.jsunpacker:jsunpacker:1.0.1" +} \ No newline at end of file diff --git a/src/es/katanime/res/mipmap-hdpi/ic_launcher.png b/src/es/katanime/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000..0b2cfcc86b Binary files /dev/null and b/src/es/katanime/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/es/katanime/res/mipmap-mdpi/ic_launcher.png b/src/es/katanime/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000..3a135eb565 Binary files /dev/null and b/src/es/katanime/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/es/katanime/res/mipmap-xhdpi/ic_launcher.png b/src/es/katanime/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000000..7c14d191de Binary files /dev/null and b/src/es/katanime/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/es/katanime/res/mipmap-xxhdpi/ic_launcher.png b/src/es/katanime/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000..afb8bfc0a2 Binary files /dev/null and b/src/es/katanime/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/es/katanime/res/mipmap-xxxhdpi/ic_launcher.png b/src/es/katanime/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000..80a68f4de3 Binary files /dev/null and b/src/es/katanime/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/es/katanime/src/eu/kanade/tachiyomi/animeextension/es/katanime/CryptoAES.kt b/src/es/katanime/src/eu/kanade/tachiyomi/animeextension/es/katanime/CryptoAES.kt new file mode 100644 index 0000000000..d0275ed8b3 --- /dev/null +++ b/src/es/katanime/src/eu/kanade/tachiyomi/animeextension/es/katanime/CryptoAES.kt @@ -0,0 +1,220 @@ +package eu.kanade.tachiyomi.lib.cryptoaes + +/* + * Copyright (C) The Tachiyomi Open Source Project + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +// Thanks to Vlad on Stackoverflow: https://stackoverflow.com/a/63701411 + +import android.util.Base64 +import java.security.MessageDigest +import java.util.Arrays +import javax.crypto.Cipher +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec + +/** + * Conforming with CryptoJS AES method + */ +@Suppress("unused") +object CryptoAES { + + private const val KEY_SIZE = 32 // 256 bits + private const val IV_SIZE = 16 // 128 bits + private const val SALT_SIZE = 8 // 64 bits + private const val HASH_CIPHER = "AES/CBC/PKCS7PADDING" + private const val HASH_CIPHER_FALLBACK = "AES/CBC/PKCS5PADDING" + private const val AES = "AES" + private const val KDF_DIGEST = "MD5" + + /** + * Decrypt using CryptoJS defaults compatible method. + * Uses KDF equivalent to OpenSSL's EVP_BytesToKey function + * + * http://stackoverflow.com/a/29152379/4405051 + * @param cipherText base64 encoded ciphertext + * @param password passphrase + */ + fun decrypt(cipherText: String, password: String): String { + return try { + val ctBytes = Base64.decode(cipherText, Base64.DEFAULT) + val saltBytes = Arrays.copyOfRange(ctBytes, SALT_SIZE, IV_SIZE) + val cipherTextBytes = Arrays.copyOfRange(ctBytes, IV_SIZE, ctBytes.size) + val md5 = MessageDigest.getInstance("MD5") + val keyAndIV = generateKeyAndIV(KEY_SIZE, IV_SIZE, 1, saltBytes, password.toByteArray(Charsets.UTF_8), md5) + decryptAES( + cipherTextBytes, + keyAndIV?.get(0) ?: ByteArray(KEY_SIZE), + keyAndIV?.get(1) ?: ByteArray(IV_SIZE), + ) + } catch (e: Exception) { + "" + } + } + + 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( + KEY_SIZE, + IV_SIZE, + 1, + salt.decodeHex(), + password.toByteArray(Charsets.UTF_8), + md5, + ) + decryptAES( + ctBytes, + keyAndIV?.get(0) ?: ByteArray(KEY_SIZE), + keyAndIV?.get(1) ?: ByteArray(IV_SIZE), + ) + } catch (e: Exception) { + "" + } + } + + /** + * Decrypt using CryptoJS defaults compatible method. + * + * @param cipherText base64 encoded ciphertext + * @param keyBytes key as a bytearray + * @param ivBytes iv as a bytearray + */ + fun decrypt(cipherText: String, keyBytes: ByteArray, ivBytes: ByteArray): String { + return try { + val cipherTextBytes = Base64.decode(cipherText, Base64.DEFAULT) + decryptAES(cipherTextBytes, keyBytes, ivBytes) + } catch (e: Exception) { + "" + } + } + + /** + * Encrypt using CryptoJS defaults compatible method. + * + * @param plainText plaintext + * @param keyBytes key as a bytearray + * @param ivBytes iv as a bytearray + */ + fun encrypt(plainText: String, keyBytes: ByteArray, ivBytes: ByteArray): String { + return try { + val cipherTextBytes = plainText.toByteArray() + encryptAES(cipherTextBytes, keyBytes, ivBytes) + } catch (e: Exception) { + "" + } + } + + /** + * Decrypt using CryptoJS defaults compatible method. + * + * @param cipherTextBytes encrypted text as a bytearray + * @param keyBytes key as a bytearray + * @param ivBytes iv as a bytearray + */ + private fun decryptAES(cipherTextBytes: ByteArray, keyBytes: ByteArray, ivBytes: ByteArray): String { + return try { + val cipher = try { + Cipher.getInstance(HASH_CIPHER) + } catch (e: Throwable) { Cipher.getInstance(HASH_CIPHER_FALLBACK) } + val keyS = SecretKeySpec(keyBytes, AES) + cipher.init(Cipher.DECRYPT_MODE, keyS, IvParameterSpec(ivBytes)) + cipher.doFinal(cipherTextBytes).toString(Charsets.UTF_8) + } catch (e: Exception) { + "" + } + } + + /** + * Encrypt using CryptoJS defaults compatible method. + * + * @param plainTextBytes encrypted text as a bytearray + * @param keyBytes key as a bytearray + * @param ivBytes iv as a bytearray + */ + private fun encryptAES(plainTextBytes: ByteArray, keyBytes: ByteArray, ivBytes: ByteArray): String { + return try { + val cipher = try { + Cipher.getInstance(HASH_CIPHER) + } catch (e: Throwable) { Cipher.getInstance(HASH_CIPHER_FALLBACK) } + val keyS = SecretKeySpec(keyBytes, AES) + cipher.init(Cipher.ENCRYPT_MODE, keyS, IvParameterSpec(ivBytes)) + Base64.encodeToString(cipher.doFinal(plainTextBytes), Base64.DEFAULT) + } catch (e: Exception) { + "" + } + } + + /** + * Generates a key and an initialization vector (IV) with the given salt and password. + * + * https://stackoverflow.com/a/41434590 + * This method is equivalent to OpenSSL's EVP_BytesToKey function + * (see https://github.com/openssl/openssl/blob/master/crypto/evp/evp_key.c). + * By default, OpenSSL uses a single iteration, MD5 as the algorithm and UTF-8 encoded password data. + * + * @param keyLength the length of the generated key (in bytes) + * @param ivLength the length of the generated IV (in bytes) + * @param iterations the number of digestion rounds + * @param salt the salt data (8 bytes of data or `null`) + * @param password the password data (optional) + * @param md the message digest algorithm to use + * @return an two-element array with the generated key and IV + */ + private fun generateKeyAndIV( + keyLength: Int, + ivLength: Int, + iterations: Int, + salt: ByteArray, + password: ByteArray, + md: MessageDigest, + ): Array? { + val digestLength = md.digestLength + val requiredLength = (keyLength + ivLength + digestLength - 1) / digestLength * digestLength + val generatedData = ByteArray(requiredLength) + var generatedLength = 0 + return try { + md.reset() + + // Repeat process until sufficient data has been generated + while (generatedLength < keyLength + ivLength) { + // Digest data (last digest if available, password data, salt if available) + if (generatedLength > 0) md.update(generatedData, generatedLength - digestLength, digestLength) + md.update(password) + md.update(salt, 0, SALT_SIZE) + md.digest(generatedData, generatedLength, digestLength) + + // additional rounds + for (i in 1 until iterations) { + md.update(generatedData, generatedLength, digestLength) + md.digest(generatedData, generatedLength, digestLength) + } + generatedLength += digestLength + } + + // Copy key and IV into separate byte arrays + val result = arrayOfNulls(2) + result[0] = generatedData.copyOfRange(0, keyLength) + if (ivLength > 0) result[1] = generatedData.copyOfRange(keyLength, keyLength + ivLength) + result + } catch (e: Exception) { + throw e + } finally { + // Clean out temporary data + Arrays.fill(generatedData, 0.toByte()) + } + } + + // Stolen from AnimixPlay(EN) / GogoCdnExtractor + fun String.decodeHex(): ByteArray { + check(length % 2 == 0) { "Must have an even length" } + return chunked(2) + .map { it.toInt(16).toByte() } + .toByteArray() + } +} diff --git a/src/es/katanime/src/eu/kanade/tachiyomi/animeextension/es/katanime/Katanime.kt b/src/es/katanime/src/eu/kanade/tachiyomi/animeextension/es/katanime/Katanime.kt new file mode 100644 index 0000000000..86cc5825e1 --- /dev/null +++ b/src/es/katanime/src/eu/kanade/tachiyomi/animeextension/es/katanime/Katanime.kt @@ -0,0 +1,259 @@ + +package eu.kanade.tachiyomi.animeextension.es.katanime + +import android.app.Application +import android.content.SharedPreferences +import androidx.preference.ListPreference +import androidx.preference.PreferenceScreen +import eu.kanade.tachiyomi.animeextension.es.katanime.extractors.UnpackerExtractor +import eu.kanade.tachiyomi.animesource.ConfigurableAnimeSource +import eu.kanade.tachiyomi.animesource.model.AnimeFilterList +import eu.kanade.tachiyomi.animesource.model.AnimesPage +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.AnimeHttpSource +import eu.kanade.tachiyomi.lib.cryptoaes.CryptoAES +import eu.kanade.tachiyomi.lib.doodextractor.DoodExtractor +import eu.kanade.tachiyomi.lib.filemoonextractor.FilemoonExtractor +import eu.kanade.tachiyomi.lib.mp4uploadextractor.Mp4uploadExtractor +import eu.kanade.tachiyomi.lib.sendvidextractor.SendvidExtractor +import eu.kanade.tachiyomi.lib.streamtapeextractor.StreamTapeExtractor +import eu.kanade.tachiyomi.lib.streamwishextractor.StreamWishExtractor +import eu.kanade.tachiyomi.lib.vidguardextractor.VidGuardExtractor +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.util.asJsoup +import eu.kanade.tachiyomi.util.parseAs +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import okhttp3.Request +import okhttp3.Response +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Locale + +class Katanime : ConfigurableAnimeSource, AnimeHttpSource() { + + override val name = "Katanime" + + override val baseUrl = "https://katanime.net" + + override val lang = "es" + + override val supportsLatest = true + + private val preferences: SharedPreferences by lazy { + Injekt.get().getSharedPreferences("source_$id", 0x0000) + } + + companion object { + const val DECRYPTION_PASSWORD = "hanabi" + + private const val PREF_QUALITY_KEY = "preferred_quality" + private const val PREF_QUALITY_DEFAULT = "1080" + private val QUALITY_LIST = arrayOf("1080", "720", "480", "360") + + private const val PREF_SERVER_KEY = "preferred_server" + private const val PREF_SERVER_DEFAULT = "VidGuard" + private val SERVER_LIST = arrayOf( + "StreamWish", + "VidGuard", + "Filemoon", + "StreamTape", + "FileLions", + "DoodStream", + "Sendvid", + "LuluStream", + "Mp4Upload", + ) + + private val DATE_FORMATTER by lazy { + SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.ENGLISH) + } + } + + override fun animeDetailsParse(response: Response): SAnime { + val document = response.asJsoup() + return SAnime.create().apply { + title = document.selectFirst(".comics-title")?.ownText() ?: "" + description = document.selectFirst("#sinopsis p")?.ownText() + genre = document.select(".anime-genres a").joinToString { it.text() } + status = with(document.select(".details-by #estado").text()) { + when { + contains("Finalizado", true) -> SAnime.COMPLETED + contains("Emision", true) -> SAnime.ONGOING + else -> SAnime.UNKNOWN + } + } + } + } + + override fun popularAnimeRequest(page: Int) = GET("$baseUrl/populares", headers) + + override fun popularAnimeParse(response: Response): AnimesPage { + val document = response.asJsoup() + val elements = document.select("#article-div .full > a") + val nextPage = document.select(".pagination .active ~ li:not(.disabled)").any() + val animeList = elements.map { element -> + SAnime.create().apply { + setUrlWithoutDomain(element.attr("abs:href")) + title = element.selectFirst("img")!!.attr("alt") + thumbnail_url = element.selectFirst("img")?.getImageUrl() + } + } + return AnimesPage(animeList, nextPage) + } + + override fun latestUpdatesParse(response: Response) = popularAnimeParse(response) + + override fun latestUpdatesRequest(page: Int): Request { + val currentYear = Calendar.getInstance().get(Calendar.YEAR) + return GET("$baseUrl/animes?fecha=$currentYear&p=$page") + } + + override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request { + val params = KatanimeFilters.getSearchParameters(filters) + return when { + query.isNotBlank() -> GET("$baseUrl/buscar?q=$query&p=$page", headers) + params.filter.isNotBlank() -> GET("$baseUrl/animes${params.getQuery()}&p=$page", headers) + else -> popularAnimeRequest(page) + } + } + + override fun searchAnimeParse(response: Response) = popularAnimeParse(response) + + override fun episodeListParse(response: Response): List { + val jsoup = response.asJsoup() + return jsoup.select("#c_list .cap_list").map { + SEpisode.create().apply { + name = it.selectFirst(".entry-title-h2")?.ownText() ?: "" + episode_number = it.selectFirst(".entry-title-h2")?.ownText()?.substringAfter("Capítulo")?.trim()?.toFloat() ?: 0F + date_upload = it.selectFirst(".timeago")?.attr("datetime")?.toDate() ?: 0L + setUrlWithoutDomain(it.attr("abs:href")) + } + }.reversed() + } + + override fun videoListParse(response: Response): List