Add extension (#1447)

This commit is contained in:
Secozzi
2023-03-28 14:47:03 +02:00
committed by GitHub
parent af1cc8a25b
commit 5961dd1b17
10 changed files with 550 additions and 0 deletions

View File

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest package="eu.kanade.tachiyomi.animeextension" />

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

View File

@ -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<FileObject>,
) {
@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,
)

View File

@ -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<Application>().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<Pair<String, String>>) : UriPartFilter(
"Select server",
domains,
)
private fun getDomains(): Array<Pair<String, String>> {
return preferences.getString("domain_list", "")!!.split(",").map {
Pair(it.substringAfter("https://"), it)
}.toTypedArray()
}
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 fetchAnimeDetails(anime: SAnime): Observable<SAnime> {
return Observable.just(anime)
}
override fun animeDetailsParse(response: Response): SAnime = throw Exception("Not used")
// ============================== Episodes ==============================
override fun fetchEpisodeList(anime: SAnime): Observable<List<SEpisode>> {
val episodeList = mutableListOf<SEpisode>()
val parsed = json.decodeFromString<LinkData>(anime.url)
var counter = 1
val newParsed = if (parsed.type != "search") {
parsed
} else {
val idParsed = json.decodeFromString<IdUrl>(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<ResponseData>(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<SEpisode> = throw Exception("Not used")
// ============================ Video Links =============================
override fun fetchVideoList(episode: SEpisode): Observable<List<Video>> {
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<ResponseData>(response.body.string().decrypt())
val animeList = mutableListOf<SAnime>()
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)
}
}