diff --git a/src/en/kayoanime/AndroidManifest.xml b/src/en/kayoanime/AndroidManifest.xml new file mode 100644 index 000000000..acb4de356 --- /dev/null +++ b/src/en/kayoanime/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/src/en/kayoanime/build.gradle b/src/en/kayoanime/build.gradle new file mode 100644 index 000000000..191c40fd7 --- /dev/null +++ b/src/en/kayoanime/build.gradle @@ -0,0 +1,13 @@ +apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' +apply plugin: 'kotlinx-serialization' + +ext { + extName = 'Kayoanime' + pkgNameSuffix = 'en.kayoanime' + extClass = '.Kayoanime' + extVersionCode = 1 + libVersion = '13' +} + +apply from: "$rootDir/common.gradle" diff --git a/src/en/kayoanime/res/mipmap-hdpi/ic_launcher.png b/src/en/kayoanime/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..cd2a7bc54 Binary files /dev/null and b/src/en/kayoanime/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/en/kayoanime/res/mipmap-mdpi/ic_launcher.png b/src/en/kayoanime/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..a0c51152e Binary files /dev/null and b/src/en/kayoanime/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/en/kayoanime/res/mipmap-xhdpi/ic_launcher.png b/src/en/kayoanime/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..a90e2c5c5 Binary files /dev/null and b/src/en/kayoanime/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/en/kayoanime/res/mipmap-xxhdpi/ic_launcher.png b/src/en/kayoanime/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..cf96dece2 Binary files /dev/null and b/src/en/kayoanime/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/en/kayoanime/res/mipmap-xxxhdpi/ic_launcher.png b/src/en/kayoanime/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..7227be5e0 Binary files /dev/null and b/src/en/kayoanime/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/en/kayoanime/res/web_hi_res_512.png b/src/en/kayoanime/res/web_hi_res_512.png new file mode 100644 index 000000000..6931c2637 Binary files /dev/null and b/src/en/kayoanime/res/web_hi_res_512.png differ diff --git a/src/en/kayoanime/src/eu/kanade/tachiyomi/animeextension/en/kayoanime/Kayoanime.kt b/src/en/kayoanime/src/eu/kanade/tachiyomi/animeextension/en/kayoanime/Kayoanime.kt new file mode 100644 index 000000000..ba0e8c5ee --- /dev/null +++ b/src/en/kayoanime/src/eu/kanade/tachiyomi/animeextension/en/kayoanime/Kayoanime.kt @@ -0,0 +1,472 @@ +package eu.kanade.tachiyomi.animeextension.en.kayoanime + +import android.app.Application +import android.content.SharedPreferences +import androidx.preference.PreferenceScreen +import androidx.preference.SwitchPreferenceCompat +import eu.kanade.tachiyomi.animeextension.en.kayoanime.extractors.GoogleDriveExtractor +import eu.kanade.tachiyomi.animesource.ConfigurableAnimeSource +import eu.kanade.tachiyomi.animesource.model.AnimeFilter +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.ParsedAnimeHttpSource +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.POST +import eu.kanade.tachiyomi.network.asObservableSuccess +import eu.kanade.tachiyomi.util.asJsoup +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonPrimitive +import okhttp3.FormBody +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import org.jsoup.Jsoup +import org.jsoup.nodes.Document +import org.jsoup.nodes.Element +import rx.Observable +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get +import uy.kohesive.injekt.injectLazy +import java.text.CharacterIterator +import java.text.SimpleDateFormat +import java.text.StringCharacterIterator +import java.util.Locale + +class Kayoanime : ConfigurableAnimeSource, ParsedAnimeHttpSource() { + + override val name = "Kayoanime (Experimental)" + + override val baseUrl = "https://kayoanime.com" + + override val lang = "en" + + // Used for loading anime + private var infoQuery = "" + private var max = "" + private var latest_post = "" + private var layout = "" + private var settings = "" + private var currentReferer = "" + + override val supportsLatest = true + + override val client: OkHttpClient = network.cloudflareClient + + private val MAX_RECURSION_DEPTH = 2 + + private val json: Json by injectLazy() + + private val preferences: SharedPreferences by lazy { + Injekt.get().getSharedPreferences("source_$id", 0x0000) + } + + companion object { + private val DateFormatter by lazy { + SimpleDateFormat("d MMMM yyyy", Locale.ENGLISH) + } + } + + // ============================== Popular =============================== + + override fun popularAnimeRequest(page: Int): Request { + return if (page == 1) { + infoQuery = "" + max = "" + latest_post = "" + layout = "" + settings = "" + currentReferer = "https://kayoanime.com/ongoing-anime/" + GET("$baseUrl/ongoing-anime/") + } else { + val formBody = FormBody.Builder() + .add("action", "tie_archives_load_more") + .add("query", infoQuery) + .add("max", max) + .add("page", page.toString()) + .add("latest_post", latest_post) + .add("layout", layout) + .add("settings", settings) + .build() + val formHeaders = headers.newBuilder() + .add("Accept", "*/*") + .add("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8") + .add("Host", "kayoanime.com") + .add("Origin", "https://kayoanime.com") + .add("Referer", currentReferer) + .add("X-Requested-With", "XMLHttpRequest") + .build() + POST("$baseUrl/wp-admin/admin-ajax.php", body = formBody, headers = formHeaders) + } + } + + override fun popularAnimeParse(response: Response): AnimesPage { + return if (response.request.url.toString().endsWith("admin-ajax.php")) { + val body = response.body.string() + val rawParsed = json.decodeFromString(body) + val parsed = json.decodeFromString(rawParsed) + val soup = Jsoup.parse(parsed.code) + + val animes = soup.select("li.post-item").map { element -> + popularAnimeFromElement(element) + } + + AnimesPage(animes, !parsed.hide_next) + } else { + val document = response.asJsoup() + + val animes = document.select(popularAnimeSelector()).map { element -> + popularAnimeFromElement(element) + } + + val hasNextPage = popularAnimeNextPageSelector()?.let { selector -> + document.select(selector).first() + } != null + if (hasNextPage) { + val container = document.selectFirst("ul#posts-container")!! + val pagesNav = document.selectFirst("div.pages-nav > a")!! + layout = container.attr("data-layout") + infoQuery = pagesNav.attr("data-query") + max = pagesNav.attr("data-max") + latest_post = pagesNav.attr("data-latest") + settings = container.attr("data-settings") + } + + AnimesPage(animes, hasNextPage) + } + } + + override fun popularAnimeSelector(): String = "ul#posts-container > li.post-item" + + override fun popularAnimeNextPageSelector(): String = "div.pages-nav > a[data-text=load more]" + + override fun popularAnimeFromElement(element: Element): SAnime { + return SAnime.create().apply { + setUrlWithoutDomain(element.selectFirst("a")!!.attr("href").toHttpUrl().encodedPath) + thumbnail_url = element.selectFirst("img[src]")?.attr("src") ?: "" + title = element.selectFirst("h2.post-title")!!.text().substringBefore(" Episode") + } + } + + // =============================== Latest =============================== + + override fun latestUpdatesRequest(page: Int): Request = GET(baseUrl) + + override fun latestUpdatesSelector(): String = "ul.tabs:has(a:contains(Recent)) + div.tab-content li.widget-single-post-item" + + override fun latestUpdatesNextPageSelector(): String? = null + + override fun latestUpdatesFromElement(element: Element): SAnime { + return SAnime.create().apply { + setUrlWithoutDomain(element.selectFirst("a")!!.attr("href").toHttpUrl().encodedPath) + thumbnail_url = element.selectFirst("img[src]")?.attr("src") ?: "" + title = element.selectFirst("a.post-title")!!.text().substringBefore(" Episode") + } + } + + // =============================== Search =============================== + + override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request { + return if (page == 1) { + infoQuery = "" + max = "" + latest_post = "" + layout = "" + settings = "" + + val filterList = if (filters.isEmpty()) getFilterList() else filters + val genreFilter = filterList.find { it is GenreFilter } as GenreFilter + val subPageFilter = filterList.find { it is SubPageFilter } as SubPageFilter + + return when { + query.isNotBlank() -> { + val cleanQuery = query.replace(" ", "+") + currentReferer = "$baseUrl?s=$cleanQuery" + GET("$baseUrl?s=$cleanQuery") + } + genreFilter.state != 0 -> { + val url = "$baseUrl${genreFilter.toUriPart()}" + currentReferer = url + GET(url) + } + subPageFilter.state != 0 -> { + val url = "$baseUrl${subPageFilter.toUriPart()}" + currentReferer = url + GET(url) + } + else -> popularAnimeRequest(page) + } + } else { + val formBody = FormBody.Builder() + .add("action", "tie_archives_load_more") + .add("query", infoQuery) + .add("max", max) + .add("page", page.toString()) + .add("latest_post", latest_post) + .add("layout", layout) + .add("settings", settings) + .build() + val formHeaders = headers.newBuilder() + .add("Accept", "*/*") + .add("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8") + .add("Host", "kayoanime.com") + .add("Origin", "https://kayoanime.com") + .add("Referer", currentReferer) + .add("X-Requested-With", "XMLHttpRequest") + .build() + POST("$baseUrl/wp-admin/admin-ajax.php", body = formBody, headers = formHeaders) + } + } + + override fun searchAnimeParse(response: Response): AnimesPage = popularAnimeParse(response) + + override fun searchAnimeSelector(): String = popularAnimeSelector() + + override fun searchAnimeNextPageSelector(): String = popularAnimeNextPageSelector() + + override fun searchAnimeFromElement(element: Element): SAnime = popularAnimeFromElement(element) + + // ============================== Filters =============================== + + override fun getFilterList(): AnimeFilterList = AnimeFilterList( + AnimeFilter.Header("Text search ignores filters"), + SubPageFilter(), + GenreFilter(), + ) + + private class SubPageFilter : UriPartFilter( + "Sub-page", + arrayOf( + Pair("", ""), + Pair("Adventure", "/adventure/"), + Pair("Comedy", "/comedy/"), + Pair("Demons", "/demons/"), + Pair("Drama", "/drama/"), + Pair("Fantasy", "/fantasy/"), + Pair("Mecha", "/mecha/"), + Pair("Military", "/military/"), + Pair("Romance", "/romance/"), + Pair("School", "/school/"), + Pair("Sci-Fi", "/sci-fi/"), + Pair("Shounen", "/shounen/"), + Pair("Slice of Life", "/slice-of-life/"), + Pair("Sports", "/sports/"), + Pair("Super Power", "/super-power/"), + Pair("Supernatural", "/supernatural/"), + ), + ) + + private open class UriPartFilter(displayName: String, val vals: Array>) : + AnimeFilter.Select(displayName, vals.map { it.first }.toTypedArray()) { + fun toUriPart() = vals[state].second + } + + // =========================== Anime Details ============================ + + override fun fetchAnimeDetails(anime: SAnime): Observable { + return client.newCall(animeDetailsRequest(anime)) + .asObservableSuccess() + .map { response -> + animeDetailsParse(response, anime).apply { initialized = true } + } + } + + override fun animeDetailsParse(document: Document): SAnime = throw Exception("Not used") + + private fun animeDetailsParse(response: Response, anime: SAnime): SAnime { + val document = response.asJsoup() + val moreInfo = document.select("div.toggle-content > ul > li").joinToString("\n") { it.text() } + val realDesc = document.selectFirst("div.entry-content:has(div.toggle + div.clearfix + div.toggle:has(h3:contains(Information)))")?.let { + it.selectFirst("div.toggle > div.toggle-content")!!.text() + "\n\n" + } ?: "" + + return SAnime.create().apply { + title = anime.title + thumbnail_url = anime.thumbnail_url + status = document.selectFirst("div.toggle-content > ul > li:contains(Status)")?.let { + parseStatus(it.text()) + } ?: SAnime.UNKNOWN + description = realDesc + "\n\n$moreInfo" + genre = document.selectFirst("div.toggle-content > ul > li:contains(Genres)")?.let { + it.text().substringAfter("Genres: ") + } + author = document.selectFirst("div.toggle-content > ul > li:contains(Studios)")?.let { + it.text().substringAfter("Studios: ") + } + } + } + + // ============================== Episodes ============================== + + override fun episodeListParse(response: Response): List { + val document = response.asJsoup() + val episodeList = mutableListOf() + + fun traverseFolder(url: String, path: String, recursionDepth: Int = 0) { + if (recursionDepth == MAX_RECURSION_DEPTH) return + val headers = headers.newBuilder() + .add("Accept", "*/*") + .add("Connection", "keep-alive") + .add("Cookie", getCookie("https://drive.google.com")) + .add("Host", "drive.google.com") + + val driveDocument = client.newCall( + GET(url, headers = headers.build()), + ).execute().asJsoup() + + if (driveDocument.selectFirst("script:containsData(requestAccess)") != null) { + throw Exception("Please log in through webview on google drive & join group") + } + + val script = driveDocument.selectFirst("script:containsData(_DRIVE_ivd)") ?: return + val data = script.data().substringAfter("['_DRIVE_ivd'] = '").substringBeforeLast("';") + val decoded = Regex("\\\\x([0-9a-fA-F]{2})").replace(data) { matchResult -> + Integer.parseInt(matchResult.groupValues[1], 16).toChar().toString() + }.replace("\\\\\"", "\\\"") // Dirty fix, happens when item names includes `"` + + val folderArr = json.decodeFromString>(decoded) + + folderArr.first().jsonArray.forEachIndexed { index, item -> + val size = item.jsonArray.getOrNull(13)?.let { t -> formatBytes(t.toString().toLongOrNull()) } + val name = item.jsonArray.getOrNull(2)?.jsonPrimitive?.content ?: "Name unavailable" + val id = item.jsonArray.getOrNull(0)?.jsonPrimitive?.content ?: "" + val type = item.jsonArray.getOrNull(3)?.jsonPrimitive?.content ?: "Unknown type" + if (type.startsWith("video")) { + val episode = SEpisode.create() + episode.scanlator = if (preferences.getBoolean("scanlator_order", false)) { + "/$path • $size" + } else { + "$size • /$path" + } + episode.name = name.removePrefix("[Kayoanime] ") + episode.url = "https://drive.google.com/uc?id=$id" + episode.episode_number = index.toFloat() + episode.date_upload = -1L + episodeList.add(episode) + } + if (type.endsWith(".folder")) { + traverseFolder( + "https://drive.google.com/drive/folders/$id", + "$path/$name", + recursionDepth + 1, + ) + } + } + } + + document.select("div.toggle:has(> div.toggle-content > a[href*=drive.google.com])").distinctBy { t -> + getVideoPathsFromElement(t) + }.forEach { season -> + season.select("a[href*=drive.google.com]").distinctBy { it.text() }.forEach { + val url = it.selectFirst("a[href*=drive.google.com]")!!.attr("href").substringBeforeLast("?usp=share_link") + traverseFolder(url, getVideoPathsFromElement(season) + " " + it.text()) + } + } + + return episodeList.reversed() + } + + private fun getVideoPathsFromElement(element: Element): String { + return element.selectFirst("h3")!!.text() + .substringBefore("480p").substringBefore("720p").substringBefore("1080p") + } + + override fun episodeListSelector(): String = throw Exception("Not used") + + override fun episodeFromElement(element: Element): SEpisode = throw Exception("Not used") + + // ============================ Video Links ============================= + + override fun fetchVideoList(episode: SEpisode): Observable> { + val videoList = GoogleDriveExtractor(client, headers).videosFromUrl(episode.url) + return Observable.just(videoList) + } + + 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") + + // ============================= Utilities ============================== + + @Serializable + data class PostResponse( + val hide_next: Boolean, + val code: String, + ) + + private fun formatBytes(bytes: Long?): String? { + if (bytes == null) return null + val absB = if (bytes == Long.MIN_VALUE) Long.MAX_VALUE else Math.abs(bytes) + if (absB < 1024) { + return "$bytes B" + } + var value = absB + val ci: CharacterIterator = StringCharacterIterator("KMGTPE") + var i = 40 + while (i >= 0 && absB > 0xfffccccccccccccL shr i) { + value = value shr 10 + ci.next() + i -= 10 + } + value *= java.lang.Long.signum(bytes).toLong() + return java.lang.String.format("%.1f %cB", value / 1024.0, ci.current()) + } + + private fun getCookie(url: String): String { + val cookieList = client.cookieJar.loadForRequest(url.toHttpUrl()) + return if (cookieList.isNotEmpty()) { + cookieList.joinToString("; ") { "${it.name}=${it.value}" } + } else { + "" + } + } + + private fun parseStatus(statusString: String): Int { + return when (statusString) { + "Status: Currently Airing" -> SAnime.ONGOING + "Status: Finished Airing" -> SAnime.COMPLETED + else -> SAnime.UNKNOWN + } + } + + // From Dopebox + private fun Iterable.parallelMap(f: suspend (A) -> B): List = + runBlocking { + map { async(Dispatchers.Default) { f(it) } }.awaitAll() + } + + override fun setupPreferenceScreen(screen: PreferenceScreen) { + val scanlatorOrder = SwitchPreferenceCompat(screen.context).apply { + key = "scanlator_order" + title = "Switch order of file path and size" + setDefaultValue(false) + setOnPreferenceChangeListener { _, newValue -> + preferences.edit().putBoolean(key, newValue as Boolean).commit() + } + } + + screen.addPreference(scanlatorOrder) + } +} diff --git a/src/en/kayoanime/src/eu/kanade/tachiyomi/animeextension/en/kayoanime/extractors/GoogleDriveExtractor.kt b/src/en/kayoanime/src/eu/kanade/tachiyomi/animeextension/en/kayoanime/extractors/GoogleDriveExtractor.kt new file mode 100644 index 000000000..c64697c44 --- /dev/null +++ b/src/en/kayoanime/src/eu/kanade/tachiyomi/animeextension/en/kayoanime/extractors/GoogleDriveExtractor.kt @@ -0,0 +1,95 @@ +package eu.kanade.tachiyomi.animeextension.en.kayoanime.extractors + +import eu.kanade.tachiyomi.animesource.model.Video +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.POST +import eu.kanade.tachiyomi.util.asJsoup +import okhttp3.Headers +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.RequestBody.Companion.toRequestBody + +class GoogleDriveExtractor(private val client: OkHttpClient, private val headers: Headers) { + // Needs to be the form of `https://drive.google.com/uc?id=GOOGLEDRIVEITEMID` + fun videosFromUrl(itemUrl: String, videoName: String = "Video"): List