Initial commit

This commit is contained in:
almightyhak 2024-06-20 11:54:12 +07:00
commit 98ed7e8839
2263 changed files with 108711 additions and 0 deletions

View file

@ -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.

View file

@ -0,0 +1,11 @@
ext {
extName = 'Kayoanime'
extClass = '.Kayoanime'
extVersionCode = 12
}
apply from: "$rootDir/common.gradle"
dependencies {
implementation(project(':lib:googledrive-extractor'))
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

View file

@ -0,0 +1,162 @@
package eu.kanade.tachiyomi.animeextension.en.kayoanime
import android.util.Base64
import eu.kanade.tachiyomi.animesource.model.SEpisode
import eu.kanade.tachiyomi.network.POST
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import okhttp3.Headers
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.RequestBody.Companion.toRequestBody
import uy.kohesive.injekt.injectLazy
import java.net.URLEncoder
class DriveIndexExtractor(private val client: OkHttpClient, private val headers: Headers) {
private val json: Json by injectLazy()
fun getEpisodesFromIndex(
indexUrl: String,
path: String,
trimName: Boolean,
): List<SEpisode> {
val episodeList = mutableListOf<SEpisode>()
val basePathCounter = indexUrl.toHttpUrl().pathSegments.size
var counter = 1
fun traverseDirectory(url: String) {
var newToken: String? = ""
var newPageIndex = 0
while (newToken != null) {
val popHeaders = headers.newBuilder()
.add("Accept", "*/*")
.add("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8")
.add("Host", url.toHttpUrl().host)
.add("Origin", "https://${url.toHttpUrl().host}")
.add("Referer", URLEncoder.encode(url, "UTF-8"))
.add("X-Requested-With", "XMLHttpRequest")
.build()
val popBody = "password=&page_token=$newToken&page_index=$newPageIndex".toRequestBody("application/x-www-form-urlencoded".toMediaType())
val parsedBody = client.newCall(
POST(url, body = popBody, headers = popHeaders),
).execute().body.string().decrypt()
val parsed = json.decodeFromString<ResponseData>(parsedBody)
parsed.data.files.forEach { item ->
if (item.mimeType.endsWith("folder")) {
val newUrl = joinUrl(url, item.name).addSuffix("/")
traverseDirectory(newUrl)
}
if (item.mimeType.startsWith("video/")) {
val epUrl = joinUrl(url, item.name)
val paths = epUrl.toHttpUrl().pathSegments
// Get other info
val season = if (paths.size == basePathCounter) {
""
} else {
paths[basePathCounter - 1]
}
val seasonInfoRegex = """(\([\s\w-]+\))(?: ?\[[\s\w-]+\])?${'$'}""".toRegex()
val seasonInfo = if (seasonInfoRegex.containsMatchIn(season)) {
"${seasonInfoRegex.find(season)!!.groups[1]!!.value}"
} else {
""
}
val extraInfo = if (paths.size > basePathCounter) {
"/$path/" + paths.subList(basePathCounter - 1, paths.size - 1).joinToString("/") { it.trimInfo() }
} else {
"/$path"
}
val size = item.size?.toLongOrNull()?.let { formatFileSize(it) }
episodeList.add(
SEpisode.create().apply {
name = if (trimName) item.name.trimInfo() else item.name
this.url = epUrl
scanlator = "${if (size == null) "" else "$size"}$seasonInfo$extraInfo"
date_upload = -1L
episode_number = counter.toFloat()
},
)
counter++
}
}
newToken = parsed.nextPageToken
newPageIndex += 1
}
}
traverseDirectory(indexUrl)
return episodeList
}
@Serializable
data class ResponseData(
val nextPageToken: String? = null,
val data: DataObject,
) {
@Serializable
data class DataObject(
val files: List<FileObject>,
) {
@Serializable
data class FileObject(
val mimeType: String,
val id: String,
val name: String,
val modifiedTime: String? = null,
val size: 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 String.addSuffix(suffix: String): String {
return if (this.endsWith(suffix)) {
this
} else {
this.plus(suffix)
}
}
private fun String.decrypt(): String {
return Base64.decode(this.reversed().substring(24, this.length - 20), Base64.DEFAULT).toString(Charsets.UTF_8)
}
private fun joinUrl(path1: String, path2: String): String {
return path1.removeSuffix("/") + "/" + path2.removePrefix("/")
}
private fun formatFileSize(bytes: Long): String {
return when {
bytes >= 1073741824 -> "%.2f GB".format(bytes / 1073741824.0)
bytes >= 1048576 -> "%.2f MB".format(bytes / 1048576.0)
bytes >= 1024 -> "%.2f KB".format(bytes / 1024.0)
bytes > 1 -> "$bytes bytes"
bytes == 1L -> "$bytes byte"
else -> ""
}
}
}

View file

@ -0,0 +1,592 @@
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.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.lib.googledriveextractor.GoogleDriveExtractor
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 kotlinx.serialization.json.Json
import okhttp3.FormBody
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
import java.security.MessageDigest
class Kayoanime : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
override val name = "Kayoanime"
override val id = 203922289858257167
override val baseUrl = "https://kayoanime.com"
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<Application>().getSharedPreferences("source_$id", 0x0000)
}
// ============================== Popular ===============================
override fun popularAnimeRequest(page: Int): Request {
return if (page == 1) {
infoQuery = ""
max = ""
latestPost = ""
layout = ""
settings = ""
currentReferer = "https://kayoanime.com/ongoing-animes/"
GET("$baseUrl/ongoing-animes/")
} else {
val formBody = FormBody.Builder()
.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 = 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<String>(body)
val parsed = json.decodeFromString<PostResponse>(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")
latestPost = pagesNav.attr("data-latest")
settings = container.attr("data-settings")
}
AnimesPage(animes, hasNextPage)
}
}
override fun popularAnimeSelector(): String = "ul#posts-container > li.post-item"
override 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")
}
override fun popularAnimeNextPageSelector(): String = "div.pages-nav > a[data-text=load more]"
// =============================== 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 latestUpdatesFromElement(element: Element): SAnime = SAnime.create().apply {
setUrlWithoutDomain(element.selectFirst("a")!!.attr("href"))
thumbnail_url = element.selectFirst("img[src]")?.attr("src") ?: ""
title = element.selectFirst("a.post-title")!!.text().substringBefore(" Episode")
}
override fun latestUpdatesNextPageSelector(): String? = null
// =============================== Search ===============================
override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request {
return if (page == 1) {
infoQuery = ""
max = ""
latestPost = ""
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", latestPost)
.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 =
throw UnsupportedOperationException()
override fun searchAnimeFromElement(element: Element): SAnime =
throw UnsupportedOperationException()
override fun searchAnimeNextPageSelector(): String =
throw UnsupportedOperationException()
// ============================== Filters ===============================
override fun getFilterList(): AnimeFilterList = AnimeFilterList(
AnimeFilter.Header("Text search ignores filters"),
SubPageFilter(),
GenreFilter(),
)
private class SubPageFilter : UriPartFilter(
"Sub-page",
arrayOf(
Pair("<select>", ""),
Pair("Anime Series", "/anime-series/"),
Pair("Anime Movie", "/anime-movie/"),
),
)
private class GenreFilter : UriPartFilter(
"Genres",
arrayOf(
Pair("<select>", ""),
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<Pair<String, String>>) :
AnimeFilter.Select<String>(displayName, vals.map { it.first }.toTypedArray()) {
fun toUriPart() = vals[state].second
}
// =========================== Anime Details ============================
override fun animeDetailsParse(document: Document): SAnime {
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 {
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 ==============================
// Lots of code borrowed from https://github.com/yt-dlp/yt-dlp/blob/master/yt_dlp/extractor/googledrive.py under the `GoogleDriveFolderIE` class
override fun episodeListParse(response: Response): List<SEpisode> {
val document = response.asJsoup()
val episodeList = mutableListOf<SEpisode>()
fun traverseFolder(url: String, path: String, recursionDepth: Int = 0) {
if (recursionDepth == MAX_RECURSION_DEPTH) return
val folderId = url.substringAfter("/folders/")
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(url, 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<GDrivePostResponse> {
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 (preferences.trimEpisodeName) 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(
"https://drive.google.com/drive/folders/${it.id}",
"$path/${it.title}",
recursionDepth + 1,
)
}
}
pageToken = parsed.nextPageToken
}
}
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=shar")
traverseFolder(url, getVideoPathsFromElement(season) + " " + it.text())
}
}
val noRedirectClient = client.newBuilder().followRedirects(false).build()
val indexExtractor = DriveIndexExtractor(client, headers)
document.select("div.toggle:has(> div.toggle-content > a[href*=tinyurl.com])").forEach { season ->
season.select("a[href*=tinyurl.com]").forEach {
val url = it.selectFirst("a[href*=tinyurl.com]")!!.attr("href")
val redirected = noRedirectClient.newCall(GET(url)).execute()
redirected.headers["location"]?.let { location ->
val host = location.toHttpUrl().host
if (host.contains("workers.dev")) {
episodeList.addAll(
indexExtractor.getEpisodesFromIndex(
location,
getVideoPathsFromElement(season) + " " + it.text(),
preferences.trimEpisodeName,
),
)
}
if (host.contains("slogoanime")) {
val document = client.newCall(GET(location)).execute().asJsoup()
document.select("a[href*=drive.google.com]").distinctBy { it.text() }.forEach {
val url = it.selectFirst("a[href*=drive.google.com]")!!.attr("href").substringBeforeLast("?usp=shar")
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")
.replace("Download The Anime From Drive", "", true)
}
override fun episodeListSelector(): String = throw UnsupportedOperationException()
override fun episodeFromElement(element: Element): SEpisode = throw UnsupportedOperationException()
// ============================ Video Links =============================
override suspend fun getVideoList(episode: SEpisode): List<Video> {
val httpUrl = episode.url.toHttpUrl()
val host = httpUrl.host
return if (host == "drive.google.com") {
val id = httpUrl.queryParameter("id")!!
GoogleDriveExtractor(client, headers).videosFromUrl(id)
} else if (host.contains("workers.dev")) {
getIndexVideoUrl(episode.url)
} else {
throw Exception("Unsupported url: ${episode.url}")
}
}
override fun videoListSelector(): String = throw UnsupportedOperationException()
override fun videoFromElement(element: Element): Video = throw UnsupportedOperationException()
override fun videoUrlParse(document: Document): String = throw UnsupportedOperationException()
// ============================= Utilities ==============================
// 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<ResponseItem>? = 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 getIndexVideoUrl(url: String): List<Video> {
val doc = client.newCall(
GET("$url?a=view"),
).execute().asJsoup()
val script = doc.selectFirst("script:containsData(videodomain)")?.data()
?: doc.selectFirst("script:containsData(downloaddomain)")?.data()
?: return listOf(Video(url, "Video", url))
if (script.contains("\"second_domain_for_dl\":false")) {
return listOf(Video(url, "Video", url))
}
val domainUrl = if (script.contains("videodomain", true)) {
script
.substringAfter("\"videodomain\":\"")
.substringBefore("\"")
} else {
script
.substringAfter("\"downloaddomain\":\"")
.substringBefore("\"")
}
val videoUrl = if (domainUrl.isBlank()) {
url
} else {
domainUrl + url.toHttpUrl().encodedPath
}
return listOf(Video(videoUrl, "Video", videoUrl))
}
@Serializable
data class PostResponse(
val hide_next: Boolean,
val code: String,
)
private fun formatBytes(bytes: Long): String {
return 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 {
""
}
}
private fun parseStatus(statusString: String): Int {
return when (statusString) {
"Status: Currently Airing" -> SAnime.ONGOING
"Status: Finished Airing" -> SAnime.COMPLETED
else -> SAnime.UNKNOWN
}
}
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====="
private const val MAX_RECURSION_DEPTH = 2
private const val TRIM_EPISODE_NAME_KEY = "trim_episode"
private const val TRIM_EPISODE_NAME_DEFAULT = true
}
private val SharedPreferences.trimEpisodeName
get() = getBoolean(TRIM_EPISODE_NAME_KEY, TRIM_EPISODE_NAME_DEFAULT)
// ============================== Settings ==============================
override fun setupPreferenceScreen(screen: PreferenceScreen) {
SwitchPreferenceCompat(screen.context).apply {
key = TRIM_EPISODE_NAME_KEY
title = "Trim info from episode name"
setDefaultValue(TRIM_EPISODE_NAME_DEFAULT)
setOnPreferenceChangeListener { _, newValue ->
preferences.edit().putBoolean(key, newValue as Boolean).commit()
}
}.also(screen::addPreference)
}
}