diff --git a/lib/googledrive-episodes/build.gradle.kts b/lib/googledrive-episodes/build.gradle.kts new file mode 100644 index 0000000000..c26cbc8a82 --- /dev/null +++ b/lib/googledrive-episodes/build.gradle.kts @@ -0,0 +1,3 @@ +plugins { + id("lib-android") +} diff --git a/lib/googledrive-episodes/src/main/java/eu/kanade/tachiyomi/lib/googledriveepisodes/GoogleDriveEpisodes.kt b/lib/googledrive-episodes/src/main/java/eu/kanade/tachiyomi/lib/googledriveepisodes/GoogleDriveEpisodes.kt new file mode 100644 index 0000000000..229ff7747d --- /dev/null +++ b/lib/googledrive-episodes/src/main/java/eu/kanade/tachiyomi/lib/googledriveepisodes/GoogleDriveEpisodes.kt @@ -0,0 +1,176 @@ +package eu.kanade.tachiyomi.lib.googledriveepisodes + +import eu.kanade.tachiyomi.animesource.model.SEpisode +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.POST +import eu.kanade.tachiyomi.util.asJsoup +import eu.kanade.tachiyomi.util.parseAs +import kotlinx.serialization.Serializable +import okhttp3.Headers +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.RequestBody.Companion.toRequestBody +import java.security.MessageDigest + +class GoogleDriveEpisodes(private val client: OkHttpClient, private val headers: Headers) { + // Lots of code borrowed from https://github.com/yt-dlp/yt-dlp/blob/master/yt_dlp/extractor/googledrive.py under the `GoogleDriveFolderIE` class + fun getEpisodesFromFolder(folderId: String, path: String, maxRecDepth: Int, trimNames: Boolean): List { + val episodeList = mutableListOf() + + fun traverseFolder(folderId: String, path: String, recursionDepth: Int = 0) { + if (recursionDepth == maxRecDepth) return + + val driveHeaders = headers.newBuilder() + .add("Accept", "*/*") + .add("Connection", "keep-alive") + .add("Cookie", getCookie("https://drive.google.com")) + .add("Host", "drive.google.com") + .build() + + val driveDocument = client.newCall( + GET("https://drive.google.com/drive/folders/$folderId", headers = driveHeaders), + ).execute().asJsoup() + if (driveDocument.selectFirst("title:contains(Error 404 \\(Not found\\))") != null) return + + val keyScript = driveDocument.select("script").first { script -> + KEY_REGEX.find(script.data()) != null + }.data() + val key = KEY_REGEX.find(keyScript)?.groupValues?.get(1) ?: "" + + val versionScript = driveDocument.select("script").first { script -> + KEY_REGEX.find(script.data()) != null + }.data() + val driveVersion = VERSION_REGEX.find(versionScript)?.groupValues?.get(1) ?: "" + val sapisid = client.cookieJar.loadForRequest("https://drive.google.com".toHttpUrl()).firstOrNull { + it.name == "SAPISID" || it.name == "__Secure-3PAPISID" + }?.value ?: "" + + var pageToken: String? = "" + while (pageToken != null) { + val requestUrl = "/drive/v2internal/files?openDrive=false&reason=102&syncType=0&errorRecovery=false&q=trashed%20%3D%20false%20and%20'$folderId'%20in%20parents&fields=kind%2CnextPageToken%2Citems(kind%2CmodifiedDate%2ChasVisitorPermissions%2CcontainsUnsubscribedChildren%2CmodifiedByMeDate%2ClastViewedByMeDate%2CalternateLink%2CfileSize%2Cowners(kind%2CpermissionId%2CemailAddressFromAccount%2Cdomain%2Cid)%2ClastModifyingUser(kind%2CpermissionId%2CemailAddressFromAccount%2Cid)%2CcustomerId%2CancestorHasAugmentedPermissions%2ChasThumbnail%2CthumbnailVersion%2Ctitle%2Cid%2CresourceKey%2CabuseIsAppealable%2CabuseNoticeReason%2Cshared%2CaccessRequestsCount%2CsharedWithMeDate%2CuserPermission(role)%2CexplicitlyTrashed%2CmimeType%2CquotaBytesUsed%2Ccopyable%2Csubscribed%2CfolderColor%2ChasChildFolders%2CfileExtension%2CprimarySyncParentId%2CsharingUser(kind%2CpermissionId%2CemailAddressFromAccount%2Cid)%2CflaggedForAbuse%2CfolderFeatures%2Cspaces%2CsourceAppId%2Crecency%2CrecencyReason%2Cversion%2CactionItems%2CteamDriveId%2ChasAugmentedPermissions%2CcreatedDate%2CprimaryDomainName%2CorganizationDisplayName%2CpassivelySubscribed%2CtrashingUser(kind%2CpermissionId%2CemailAddressFromAccount%2Cid)%2CtrashedDate%2Cparents(id)%2Ccapabilities(canMoveItemIntoTeamDrive%2CcanUntrash%2CcanMoveItemWithinTeamDrive%2CcanMoveItemOutOfTeamDrive%2CcanDeleteChildren%2CcanTrashChildren%2CcanRequestApproval%2CcanReadCategoryMetadata%2CcanEditCategoryMetadata%2CcanAddMyDriveParent%2CcanRemoveMyDriveParent%2CcanShareChildFiles%2CcanShareChildFolders%2CcanRead%2CcanMoveItemWithinDrive%2CcanMoveChildrenWithinDrive%2CcanAddFolderFromAnotherDrive%2CcanChangeSecurityUpdateEnabled%2CcanBlockOwner%2CcanReportSpamOrAbuse%2CcanCopy%2CcanDownload%2CcanEdit%2CcanAddChildren%2CcanDelete%2CcanRemoveChildren%2CcanShare%2CcanTrash%2CcanRename%2CcanReadTeamDrive%2CcanMoveTeamDriveItem)%2CcontentRestrictions(readOnly)%2CapprovalMetadata(approvalVersion%2CapprovalSummaries%2ChasIncomingApproval)%2CshortcutDetails(targetId%2CtargetMimeType%2CtargetLookupStatus%2CtargetFile%2CcanRequestAccessToTarget)%2CspamMetadata(markedAsSpamDate%2CinSpamView)%2Clabels(starred%2Ctrashed%2Crestricted%2Cviewed))%2CincompleteSearch&appDataFilter=NO_APP_DATA&spaces=drive&pageToken=$pageToken&maxResults=100&supportsTeamDrives=true&includeItemsFromAllDrives=true&corpora=default&orderBy=folder%2Ctitle_natural%20asc&retryCount=0&key=$key HTTP/1.1" + val body = """--$BOUNDARY + |content-type: application/http + |content-transfer-encoding: binary + | + |GET $requestUrl + |X-Goog-Drive-Client-Version: $driveVersion + |authorization: ${generateSapisidhashHeader(sapisid)} + |x-goog-authuser: 0 + | + |--$BOUNDARY--""".trimMargin("|").toRequestBody("multipart/mixed; boundary=\"$BOUNDARY\"".toMediaType()) + + val postUrl = buildString { + append("https://clients6.google.com/batch/drive/v2internal") + append("?${'$'}ct=multipart/mixed; boundary=\"$BOUNDARY\"") + append("&key=$key") + } + + val postHeaders = headers.newBuilder() + .add("Content-Type", "text/plain; charset=UTF-8") + .add("Origin", "https://drive.google.com") + .add("Cookie", getCookie("https://drive.google.com")) + .build() + + val response = client.newCall( + POST(postUrl, body = body, headers = postHeaders), + ).execute() + + val parsed = response.parseAs { + JSON_REGEX.find(it)!!.groupValues[1] + } + + if (parsed.items == null) throw Exception("Failed to load items, please log in to google drive through webview") + parsed.items.forEachIndexed { index, it -> + if (it.mimeType.startsWith("video")) { + val size = it.fileSize?.toLongOrNull()?.let { formatBytes(it) } + val pathName = path.trimInfo() + + episodeList.add( + SEpisode.create().apply { + name = if (trimNames) it.title.trimInfo() else it.title + this.url = "https://drive.google.com/uc?id=${it.id}" + episode_number = ITEM_NUMBER_REGEX.find(it.title.trimInfo())?.groupValues?.get(1)?.toFloatOrNull() ?: index.toFloat() + date_upload = -1L + scanlator = "$size • /$pathName" + }, + ) + } + if (it.mimeType.endsWith(".folder")) { + traverseFolder(it.id, "$path/${it.title}", recursionDepth + 1) + } + } + + pageToken = parsed.nextPageToken + } + } + + traverseFolder(folderId, path) + + return episodeList + } + + // https://github.com/yt-dlp/yt-dlp/blob/8f0be90ecb3b8d862397177bb226f17b245ef933/yt_dlp/extractor/youtube.py#L573 + private fun generateSapisidhashHeader(SAPISID: String, origin: String = "https://drive.google.com"): String { + val timeNow = System.currentTimeMillis() / 1000 + // SAPISIDHASH algorithm from https://stackoverflow.com/a/32065323 + val sapisidhash = MessageDigest + .getInstance("SHA-1") + .digest("$timeNow $SAPISID $origin".toByteArray()) + .joinToString("") { "%02x".format(it) } + return "SAPISIDHASH ${timeNow}_$sapisidhash" + } + + @Serializable + data class GDrivePostResponse( + val nextPageToken: String? = null, + val items: List? = null, + ) { + @Serializable + data class ResponseItem( + val id: String, + val title: String, + val mimeType: String, + val fileSize: String? = null, + ) + } + + private fun String.trimInfo(): String { + var newString = this.replaceFirst("""^\[\w+\] ?""".toRegex(), "") + val regex = """( ?\[[\s\w-]+\]| ?\([\s\w-]+\))(\.mkv|\.mp4|\.avi)?${'$'}""".toRegex() + + while (regex.containsMatchIn(newString)) { + newString = regex.replace(newString) { matchResult -> + matchResult.groups[2]?.value ?: "" + } + } + + return newString.trim() + } + + private fun formatBytes(bytes: Long): String = when { + bytes >= 1_000_000_000 -> "%.2f GB".format(bytes / 1_000_000_000.0) + bytes >= 1_000_000 -> "%.2f MB".format(bytes / 1_000_000.0) + bytes >= 1_000 -> "%.2f KB".format(bytes / 1_000.0) + bytes > 1 -> "$bytes bytes" + bytes == 1L -> "$bytes byte" + else -> "" + } + + private fun getCookie(url: String): String { + val cookieList = client.cookieJar.loadForRequest(url.toHttpUrl()) + return if (cookieList.isNotEmpty()) { + cookieList.joinToString("; ") { "${it.name}=${it.value}" } + } else { + "" + } + } + + companion object { + private val ITEM_NUMBER_REGEX = """ - (?:S\d+E)?(\d+)""".toRegex() + private val KEY_REGEX = """"(\w{39})"""".toRegex() + private val VERSION_REGEX = """"([^"]+web-frontend[^"]+)"""".toRegex() + private val JSON_REGEX = """(?:)\s*(\{(.+)\})\s*(?:)""".toRegex(RegexOption.DOT_MATCHES_ALL) + private const val BOUNDARY = "=====vc17a3rwnndj=====" + } +} diff --git a/src/en/animesakura/README.md b/src/en/animesakura/README.md new file mode 100644 index 0000000000..4e7ea87d97 --- /dev/null +++ b/src/en/animesakura/README.md @@ -0,0 +1,3 @@ +# DISCLAIMER + +This extension requires you to log in through Google and relies heavily on scraping the website of Google Drive, which may be against their terms of service. Use at your own risk. diff --git a/src/en/animesakura/build.gradle b/src/en/animesakura/build.gradle new file mode 100644 index 0000000000..da638d6a5e --- /dev/null +++ b/src/en/animesakura/build.gradle @@ -0,0 +1,12 @@ +ext { + extName = 'Anime Sakura' + extClass = '.AnimeSakura' + extVersionCode = 1 +} + +apply from: "$rootDir/common.gradle" + +dependencies { + implementation(project(':lib:googledrive-extractor')) + implementation(project(':lib:googledrive-episodes')) +} \ No newline at end of file diff --git a/src/en/animesakura/res/mipmap-hdpi/ic_launcher.png b/src/en/animesakura/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000..9e53b7a990 Binary files /dev/null and b/src/en/animesakura/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/en/animesakura/res/mipmap-mdpi/ic_launcher.png b/src/en/animesakura/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000..caa402f492 Binary files /dev/null and b/src/en/animesakura/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/en/animesakura/res/mipmap-xhdpi/ic_launcher.png b/src/en/animesakura/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000000..5d3bad6082 Binary files /dev/null and b/src/en/animesakura/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/en/animesakura/res/mipmap-xxhdpi/ic_launcher.png b/src/en/animesakura/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000..95caa92766 Binary files /dev/null and b/src/en/animesakura/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/en/animesakura/res/mipmap-xxxhdpi/ic_launcher.png b/src/en/animesakura/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000..31d56757e3 Binary files /dev/null and b/src/en/animesakura/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/en/animesakura/src/eu/kanade/tachiyomi/animeextension/en/animesakura/AnimeSakura.kt b/src/en/animesakura/src/eu/kanade/tachiyomi/animeextension/en/animesakura/AnimeSakura.kt new file mode 100644 index 0000000000..552532bcda --- /dev/null +++ b/src/en/animesakura/src/eu/kanade/tachiyomi/animeextension/en/animesakura/AnimeSakura.kt @@ -0,0 +1,477 @@ +package eu.kanade.tachiyomi.animeextension.en.animesakura + +import android.app.Application +import android.content.SharedPreferences +import androidx.preference.EditTextPreference +import androidx.preference.PreferenceScreen +import androidx.preference.SwitchPreferenceCompat +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.AnimeHttpSource +import eu.kanade.tachiyomi.lib.googledriveepisodes.GoogleDriveEpisodes +import eu.kanade.tachiyomi.lib.googledriveextractor.GoogleDriveExtractor +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.POST +import eu.kanade.tachiyomi.util.asJsoup +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import okhttp3.FormBody +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.Request +import okhttp3.Response +import org.jsoup.Jsoup +import org.jsoup.nodes.Element +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get +import uy.kohesive.injekt.injectLazy + +class AnimeSakura : ConfigurableAnimeSource, AnimeHttpSource() { + + override val name = "Anime Sakura" + + override val baseUrl = "https://animesakura.co" + + override val lang = "en" + + // Used for loading anime + private var infoQuery = "" + private var max = "" + private var latestPost = "" + private var layout = "" + private var settings = "" + private var currentReferer = "" + + override val supportsLatest = true + + private val json: Json by injectLazy() + + private val preferences: SharedPreferences by lazy { + Injekt.get().getSharedPreferences("source_$id", 0x0000) + } + + // ============================== Popular =============================== + + override fun popularAnimeRequest(page: Int): Request { + val formBody = FormBody.Builder().apply { + add("action", "tie_blocks_load_more") + add("block[order]", "views") + add("block[asc_or_desc]", "DESC") + add("block[id][]", "3") + add("block[number]", "24") + addExtra(page) + }.build() + + val formHeaders = headersBuilder().apply { + add("Accept", "*/*") + add("Host", baseUrl.toHttpUrl().host) + add("Origin", baseUrl) + add("Referer", "$baseUrl/anime-series/") + add("X-Requested-With", "XMLHttpRequest") + }.build() + + return POST("$baseUrl/wp-admin/admin-ajax.php", formHeaders, formBody) + } + + override fun popularAnimeParse(response: Response): AnimesPage { + val body = response.body.string() + val rawParsed = json.decodeFromString(body) + val parsed = json.decodeFromString(rawParsed) + val document = Jsoup.parseBodyFragment(parsed.code) + + val animeList = document.select("li.post-item") + .map(::popularAnimeFromElement) + + return AnimesPage(animeList, !parsed.hide_next) + } + + private fun popularAnimeFromElement(element: Element): SAnime = SAnime.create().apply { + setUrlWithoutDomain(element.selectFirst("a")!!.attr("href")) + thumbnail_url = element.selectFirst("img[src]")?.attr("src") ?: "" + title = element.selectFirst("h2.post-title")!!.text().substringBefore(" Episode") + } + + // =============================== Latest =============================== + + override fun latestUpdatesRequest(page: Int): Request { + val formBody = FormBody.Builder().apply { + add("action", "tie_blocks_load_more") + add("block[asc_or_desc]", "DESC") + add("block[id][]", "14") + add("block[number]", "10") + addExtra(page) + }.build() + + val formHeaders = headersBuilder().apply { + add("Accept", "*/*") + add("Host", baseUrl.toHttpUrl().host) + add("Origin", baseUrl) + add("Referer", "$baseUrl/ongoing-anime/") + add("X-Requested-With", "XMLHttpRequest") + }.build() + + return POST("$baseUrl/wp-admin/admin-ajax.php", formHeaders, formBody) + } + + override fun latestUpdatesParse(response: Response): AnimesPage = popularAnimeParse(response) + + // =============================== Search =============================== + + override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request { + val subPage = filters.filterIsInstance().first().toUriPart() + val genreFilter = filters.filterIsInstance().first().toUriPart() + + if (query.isEmpty() && subPage.isNotEmpty()) { + val formBody = FormBody.Builder().apply { + add("action", "tie_blocks_load_more") + add("block[asc_or_desc]", "DESC") + add("block[id][]", "35") + add("block[number]", "15") + addExtra(page) + }.build() + val formHeaders = headersBuilder().apply { + add("Accept", "*/*") + add("Host", baseUrl.toHttpUrl().host) + add("Origin", baseUrl) + add("Referer", "$baseUrl/anime-movies/") + add("X-Requested-With", "XMLHttpRequest") + }.build() + return POST("$baseUrl/wp-admin/admin-ajax.php", formHeaders, formBody) + } + + return if (page == 1) { + infoQuery = "" + max = "" + latestPost = "" + layout = "" + settings = "" + currentReferer = "" + + val docHeaders = headersBuilder().apply { + add("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8") + }.build() + + if (query.isNotEmpty()) { + val url = baseUrl.toHttpUrl().newBuilder().apply { + addPathSegment("") + addQueryParameter("s", query) + }.build() + currentReferer = url.toString() + GET(url, docHeaders) + } else if (genreFilter.isNotEmpty()) { + currentReferer = "$baseUrl/category/$genreFilter" + GET("$baseUrl/category/$genreFilter", docHeaders) + } else { + currentReferer = "$baseUrl/?s=" + GET("$baseUrl/?s=", docHeaders) + } + } else { + val formBody = FormBody.Builder().apply { + add("action", "tie_archives_load_more") + add("query", infoQuery) + add("max", max) + add("page", page.toString()) + add("latest_post", latestPost) + add("layout", layout) + add("settings", settings) + }.build() + val formHeaders = headersBuilder().apply { + add("Accept", "*/*") + add("Host", baseUrl.toHttpUrl().host) + add("Origin", baseUrl) + add("Referer", currentReferer) + add("X-Requested-With", "XMLHttpRequest") + }.build() + POST("$baseUrl/wp-admin/admin-ajax.php", formHeaders, formBody) + } + } + + override fun searchAnimeParse(response: Response): AnimesPage { + return if (response.request.url.toString().contains("admin-ajax")) { + popularAnimeParse(response) + } else { + val document = response.asJsoup() + val animeList = document.select("ul#posts-container > li.post-item").map { element -> + SAnime.create().apply { + setUrlWithoutDomain(element.selectFirst("a")!!.attr("abs:href")) + thumbnail_url = element.selectFirst("img")!!.imgAttr() + title = element.selectFirst("h2.post-title")!!.text().substringBefore(" Episode") + } + } + val hasNextPage = document.selectFirst("div.pages-nav > a[data-text=load more]") != 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") + latestPost = pagesNav.attr("data-latest") + settings = container.attr("data-settings") + } + + AnimesPage(animeList, hasNextPage) + } + } + + // ============================== Filters =============================== + + override fun getFilterList(): AnimeFilterList = AnimeFilterList( + AnimeFilter.Header("Text search ignores filters"), + SubPageFilter(), + GenreFilter(), + ) + + private class SubPageFilter : UriPartFilter( + "Sub-page", + arrayOf( + Pair("", ""), + Pair("Action", "action"), + Pair("Adventure", "adventure"), + Pair("Romance", "romance"), + Pair("Ecchi", "ecchi"), + Pair("School", "school"), + Pair("Harem", "harem"), + Pair("Sci-fi", "sci-fi"), + Pair("Comedy", "comedy"), + Pair("Drama", "drama"), + Pair("Mystery", "mystery"), + Pair("Military", "military"), + Pair("Fantasy", "fantasy"), + Pair("Isekai", "isekai"), + Pair("Psychological", "psychological"), + Pair("Shoujo", "shoujo"), + Pair("Slice of Life", "slice-of-life"), + Pair("Shounen", "shounen"), + Pair("Sports", "sports"), + Pair("Supernatural", "supernatural-2"), + ), + ) + + 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 animeDetailsParse(response: Response): SAnime { + val document = response.asJsoup() + val moreInfo = Jsoup.parseBodyFragment( + document.selectFirst("div.toggle-content li p")?.html()?.replace("
", "br2n") ?: "", + ).text().replace("br2n", "\n") + val realDesc = document.select("div.stream-item ~ p").joinToString("\n\n") { it.text() } + + return SAnime.create().apply { + 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").substringAfter("⋩ ").substringBefore(" ❀") + } + author = document.selectFirst("div.toggle-content > ul > li:contains(Studios)")?.let { + it.text().substringAfter("Studios").substringAfter("⋩ ").substringBefore("⁃") + } + } + } + + // ============================== Episodes ============================== + + private val googleDriveEpisodes by lazy { GoogleDriveEpisodes(client, headers) } + private val indexExtractor by lazy { DriveIndexExtractor(client, headers) } + + override fun episodeListParse(response: Response): List { + val document = response.asJsoup() + val episodeList = mutableListOf() + val trimNames = preferences.trimEpisodeName + val blackListed = preferences.blKeywords + + document.select("div.toggle:has(> div.toggle-content > a[href*=drive.google.com]),div.toggle:has(a.shortc-button[href*=drive.google.com])").distinctBy { t -> + getVideoPathsFromElement(t) + }.forEach { season -> + season.select("a[href*=drive.google.com]").distinctBy { it.text() }.forEach season@{ + if (blackListed.any { t -> it.text().contains(t, true) }) return@season + val folderId = it.selectFirst("a[href*=drive.google.com]")!!.attr("abs:href").toHttpUrl().pathSegments[2] + episodeList.addAll( + googleDriveEpisodes.getEpisodesFromFolder(folderId, "${getVideoPathsFromElement(season)} ${it.text()}", 2, trimNames), + ) + } + } + + document.select("div.wp-block-buttons > div.wp-block-button a[href*=drive.google.com]").distinctBy { + it.text() + }.forEach { + if (blackListed.any { t -> it.text().contains(t, true) }) return@forEach + val folderId = it.attr("abs:href").toHttpUrl().pathSegments[2] + episodeList.addAll( + googleDriveEpisodes.getEpisodesFromFolder(folderId, it.text(), 2, trimNames), + ) + } + + document.select("div.toggle:has(> div.toggle-content > a[href*=workers.dev]),div.toggle:has(a.shortc-button[href*=workers.dev])").distinctBy { t -> + getVideoPathsFromElement(t) + }.forEach { season -> + season.select("a[href*=workers.dev]").distinctBy { it.text() }.forEach season@{ + if (blackListed.any { t -> it.text().contains(t, true) }) return@season + runCatching { + episodeList.addAll( + indexExtractor.getEpisodesFromIndex(it.attr("abs:href"), "${getVideoPathsFromElement(season)} ${it.text()}", trimNames), + ) + } + } + } + + return episodeList.reversed() + } + + private fun getVideoPathsFromElement(element: Element): String { + return element.selectFirst("h3")!!.text() + .substringBefore("480p").substringBefore("720p").substringBefore("1080p") + .replace("Download - ", "", true) + .replace("Download The Anime From Worker ?", "", true) + .replace("Download The Anime From Drive ", "", true) + .trim() + } + + // ============================ Video Links ============================= + + private val googleDriveExtractor by lazy { GoogleDriveExtractor(client, headers) } + + override suspend fun getVideoList(episode: SEpisode): List