diff --git a/src/all/googledriveindex/AndroidManifest.xml b/src/all/googledriveindex/AndroidManifest.xml new file mode 100644 index 000000000..acb4de356 --- /dev/null +++ b/src/all/googledriveindex/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/src/all/googledriveindex/build.gradle b/src/all/googledriveindex/build.gradle new file mode 100644 index 000000000..0d766023b --- /dev/null +++ b/src/all/googledriveindex/build.gradle @@ -0,0 +1,13 @@ +apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' +apply plugin: 'kotlinx-serialization' + +ext { + extName = 'GoogleDriveIndex' + pkgNameSuffix = 'all.googledriveindex' + extClass = '.GoogleDriveIndex' + extVersionCode = 1 + libVersion = '13' +} + +apply from: "$rootDir/common.gradle" diff --git a/src/all/googledriveindex/res/mipmap-hdpi/ic_launcher.png b/src/all/googledriveindex/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..ef9be5d6d Binary files /dev/null and b/src/all/googledriveindex/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/all/googledriveindex/res/mipmap-mdpi/ic_launcher.png b/src/all/googledriveindex/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..85f1a3fd0 Binary files /dev/null and b/src/all/googledriveindex/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/all/googledriveindex/res/mipmap-xhdpi/ic_launcher.png b/src/all/googledriveindex/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..109d821a7 Binary files /dev/null and b/src/all/googledriveindex/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/all/googledriveindex/res/mipmap-xxhdpi/ic_launcher.png b/src/all/googledriveindex/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..e92f661f0 Binary files /dev/null and b/src/all/googledriveindex/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/all/googledriveindex/res/mipmap-xxxhdpi/ic_launcher.png b/src/all/googledriveindex/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..419343c22 Binary files /dev/null and b/src/all/googledriveindex/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/all/googledriveindex/res/web_hi_res_512.png b/src/all/googledriveindex/res/web_hi_res_512.png new file mode 100644 index 000000000..8190c6fc2 Binary files /dev/null and b/src/all/googledriveindex/res/web_hi_res_512.png differ diff --git a/src/all/googledriveindex/src/eu/kanade/tachiyomi/animeextension/all/googledriveindex/DataModel.kt b/src/all/googledriveindex/src/eu/kanade/tachiyomi/animeextension/all/googledriveindex/DataModel.kt new file mode 100644 index 000000000..50bf5a804 --- /dev/null +++ b/src/all/googledriveindex/src/eu/kanade/tachiyomi/animeextension/all/googledriveindex/DataModel.kt @@ -0,0 +1,38 @@ +package eu.kanade.tachiyomi.animeextension.all.googledriveindex + +import kotlinx.serialization.Serializable + +@Serializable +data class ResponseData( + val nextPageToken: String? = null, + val data: DataObject, +) { + @Serializable + data class DataObject( + val files: List, + ) { + @Serializable + data class FileObject( + val mimeType: String, + val id: String, + val name: String, + val modifiedTime: String? = null, + val size: String? = null, + ) + } +} + +@Serializable +data class LinkData( + val type: String, + val url: String, + val info: String? = null, +) + +@Serializable +data class IdUrl( + val id: String, + val url: String, + val referer: String, + val type: String, +) diff --git a/src/all/googledriveindex/src/eu/kanade/tachiyomi/animeextension/all/googledriveindex/GoogleDriveIndex.kt b/src/all/googledriveindex/src/eu/kanade/tachiyomi/animeextension/all/googledriveindex/GoogleDriveIndex.kt new file mode 100644 index 000000000..61a7a9ce2 --- /dev/null +++ b/src/all/googledriveindex/src/eu/kanade/tachiyomi/animeextension/all/googledriveindex/GoogleDriveIndex.kt @@ -0,0 +1,497 @@ +package eu.kanade.tachiyomi.animeextension.all.googledriveindex + +import android.app.Application +import android.content.SharedPreferences +import android.util.Base64 +import android.widget.Toast +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.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 + +class GoogleDriveIndex : ConfigurableAnimeSource, AnimeHttpSource() { + + override val name = "GoogleDriveIndex" + + override val baseUrl by lazy { + preferences.getString("domain_list", "")!!.split(",").first() + } + + override val lang = "all" + + private var pageToken: String? = "" + + override val supportsLatest = false + + 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 popularAnimeRequest(page: Int): Request { + if (baseUrl.isEmpty()) { + throw Exception("Enter drive path(s) in extension settings.") + } + + if (page == 1) pageToken = "" + val popHeaders = headers.newBuilder() + .add("Accept", "*/*") + .add("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8") + .add("Host", baseUrl.toHttpUrl().host) + .add("Origin", "https://${baseUrl.toHttpUrl().host}") + .add("Referer", baseUrl) + .add("X-Requested-With", "XMLHttpRequest") + .build() + + val popBody = "password=&page_token=$pageToken&page_index=${page - 1}".toRequestBody("application/x-www-form-urlencoded".toMediaType()) + + return POST(baseUrl, body = popBody, headers = popHeaders) + } + + override fun popularAnimeParse(response: Response): AnimesPage { + return parsePage(response, baseUrl) + } + + // =============================== Latest =============================== + + override fun latestUpdatesRequest(page: Int): Request = throw Exception("Not used") + + override fun latestUpdatesParse(response: Response): AnimesPage = throw Exception("Not used") + + // =============================== Search =============================== + + override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request { + if (baseUrl.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() + + if (page == 1) pageToken = "" + val searchHeaders = headers.newBuilder() + .add("Accept", "*/*") + .add("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8") + .add("Host", serverUrl.toHttpUrl().host) + .add("Origin", "https://${serverUrl.toHttpUrl().host}") + .add("X-Requested-With", "XMLHttpRequest") + + return if (query.isBlank()) { + val popBody = "password=&page_token=$pageToken&page_index=${page - 1}".toRequestBody("application/x-www-form-urlencoded".toMediaType()) + POST( + serverUrl, + body = popBody, + headers = searchHeaders.add("Referer", serverUrl).build(), + ) + } else { + val cleanQuery = query.replace(" ", "+") + val searchUrl = "https://${serverUrl.toHttpUrl().host}/${serverUrl.toHttpUrl().pathSegments[0]}search" + + val popBody = "q=$cleanQuery&page_token=$pageToken&page_index=${page - 1}".toRequestBody("application/x-www-form-urlencoded".toMediaType()) + + POST( + searchUrl, + body = popBody, + headers = searchHeaders.add("Referer", "$searchUrl?q=$cleanQuery").build(), + ) + } + } + + override fun searchAnimeParse(response: Response): AnimesPage { + return parsePage(response, response.request.url.toString()) + } + + // ============================== 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 { + Pair(it.substringAfter("https://"), 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 { + 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) + var counter = 1 + + val newParsed = if (parsed.type != "search") { + parsed + } else { + val idParsed = json.decodeFromString(parsed.url) + val id2pathHeaders = headers.newBuilder() + .add("Accept", "*/*") + .add("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8") + .add("Host", idParsed.url.toHttpUrl().host) + .add("Origin", "https://${idParsed.url.toHttpUrl().host}") + .add("Referer", idParsed.referer) + .add("X-Requested-With", "XMLHttpRequest") + .build() + + val postBody = "id=${idParsed.id}".toRequestBody("application/x-www-form-urlencoded".toMediaType()) + val slug = client.newCall( + POST(idParsed.url + "id2path", body = postBody, headers = id2pathHeaders), + ).execute().body.string() + + LinkData( + idParsed.type, + idParsed.url + slug, + parsed.info, + ) + } + + if (newParsed.type == "single") { + val episode = SEpisode.create() + val size = if (newParsed.info == null) { + "" + } else { + " - ${newParsed.info}" + } + episode.name = "${newParsed.url.toHttpUrl().pathSegments.last()}$size" + episode.url = newParsed.url + episode.episode_number = 1F + episodeList.add(episode) + } + + if (newParsed.type == "multi") { + val basePathCounter = newParsed.url.toHttpUrl().pathSize + + 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", url) + .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(parsedBody) + + parsed.data.files.forEach { item -> + if (item.mimeType.endsWith("folder")) { + if ( + preferences.getString("blacklist_folders", "")!!.split("/") + .any { it.equals(item.name, ignoreCase = true) } + ) { + return@forEach + } + + val newUrl = joinUrl(url, item.name).addSuffix("/") + traverseDirectory(newUrl) + } + if (item.mimeType.startsWith("video/")) { + val episode = SEpisode.create() + val epUrl = joinUrl(url, item.name) + val paths = epUrl.toHttpUrl().pathSegments + + // Get season stuff + 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 seasonText = if (season.isBlank()) { + "" + } else { + "[${season.trimInfo()}] " + } + + // Get other info + val extraInfo = if (paths.size > basePathCounter) { + "/" + paths.subList(basePathCounter - 1, paths.size - 1).joinToString("/") { it.trimInfo() } + } else { + "" + } + val size = item.size?.toLongOrNull()?.let { formatFileSize(it) } + + episode.name = "$seasonText${item.name.trimInfo()}${if (size == null) "" else " - $size"}" + episode.url = epUrl + episode.scanlator = seasonInfo + extraInfo + episode.episode_number = counter.toFloat() + counter++ + + episodeList.add(episode) + } + } + + newToken = parsed.nextPageToken + newPageIndex += 1 + } + } + + traverseDirectory(newParsed.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 url = episode.url + + val doc = client.newCall( + GET("$url?a=view"), + ).execute().asJsoup() + + val script = doc.selectFirst("script:containsData(videodomain)")?.data() + ?: return Observable.just(listOf(Video(url, "Video", url))) + val domainUrl = script.substringAfter("\"videodomain\":\"").substringBefore("\"") + val videoUrl = if (domainUrl.isBlank()) { + url + } else { + domainUrl + url.toHttpUrl().encodedPath + } + + return Observable.just(listOf(Video(videoUrl, "Video", videoUrl))) + } + + // ============================= Utilities ============================== + + private fun joinUrl(path1: String, path2: String): String { + return path1.removeSuffix("/") + "/" + path2.removePrefix("/") + } + + private fun String.decrypt(): String { + return Base64.decode(this.reversed().substring(24, this.length - 20), Base64.DEFAULT).toString(Charsets.UTF_8) + } + + private fun String.addSuffix(suffix: String): String { + return if (this.endsWith(suffix)) { + this + } else { + this.plus(suffix) + } + } + + 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 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 -> "" + } + } + + private fun LinkData.toJsonString(): String { + return json.encodeToString(this) + } + + private fun IdUrl.toJsonString(): String { + return json.encodeToString(this) + } + + private fun parsePage(response: Response, url: String): AnimesPage { + val parsed = json.decodeFromString(response.body.string().decrypt()) + val animeList = mutableListOf() + val isSearch = url.endsWith(":search") + + parsed.data.files.forEach { item -> + if (item.mimeType.endsWith("folder")) { + val anime = SAnime.create() + anime.title = item.name.trimInfo() + + if (isSearch) { + anime.setUrlWithoutDomain( + LinkData( + "search", + IdUrl( + item.id, + url.substringBeforeLast("search"), + response.request.header("Referer")!!, + "multi", + ).toJsonString(), + ).toJsonString(), + ) + } else { + anime.setUrlWithoutDomain( + LinkData( + "multi", + joinUrl(url, item.name).addSuffix("/"), + ).toJsonString(), + ) + } + animeList.add(anime) + } + if ( + item.mimeType.startsWith("video/") && + !(preferences.getBoolean("ignore_non_folder", true) && isSearch) + ) { + val anime = SAnime.create() + anime.title = item.name.trimInfo() + + if (isSearch) { + anime.setUrlWithoutDomain( + LinkData( + "search", + IdUrl( + item.id, + url.substringBeforeLast("search"), + response.request.header("Referer")!!, + "single", + ).toJsonString(), + item.size?.toLongOrNull()?.let { formatFileSize(it) }, + ).toJsonString(), + ) + } else { + anime.setUrlWithoutDomain( + LinkData( + "single", + joinUrl(url, item.name), + item.size?.toLongOrNull()?.let { formatFileSize(it) }, + ).toJsonString(), + ) + } + animeList.add(anime) + } + } + + pageToken = parsed.nextPageToken + + return AnimesPage(animeList, parsed.nextPageToken != null) + } + + 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 comma separated list + """.trimMargin() + this.setDefaultValue("") + dialogTitle = "Path list" + dialogMessage = "Separate paths with a comma" + + setOnPreferenceChangeListener { _, newValue -> + try { + 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 + } catch (e: java.lang.Exception) { + e.printStackTrace() + false + } + } + } + val blacklistFolders = EditTextPreference(screen.context).apply { + key = "blacklist_folders" + title = "Blacklist folder names" + summary = """Enter names of folders to skip over + |Enter as slash / separated list + """.trimMargin() + this.setDefaultValue("NC/Extras") + dialogTitle = "Blacklisted folders" + dialogMessage = "Separate folders with a slash (case insensitive)" + + setOnPreferenceChangeListener { _, newValue -> + try { + val res = preferences.edit().putString("blacklist_folders", newValue as String).commit() + Toast.makeText(screen.context, "Restart Aniyomi to apply changes", Toast.LENGTH_LONG).show() + res + } catch (e: java.lang.Exception) { + e.printStackTrace() + false + } + } + } + + val ignoreNonFolder = SwitchPreferenceCompat(screen.context).apply { + key = "ignore_non_folder" + title = "Only include folders on search" + setDefaultValue(true) + setOnPreferenceChangeListener { _, newValue -> + preferences.edit().putBoolean(key, newValue as Boolean).commit() + } + } + + screen.addPreference(domainListPref) + screen.addPreference(blacklistFolders) + screen.addPreference(ignoreNonFolder) + } +}