diff --git a/src/all/googledriveindex/build.gradle b/src/all/googledriveindex/build.gradle index 85692ad03..5e4fffa4e 100644 --- a/src/all/googledriveindex/build.gradle +++ b/src/all/googledriveindex/build.gradle @@ -6,7 +6,7 @@ ext { extName = 'GoogleDriveIndex' pkgNameSuffix = 'all.googledriveindex' extClass = '.GoogleDriveIndex' - extVersionCode = 5 + extVersionCode = 6 libVersion = '13' } 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 index 1e16068a3..86e238ce4 100644 --- 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 @@ -2,7 +2,11 @@ package eu.kanade.tachiyomi.animeextension.all.googledriveindex import android.app.Application import android.content.SharedPreferences +import android.text.Editable +import android.text.TextWatcher import android.util.Base64 +import android.widget.Button +import android.widget.EditText import android.widget.Toast import androidx.preference.EditTextPreference import androidx.preference.PreferenceScreen @@ -40,7 +44,7 @@ class GoogleDriveIndex : ConfigurableAnimeSource, AnimeHttpSource() { override val name = "GoogleDriveIndex" override val baseUrl by lazy { - preferences.getString("domain_list", "")!!.split(",").first() + preferences.domainList.split(",").first().removeName() } override val lang = "all" @@ -83,12 +87,9 @@ class GoogleDriveIndex : ConfigurableAnimeSource, AnimeHttpSource() { // ============================== Popular =============================== override fun popularAnimeRequest(page: Int): Request { - if (baseUrl.isEmpty()) { - throw Exception("Enter drive path(s) in extension settings.") - } - - if (baseUrl.toHttpUrl().host == "drive.google.com") { - throw Exception("This extension is only for Google Drive Index sites, not drive.google.com folders.") + require(baseUrl.isNotEmpty()) { "Enter drive path(s) in extension settings." } + require(baseUrl.toHttpUrl().host != "drive.google.com") { + "This extension is only for Google Drive Index sites, not drive.google.com folders." } if (page == 1) pageToken = "" @@ -97,7 +98,7 @@ class GoogleDriveIndex : ConfigurableAnimeSource, AnimeHttpSource() { .add("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8") .add("Host", baseUrl.toHttpUrl().host) .add("Origin", "https://${baseUrl.toHttpUrl().host}") - .add("Referer", URLEncoder.encode(baseUrl, "UTF-8")) + .add("Referer", baseUrl.asReferer()) .add("X-Requested-With", "XMLHttpRequest") .build() @@ -106,9 +107,7 @@ class GoogleDriveIndex : ConfigurableAnimeSource, AnimeHttpSource() { return POST(baseUrl, body = popBody, headers = popHeaders) } - override fun popularAnimeParse(response: Response): AnimesPage { - return parsePage(response, baseUrl) - } + override fun popularAnimeParse(response: Response): AnimesPage = parsePage(response, baseUrl) // =============================== Latest =============================== @@ -125,34 +124,51 @@ class GoogleDriveIndex : ConfigurableAnimeSource, AnimeHttpSource() { query: String, filters: AnimeFilterList, ): Observable { - val req = searchAnimeRequest(page, query, filters) - return Observable.defer { - try { - client.newCall(req).asObservableSuccess() - } catch (e: NoClassDefFoundError) { - // RxJava doesn't handle Errors, which tends to happen during global searches - // if an old extension using non-existent classes is still around - throw RuntimeException(e) - } - } - .map { response -> + val filterList = if (filters.isEmpty()) getFilterList() else filters + val urlFilter = filterList.find { it is URLFilter } as URLFilter + + return if (urlFilter.state.isEmpty()) { + val req = searchAnimeRequest(page, query, filters) + Observable.defer { + try { + client.newCall(req).asObservableSuccess() + } catch (e: NoClassDefFoundError) { + // RxJava doesn't handle Errors, which tends to happen during global searches + // if an old extension using non-existent classes is still around + throw RuntimeException(e) + } + }.map { response -> searchAnimeParse(response, req.url.toString()) } + } else { + Observable.just(addSinglePage(urlFilter.state)) + } + } + + private fun addSinglePage(inputUrl: String): AnimesPage { + val match = URL_REGEX.matchEntire(inputUrl) ?: throw Exception("Invalid url") + val anime = SAnime.create().apply { + title = match.groups["name"]?.value?.substringAfter("[")?.substringBeforeLast("]") ?: "Folder" + url = LinkData( + type = "multi", + url = match.groups["url"]!!.value, + fragment = inputUrl.removeName().toHttpUrl().encodedFragment, + ).toJsonString() + thumbnail_url = "" + } + return AnimesPage(listOf(anime), false) } override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request { - if (baseUrl.isEmpty()) { - throw Exception("Enter drive path(s) in extension settings.") + require(baseUrl.isNotEmpty()) { "Enter drive path(s) in extension settings." } + require(baseUrl.toHttpUrl().host != "drive.google.com") { + "This extension is only for Google Drive Index sites, not drive.google.com folders." } val filterList = if (filters.isEmpty()) getFilterList() else filters val serverFilter = filterList.find { it is ServerFilter } as ServerFilter val serverUrl = serverFilter.toUriPart() - if (serverUrl.toHttpUrl().host == "drive.google.com") { - throw Exception("This extension is only for Google Drive Index sites, not drive.google.com folders.") - } - if (page == 1) pageToken = "" val searchHeaders = headers.newBuilder() .add("Accept", "*/*") @@ -161,37 +177,39 @@ class GoogleDriveIndex : ConfigurableAnimeSource, AnimeHttpSource() { .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", URLEncoder.encode(serverUrl, "UTF-8")).build(), - ) - } else { - val cleanQuery = query.replace(" ", "+") + return when { + query.isBlank() -> { + val popBody = "password=&page_token=$pageToken&page_index=${page - 1}".toRequestBody("application/x-www-form-urlencoded".toMediaType()) - val searchUrl = "https://${serverUrl.toHttpUrl().hostAndCred()}/${serverUrl.toHttpUrl().pathSegments[0]}search" + POST( + serverUrl, + body = popBody, + headers = searchHeaders.add("Referer", serverUrl.asReferer()).build(), + ) + } + else -> { + val cleanQuery = query.replace(" ", "+") + val searchUrl = "https://${serverUrl.toHttpUrl().hostAndCred()}/${serverUrl.toHttpUrl().pathSegments[0]}search" + val popBody = "q=$cleanQuery&page_token=$pageToken&page_index=${page - 1}".toRequestBody("application/x-www-form-urlencoded".toMediaType()) - 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(), - ) + POST( + searchUrl, + body = popBody, + headers = searchHeaders.add("Referer", "$searchUrl?q=$cleanQuery").build(), + ) + } } } - private fun searchAnimeParse(response: Response, url: String): AnimesPage { - return parsePage(response, url) - } + private fun searchAnimeParse(response: Response, url: String): AnimesPage = parsePage(response, url) // ============================== FILTERS =============================== override fun getFilterList(): AnimeFilterList = AnimeFilterList( AnimeFilter.Header("Text search will only search inside selected server"), ServerFilter(getDomains()), + AnimeFilter.Header("Add single folder"), + URLFilter(), ) private class ServerFilter(domains: Array>) : UriPartFilter( @@ -200,8 +218,13 @@ class GoogleDriveIndex : ConfigurableAnimeSource, AnimeHttpSource() { ) private fun getDomains(): Array> { - return preferences.getString("domain_list", "")!!.split(",").map { - Pair(it.substringAfter("https://"), it) + if (preferences.domainList.isBlank()) return emptyArray() + return preferences.domainList.split(",").map { + val match = URL_REGEX.matchEntire(it)!! + val name = match.groups["name"]?.let { + it.value.substringAfter("[").substringBeforeLast("]") + } + Pair(name ?: it.toHttpUrl().encodedPath, it.removeName()) }.toTypedArray() } @@ -210,6 +233,8 @@ class GoogleDriveIndex : ConfigurableAnimeSource, AnimeHttpSource() { fun toUriPart() = vals[state].second } + private class URLFilter : AnimeFilter.Text("Url") + // =========================== Anime Details ============================ override fun fetchAnimeDetails(anime: SAnime): Observable { @@ -296,6 +321,12 @@ class GoogleDriveIndex : ConfigurableAnimeSource, AnimeHttpSource() { val episodeList = mutableListOf() val parsed = json.decodeFromString(anime.url) var counter = 1 + val maxRecursionDepth = parsed.fragment?.substringBefore(",")?.toInt() ?: 2 + val (start, stop) = if (parsed.fragment?.contains(",") == true) { + parsed.fragment.substringAfter(",").split(",").map { it.toInt() } + } else { + listOf(null, null) + } val newParsed = if (parsed.type != "search") { parsed @@ -323,22 +354,23 @@ class GoogleDriveIndex : ConfigurableAnimeSource, AnimeHttpSource() { } 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) + val titleName = newParsed.url.toHttpUrl().pathSegments.last() + episodeList.add( + SEpisode.create().apply { + name = if (preferences.trimEpisodeName) titleName.trimInfo() else titleName + url = newParsed.url + episode_number = 1F + date_upload = -1L + scanlator = newParsed.info + }, + ) } if (newParsed.type == "multi") { val basePathCounter = newParsed.url.toHttpUrl().pathSize - fun traverseDirectory(url: String) { + fun traverseDirectory(url: String, recursionDepth: Int = 0) { + if (recursionDepth == maxRecursionDepth) return var newToken: String? = "" var newPageIndex = 0 @@ -361,18 +393,16 @@ class GoogleDriveIndex : ConfigurableAnimeSource, AnimeHttpSource() { 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) + traverseDirectory(newUrl, recursionDepth + 1) } if (item.mimeType.startsWith("video/")) { - val episode = SEpisode.create() + if (start != null && maxRecursionDepth == 1 && counter < start) { + counter++ + return@forEach + } + if (stop != null && maxRecursionDepth == 1 && counter > stop) return + val epUrl = joinUrl(url, item.name) val paths = epUrl.toHttpUrl().pathSegments @@ -393,17 +423,20 @@ class GoogleDriveIndex : ConfigurableAnimeSource, AnimeHttpSource() { 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 = "${item.name.trimInfo()}${if (size == null) "" else " - $size"}" - episode.url = epUrl - episode.scanlator = seasonInfo + extraInfo - episode.episode_number = counter.toFloat() + episodeList.add( + SEpisode.create().apply { + name = if (preferences.trimEpisodeName) 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++ - - episodeList.add(episode) } } @@ -485,7 +518,7 @@ class GoogleDriveIndex : ConfigurableAnimeSource, AnimeHttpSource() { } private fun String.trimInfo(): String { - var newString = this.replaceFirst("""^\[\w+\] ?""".toRegex(), "") + var newString = this.replaceFirst("""^\[[\w-]+\] ?""".toRegex(), "") val regex = """( ?\[[\s\w-]+\]| ?\([\s\w-]+\))(\.mkv|\.mp4|\.avi)?${'$'}""".toRegex() while (regex.containsMatchIn(newString)) { @@ -508,6 +541,17 @@ class GoogleDriveIndex : ConfigurableAnimeSource, AnimeHttpSource() { } } + private fun String.asReferer(): String { + return URLEncoder.encode( + this.toHttpUrl().let { + "https://${it.host}${it.encodedPath}" + }, + "UTF-8", + ) + } + + private fun String.removeName(): String = Regex("""^(\[[^\[\];]+\])""").replace(this, "") + private fun LinkData.toJsonString(): String { return json.encodeToString(this) } @@ -523,63 +567,59 @@ class GoogleDriveIndex : ConfigurableAnimeSource, AnimeHttpSource() { parsed.data.files.forEach { item -> if (item.mimeType.endsWith("folder")) { - val anime = SAnime.create() - anime.title = item.name.trimInfo() - anime.thumbnail_url = "" - - if (isSearch) { - anime.setUrlWithoutDomain( - LinkData( - "search", - IdUrl( - item.id, - url.substringBeforeLast("search"), - response.request.header("Referer")!!, + animeList.add( + SAnime.create().apply { + title = if (preferences.trimAnimeName) item.name.trimInfo() else item.name + thumbnail_url = "" + this.url = if (isSearch) { + LinkData( + "search", + IdUrl( + item.id, + url.substringBeforeLast("search"), + response.request.header("Referer")!!, + "multi", + ).toJsonString(), + ).toJsonString() + } else { + LinkData( "multi", - ).toJsonString(), - ).toJsonString(), - ) - } else { - anime.setUrlWithoutDomain( - LinkData( - "multi", - joinUrl(url, item.name).addSuffix("/"), - ).toJsonString(), - ) - } - animeList.add(anime) + joinUrl(URL_REGEX.matchEntire(url)!!.groups["url"]!!.value, item.name).addSuffix("/"), + fragment = url.toHttpUrl().encodedFragment, + ).toJsonString() + } + }, + ) } if ( item.mimeType.startsWith("video/") && - !(preferences.getBoolean("ignore_non_folder", true) && isSearch) + !(preferences.ignoreFolder && isSearch) ) { - val anime = SAnime.create() - anime.title = item.name.trimInfo() - anime.thumbnail_url = "" - - if (isSearch) { - anime.setUrlWithoutDomain( - LinkData( - "search", - IdUrl( - item.id, - url.substringBeforeLast("search"), - response.request.header("Referer")!!, + animeList.add( + SAnime.create().apply { + title = if (preferences.trimAnimeName) item.name.trimInfo() else item.name + thumbnail_url = "" + this.url = if (isSearch) { + LinkData( + "search", + IdUrl( + item.id, + url.substringBeforeLast("search"), + response.request.header("Referer")!!, + "single", + ).toJsonString(), + item.size?.toLongOrNull()?.let { formatFileSize(it) }, + ).toJsonString() + } else { + LinkData( "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) + joinUrl(URL_REGEX.matchEntire(url)!!.groups["url"]!!.value, item.name), + item.size?.toLongOrNull()?.let { formatFileSize(it) }, + fragment = url.toHttpUrl().encodedFragment, + ).toJsonString() + } + }, + ) } } @@ -588,22 +628,95 @@ class GoogleDriveIndex : ConfigurableAnimeSource, AnimeHttpSource() { return AnimesPage(animeList, parsed.nextPageToken != null) } + private fun isUrl(text: String) = URL_REGEX matches text + + /* + * Stolen from the MangaDex manga extension + * + * This will likely need to be removed or revisited when the app migrates the + * extension preferences screen to Compose. + */ + private fun setupEditTextUrlValidator(editText: EditText) { + editText.addTextChangedListener( + object : TextWatcher { + + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) { + // Do nothing. + } + + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { + // Do nothing. + } + + override fun afterTextChanged(editable: Editable?) { + requireNotNull(editable) + + val text = editable.toString() + + val isValid = text.isBlank() || text + .split(",") + .map(String::trim) + .all(::isUrl) + + editText.error = if (!isValid) "${text.split(",").first { !isUrl(it) }} is not a valid url" else null + editText.rootView.findViewById