diff --git a/src/all/googledrive/AndroidManifest.xml b/src/all/googledrive/AndroidManifest.xml new file mode 100644 index 000000000..acb4de356 --- /dev/null +++ b/src/all/googledrive/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/src/all/googledrive/build.gradle b/src/all/googledrive/build.gradle new file mode 100644 index 000000000..b8fe5cc76 --- /dev/null +++ b/src/all/googledrive/build.gradle @@ -0,0 +1,13 @@ +apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' +apply plugin: 'kotlinx-serialization' + +ext { + extName = 'Google Drive' + pkgNameSuffix = 'all.googledrive' + extClass = '.GoogleDrive' + extVersionCode = 1 + libVersion = '13' +} + +apply from: "$rootDir/common.gradle" diff --git a/src/all/googledrive/res/mipmap-hdpi/ic_launcher.png b/src/all/googledrive/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..cde626421 Binary files /dev/null and b/src/all/googledrive/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/all/googledrive/res/mipmap-mdpi/ic_launcher.png b/src/all/googledrive/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..e335d7428 Binary files /dev/null and b/src/all/googledrive/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/all/googledrive/res/mipmap-xhdpi/ic_launcher.png b/src/all/googledrive/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..3780d1794 Binary files /dev/null and b/src/all/googledrive/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/all/googledrive/res/mipmap-xxhdpi/ic_launcher.png b/src/all/googledrive/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..75c56dbc2 Binary files /dev/null and b/src/all/googledrive/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/all/googledrive/res/mipmap-xxxhdpi/ic_launcher.png b/src/all/googledrive/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..a68eef1b8 Binary files /dev/null and b/src/all/googledrive/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/all/googledrive/res/web_hi_res_512.png b/src/all/googledrive/res/web_hi_res_512.png new file mode 100644 index 000000000..4ca28cb07 Binary files /dev/null and b/src/all/googledrive/res/web_hi_res_512.png differ diff --git a/src/all/googledrive/src/eu/kanade/tachiyomi/animeextension/all/googledrive/DataModel.kt b/src/all/googledrive/src/eu/kanade/tachiyomi/animeextension/all/googledrive/DataModel.kt new file mode 100644 index 000000000..9a74656ed --- /dev/null +++ b/src/all/googledrive/src/eu/kanade/tachiyomi/animeextension/all/googledrive/DataModel.kt @@ -0,0 +1,40 @@ +package eu.kanade.tachiyomi.animeextension.all.googledrive + +import kotlinx.serialization.Serializable + +@Serializable +data class PostResponse( + 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, + ) +} + +@Serializable +data class Details( + val title: String? = null, + val author: String? = null, + val artist: String? = null, + val description: String? = null, + val genre: List? = null, + val status: Int? = null, +) + +@Serializable +data class LinkData( + val url: String, + val type: String, + val info: LinkDataInfo? = null, +) + +@Serializable +data class LinkDataInfo( + val title: String, + val size: String, +) diff --git a/src/all/googledrive/src/eu/kanade/tachiyomi/animeextension/all/googledrive/GoogleDrive.kt b/src/all/googledrive/src/eu/kanade/tachiyomi/animeextension/all/googledrive/GoogleDrive.kt new file mode 100644 index 000000000..cc053f024 --- /dev/null +++ b/src/all/googledrive/src/eu/kanade/tachiyomi/animeextension/all/googledrive/GoogleDrive.kt @@ -0,0 +1,624 @@ +package eu.kanade.tachiyomi.animeextension.all.googledrive + +import android.app.Application +import android.content.SharedPreferences +import android.widget.Toast +import androidx.preference.EditTextPreference +import androidx.preference.PreferenceScreen +import androidx.preference.SwitchPreferenceCompat +import eu.kanade.tachiyomi.animeextension.all.googledrive.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.AnimeHttpSource +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.POST +import eu.kanade.tachiyomi.util.asJsoup +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import rx.Observable +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get +import uy.kohesive.injekt.injectLazy +import java.security.MessageDigest +import java.text.CharacterIterator +import java.text.StringCharacterIterator + +class GoogleDrive : ConfigurableAnimeSource, AnimeHttpSource() { + + override val name = "Google Drive (Experimental)" + + override var baseUrl = "" + + // Hack to manipulate what gets opened in webview + private val baseUrlInternal by lazy { + preferences.getString("domain_list", "")!!.split(";").first() + } + + override val lang = "all" + + private var nextPageToken: String? = "" + + override val supportsLatest = false + + private val driveFolderRegex = Regex("""(?\[[^\[\];]+\])?https?:\/\/(?:docs|drive)\.google\.com\/drive(?:\/u\/\d+)?\/folders\/(?[\w-]{28,})(?#[^;]+)?""") + private val keyRegex = """"(\w{39})"""".toRegex() + private val versionRegex = """"([^"]+web-frontend[^"]+)"""".toRegex() + private val jsonRegex = """(?:)\s*(\{(.+)\})\s*(?:)""".toRegex(RegexOption.DOT_MATCHES_ALL) + private val boundary = "=====vc17a3rwnndj=====" + + private val json: Json by injectLazy() + + private val preferences: SharedPreferences by lazy { + Injekt.get().getSharedPreferences("source_$id", 0x0000) + } + + override val client: OkHttpClient = network.cloudflareClient + + // ============================== Popular =============================== + + override fun fetchPopularAnime(page: Int): Observable { + return Observable.just(parsePage(popularAnimeRequest(page), page)) + } + + override fun popularAnimeRequest(page: Int): Request { + val match = driveFolderRegex.matchEntire(baseUrlInternal)!! + val folderId = match.groups["id"]!!.value + val recurDepth = match.groups["depth"]?.value ?: "" + baseUrl = "https://drive.google.com/drive/folders/$folderId" + val driveHeaders = headers.newBuilder() + .add("Accept", "*/*") + .add("Connection", "keep-alive") + .add("Cookie", getCookie("https://drive.google.com")) + .add("Host", "drive.google.com") + .build() + + return GET("https://drive.google.com/drive/folders/$folderId$recurDepth", headers = driveHeaders) + } + + override fun popularAnimeParse(response: Response): AnimesPage = throw Exception("Not used") + + // =============================== Latest =============================== + + override fun latestUpdatesRequest(page: Int): Request = throw Exception("Not used") + + override fun latestUpdatesParse(response: Response): AnimesPage = throw Exception("Not used") + + // =============================== Search =============================== + + override fun searchAnimeParse(response: Response): AnimesPage = throw Exception("Not used") + + override fun fetchSearchAnime( + page: Int, + query: String, + filters: AnimeFilterList, + ): Observable { + val req = searchAnimeRequest(page, query, filters) + return Observable.just(parsePage(req, page, query)) + } + + override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request { + if (baseUrlInternal.isEmpty()) { + throw Exception("Enter drive path(s) in extension settings.") + } + + val filterList = if (filters.isEmpty()) getFilterList() else filters + val serverFilter = filterList.find { it is ServerFilter } as ServerFilter + val serverUrl = serverFilter.toUriPart() + + val match = driveFolderRegex.matchEntire(serverUrl)!! + val folderId = match.groups["id"]!!.value + val recurDepth = match.groups["depth"]?.value ?: "" + baseUrl = "https://drive.google.com/drive/folders/$folderId" + val driveHeaders = headers.newBuilder() + .add("Accept", "*/*") + .add("Connection", "keep-alive") + .add("Cookie", getCookie("https://drive.google.com")) + .add("Host", "drive.google.com") + .build() + + return GET("https://drive.google.com/drive/folders/$folderId$recurDepth", headers = driveHeaders) + } + + // ============================== FILTERS =============================== + + override fun getFilterList(): AnimeFilterList = AnimeFilterList( + AnimeFilter.Header("Text search will only search inside selected server"), + ServerFilter(getDomains()), + ) + + private class ServerFilter(domains: Array>) : UriPartFilter( + "Select server", + domains, + ) + + private fun getDomains(): Array> { + return preferences.getString("domain_list", "")!!.split(";").map { + val name = driveFolderRegex.matchEntire(it)!!.groups["name"]?.let { + it.value.substringAfter("[").substringBeforeLast("]") + } + Pair(name ?: it.toHttpUrl().encodedPath, it) + }.toTypedArray() + } + + 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 { + val parsed = json.decodeFromString(anime.url) + val anime = anime + + if (parsed.type == "single") return Observable.just(anime) + + val folderId = driveFolderRegex.matchEntire(parsed.url)!!.groups["id"]!!.value + 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(parsed.url, headers = driveHeaders), + ).execute().asJsoup() + if (driveDocument.selectFirst("title:contains(Error 404 \\(Not found\\))") != null) return Observable.just(anime) + + val keyScript = driveDocument.select("script").first { script -> + keyRegex.find(script.data()) != null + }.data() + val key = keyRegex.find(keyScript)?.groupValues?.get(1) ?: "" + + val versionScript = driveDocument.select("script").first { script -> + keyRegex.find(script.data()) != null + }.data() + val driveVersion = versionRegex.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/v2beta/files?openDrive=true&reason=102&syncType=0&errorRecovery=false&q=trashed%20%3D%20false%20and%20'$folderId'%20in%20parents&fields=kind%2CnextPageToken%2Citems(kind%2CmodifiedDate%2CmodifiedByMeDate%2ClastViewedByMeDate%2CfileSize%2Cowners(kind%2CpermissionId%2Cid)%2ClastModifyingUser(kind%2CpermissionId%2Cid)%2ChasThumbnail%2CthumbnailVersion%2Ctitle%2Cid%2CresourceKey%2Cshared%2CsharedWithMeDate%2CuserPermission(role)%2CexplicitlyTrashed%2CmimeType%2CquotaBytesUsed%2Ccopyable%2CfileExtension%2CsharingUser(kind%2CpermissionId%2Cid)%2Cspaces%2Cversion%2CteamDriveId%2ChasAugmentedPermissions%2CcreatedDate%2CtrashingUser(kind%2CpermissionId%2Cid)%2CtrashedDate%2Cparents(id)%2CshortcutDetails(targetId%2CtargetMimeType%2CtargetLookupStatus)%2Ccapabilities(canCopy%2CcanDownload%2CcanEdit%2CcanAddChildren%2CcanDelete%2CcanRemoveChildren%2CcanShare%2CcanTrash%2CcanRename%2CcanReadTeamDrive%2CcanMoveTeamDriveItem)%2Clabels(starred%2Ctrashed%2Crestricted%2Cviewed))%2CincompleteSearch&appDataFilter=NO_APP_DATA&spaces=drive&pageToken=$pageToken&maxResults=50&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 = "https://clients6.google.com/batch/drive/v2beta".toHttpUrl().newBuilder() + .addQueryParameter("${'$'}ct", "multipart/mixed;boundary=\"$boundary\"") + .addQueryParameter("key", key) + .build() + .toString() + + 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 = json.decodeFromString( + jsonRegex.find(response.body.string())!!.groupValues[1], + ) + if (parsed.items == null) throw Exception("Failed to load items, please log in through webview") + parsed.items.forEach { + if (it.mimeType.startsWith("image/") && it.title.startsWith("cover.")) { + anime.thumbnail_url = "https://drive.google.com/uc?id=${it.id}" + } + } + + pageToken = parsed.nextPageToken + } + + return Observable.just(anime) + } + + override fun animeDetailsParse(response: Response): SAnime = throw Exception("Not used") + + // ============================== Episodes ============================== + + override fun fetchEpisodeList(anime: SAnime): Observable> { + val episodeList = mutableListOf() + val parsed = json.decodeFromString(anime.url) + + val maxRecursionDepth = parsed.url.toHttpUrl().encodedFragment?.toInt() ?: 2 + + fun traverseFolder(url: String, path: String, recursionDepth: Int = 0) { + if (recursionDepth == maxRecursionDepth) return + + val folderId = driveFolderRegex.matchEntire(url)!!.groups["id"]!!.value + 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 -> + keyRegex.find(script.data()) != null + }.data() + val key = keyRegex.find(keyScript)?.groupValues?.get(1) ?: "" + + val versionScript = driveDocument.select("script").first { script -> + keyRegex.find(script.data()) != null + }.data() + val driveVersion = versionRegex.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/v2beta/files?openDrive=true&reason=102&syncType=0&errorRecovery=false&q=trashed%20%3D%20false%20and%20'$folderId'%20in%20parents&fields=kind%2CnextPageToken%2Citems(kind%2CmodifiedDate%2CmodifiedByMeDate%2ClastViewedByMeDate%2CfileSize%2Cowners(kind%2CpermissionId%2Cid)%2ClastModifyingUser(kind%2CpermissionId%2Cid)%2ChasThumbnail%2CthumbnailVersion%2Ctitle%2Cid%2CresourceKey%2Cshared%2CsharedWithMeDate%2CuserPermission(role)%2CexplicitlyTrashed%2CmimeType%2CquotaBytesUsed%2Ccopyable%2CfileExtension%2CsharingUser(kind%2CpermissionId%2Cid)%2Cspaces%2Cversion%2CteamDriveId%2ChasAugmentedPermissions%2CcreatedDate%2CtrashingUser(kind%2CpermissionId%2Cid)%2CtrashedDate%2Cparents(id)%2CshortcutDetails(targetId%2CtargetMimeType%2CtargetLookupStatus)%2Ccapabilities(canCopy%2CcanDownload%2CcanEdit%2CcanAddChildren%2CcanDelete%2CcanRemoveChildren%2CcanShare%2CcanTrash%2CcanRename%2CcanReadTeamDrive%2CcanMoveTeamDriveItem)%2Clabels(starred%2Ctrashed%2Crestricted%2Cviewed))%2CincompleteSearch&appDataFilter=NO_APP_DATA&spaces=drive&pageToken=$pageToken&maxResults=50&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 = "https://clients6.google.com/batch/drive/v2beta".toHttpUrl().newBuilder() + .addQueryParameter("${'$'}ct", "multipart/mixed;boundary=\"$boundary\"") + .addQueryParameter("key", key) + .build() + .toString() + + 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 = json.decodeFromString( + jsonRegex.find(response.body.string())!!.groupValues[1], + ) + if (parsed.items == null) throw Exception("Failed to load items, please log in through webview") + parsed.items.forEachIndexed { index, it -> + if (it.mimeType.startsWith("video")) { + val episode = SEpisode.create() + val size = formatBytes(it.fileSize?.toLongOrNull()) + val pathName = if (preferences.getBoolean("trim_episode_info", false)) { + path.trimInfo() + } else { + path + } + + val itemNumberRegex = """ - (?:S\d+E)?(\d+)""".toRegex() + episode.scanlator = if (preferences.getBoolean("scanlator_order", false)) { + "/$pathName • $size" + } else { + "$size • /$pathName" + } + episode.name = if (preferences.getBoolean("trim_episode_name", false)) { + it.title.trimInfo() + } else { + it.title + } + episode.url = "https://drive.google.com/uc?id=${it.id}" + episode.episode_number = itemNumberRegex.find(it.title.trimInfo())?.groupValues?.get(1)?.toFloatOrNull() ?: index.toFloat() + episode.date_upload = -1L + episodeList.add(episode) + } + if (it.mimeType.endsWith(".folder")) { + traverseFolder( + "https://drive.google.com/drive/folders/${it.id}", + if (path.isEmpty()) it.title else "$path/${it.title}", + recursionDepth + 1, + ) + } + } + + pageToken = parsed.nextPageToken + } + } + + if (parsed.type == "single") { + val episode = SEpisode.create() + episode.name = parsed.info!!.title + episode.scanlator = parsed.info!!.size + episode.url = parsed.url + episode.episode_number = 1F + episode.date_upload = -1L + } else { + traverseFolder(parsed.url, "") + } + + return Observable.just(episodeList.reversed()) + } + + override fun episodeListParse(response: Response): List = throw Exception("Not used") + + // ============================ Video Links ============================= + + override fun fetchVideoList(episode: SEpisode): Observable> { + val videoList = GoogleDriveExtractor(client, headers).videosFromUrl(episode.url) + return Observable.just(videoList) + } + + // ============================= Utilities ============================== + + private fun parsePage(request: Request, page: Int, matches: String? = null): AnimesPage { + val animeList = mutableListOf() + + val recurDepth = request.url.encodedFragment?.let { "#$it" } ?: "" + + val folderId = driveFolderRegex.matchEntire(request.url.toString())!!.groups["id"]!!.value + val driveDocument = client.newCall(request).execute().asJsoup() + if (driveDocument.selectFirst("title:contains(Error 404 \\(Not found\\))") != null) { + return AnimesPage(emptyList(), false) + } + + val keyScript = driveDocument.select("script").first { script -> + keyRegex.find(script.data()) != null + }.data() + val key = keyRegex.find(keyScript)?.groupValues?.get(1) ?: "" + + val versionScript = driveDocument.select("script").first { script -> + keyRegex.find(script.data()) != null + }.data() + val driveVersion = versionRegex.find(versionScript)?.groupValues?.get(1) ?: "" + val sapisid = client.cookieJar.loadForRequest("https://drive.google.com".toHttpUrl()).firstOrNull { + it.name == "SAPISID" || it.name == "__Secure-3PAPISID" + }?.value ?: "" + + if (page == 1) nextPageToken = "" + val requestUrl = "/drive/v2beta/files?openDrive=true&reason=102&syncType=0&errorRecovery=false&q=trashed%20%3D%20false%20and%20'$folderId'%20in%20parents&fields=kind%2CnextPageToken%2Citems(kind%2CmodifiedDate%2CmodifiedByMeDate%2ClastViewedByMeDate%2CfileSize%2Cowners(kind%2CpermissionId%2Cid)%2ClastModifyingUser(kind%2CpermissionId%2Cid)%2ChasThumbnail%2CthumbnailVersion%2Ctitle%2Cid%2CresourceKey%2Cshared%2CsharedWithMeDate%2CuserPermission(role)%2CexplicitlyTrashed%2CmimeType%2CquotaBytesUsed%2Ccopyable%2CfileExtension%2CsharingUser(kind%2CpermissionId%2Cid)%2Cspaces%2Cversion%2CteamDriveId%2ChasAugmentedPermissions%2CcreatedDate%2CtrashingUser(kind%2CpermissionId%2Cid)%2CtrashedDate%2Cparents(id)%2CshortcutDetails(targetId%2CtargetMimeType%2CtargetLookupStatus)%2Ccapabilities(canCopy%2CcanDownload%2CcanEdit%2CcanAddChildren%2CcanDelete%2CcanRemoveChildren%2CcanShare%2CcanTrash%2CcanRename%2CcanReadTeamDrive%2CcanMoveTeamDriveItem)%2Clabels(starred%2Ctrashed%2Crestricted%2Cviewed))%2CincompleteSearch&appDataFilter=NO_APP_DATA&spaces=drive&pageToken=$nextPageToken&maxResults=50&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 = "https://clients6.google.com/batch/drive/v2beta".toHttpUrl().newBuilder() + .addQueryParameter("${'$'}ct", "multipart/mixed;boundary=\"$boundary\"") + .addQueryParameter("key", key) + .build() + .toString() + + 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 = json.decodeFromString( + jsonRegex.find(response.body.string())!!.groupValues[1], + ) + if (parsed.items == null) throw Exception("Failed to load items, please log in through webview") + parsed.items.forEachIndexed { index, it -> + if (matches != null) { + if (!it.title.contains(matches, true)) return@forEachIndexed + } + + if (it.mimeType.startsWith("video")) { + val anime = SAnime.create() + anime.title = if (preferences.getBoolean("trim_anime_info", false)) { + it.title.trimInfo() + } else { + it.title + } + anime.setUrlWithoutDomain( + LinkData( + "https://drive.google.com/drive/folders/${it.id}", + "single", + LinkDataInfo(it.title, formatBytes(it.fileSize?.toLongOrNull()) ?: ""), + ).toJsonString(), + ) + anime.thumbnail_url = "" + animeList.add(anime) + } + if (it.mimeType.endsWith(".folder")) { + val anime = SAnime.create() + anime.title = if (preferences.getBoolean("trim_anime_info", false)) { + it.title.trimInfo() + } else { + it.title + } + anime.setUrlWithoutDomain( + LinkData( + "https://drive.google.com/drive/folders/${it.id}$recurDepth", + "multi", + ).toJsonString(), + ) + anime.thumbnail_url = "" + animeList.add(anime) + } + } + + nextPageToken = parsed.nextPageToken + + return AnimesPage(animeList, nextPageToken != null) + } + + // 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" + } + + 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? { + 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 LinkData.toJsonString(): String { + return json.encodeToString(this) + } + + private fun LinkDataInfo.toJsonString(): String { + return json.encodeToString(this) + } + + override fun setupPreferenceScreen(screen: PreferenceScreen) { + val domainListPref = EditTextPreference(screen.context).apply { + key = "domain_list" + title = "Enter drive paths to be shown in extension" + summary = """Enter drive paths to be shown in extension + |Enter as a semicolon `;` separated list + """.trimMargin() + this.setDefaultValue("") + dialogTitle = "Path list" + dialogMessage = """Separate paths with a semicolon. + |- (optional) Add [] before url to customize name. For example: [drive 5]https://drive.google.com/drive/folders/whatever + |- (optional) add # to limit the depth of recursion when loading epsiodes, defaults is 2. For example: https://drive.google.com/drive/folders/whatever#5 + """.trimMargin() + + setOnPreferenceChangeListener { _, newValue -> + try { + // Validate the urls + val domain = newValue as String + val domainList = domain.split(";") + var isValid = true + var message = "" + + domainList.forEach { d -> + if (message.isNotBlank()) return@forEach + val matchResult = driveFolderRegex.matchEntire(d) + if (matchResult == null) { + message = "Invalid url for $d" + isValid = false + } else { + matchResult.groups["depth"]?.let { + if (it.value.substringAfter("#").toIntOrNull() == null) { + isValid = false + message = "Level depth must be an integer, got `${it.value.substringAfter("#")}`" + } + } + } + } + + if (isValid) { + val res = preferences.edit().putString("domain_list", newValue as String).commit() + Toast.makeText(screen.context, "Restart Aniyomi to apply changes", Toast.LENGTH_LONG).show() + res + } else { + Toast.makeText(screen.context, message, Toast.LENGTH_SHORT).show() + false + } + } catch (e: java.lang.Exception) { + e.printStackTrace() + false + } + } + } + + val trimAnimeInfo = SwitchPreferenceCompat(screen.context).apply { + key = "trim_anime_info" + title = "Trim info from anime titles" + setDefaultValue(false) + setOnPreferenceChangeListener { _, newValue -> + preferences.edit().putBoolean(key, newValue as Boolean).commit() + } + } + val trimEpisodeName = SwitchPreferenceCompat(screen.context).apply { + key = "trim_episode_name" + title = "Trim info from episode name" + setDefaultValue(true) + setOnPreferenceChangeListener { _, newValue -> + preferences.edit().putBoolean(key, newValue as Boolean).commit() + } + } + val trimEpisodeInfo = SwitchPreferenceCompat(screen.context).apply { + key = "trim_episode_info" + title = "Trim info from episode info" + setDefaultValue(false) + setOnPreferenceChangeListener { _, newValue -> + preferences.edit().putBoolean(key, newValue as Boolean).commit() + } + } + + screen.addPreference(trimAnimeInfo) + screen.addPreference(trimEpisodeName) + screen.addPreference(trimEpisodeInfo) + screen.addPreference(domainListPref) + } +} diff --git a/src/all/googledrive/src/eu/kanade/tachiyomi/animeextension/all/googledrive/extractors/GoogleDriveExtractor.kt b/src/all/googledrive/src/eu/kanade/tachiyomi/animeextension/all/googledrive/extractors/GoogleDriveExtractor.kt new file mode 100644 index 000000000..01dc3e94b --- /dev/null +++ b/src/all/googledrive/src/eu/kanade/tachiyomi/animeextension/all/googledrive/extractors/GoogleDriveExtractor.kt @@ -0,0 +1,95 @@ +package eu.kanade.tachiyomi.animeextension.all.googledrive.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