Merge branch 'almightyhak:main' into main

This commit is contained in:
Dark25 2024-08-04 19:51:05 +01:00 committed by GitHub
commit b0cacb5533
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
80 changed files with 621 additions and 4441 deletions

View file

@ -44,7 +44,7 @@ jobs:
- name: Bump extensions that uses a modified lib
if: steps.modified-libs.outputs.any_changed == 'true'
run: |
./.github/scripts/bump-versions.py ${{ steps.modified-libs.outputs.all_changed_files }}
chmod +x ./.github/scripts/bump-versions.py ${{ steps.modified-libs.outputs.all_changed_files }}
- name: Validate Gradle Wrapper
uses: gradle/wrapper-validation-action@a494d935f4b56874c4a5a87d19af7afcf3a163d0 # v2

View file

@ -1,19 +1,20 @@
# Aniyomi-extensions
## Guide
The source code for the extensions
just paste this into your anime repo
```
https://raw.githubusercontent.com/almightyhak/aniyomi-anime-repo/main/index.min.json
```
If your interested in installing just the apks they can be found [Here](https://github.com/almightyhak/aniyomi-anime-repo)
## Support Server
[Discord](https://discord.gg/vut4mmXQzU)
Join the [Discord](https://discord.gg/vut4mmXQzU) for updates and announcements
and please check the discord BEFORE making an issue
## Guide
## Contributing
just paste this into your anime repo https://raw.githubusercontent.com/almightyhak/aniyomi-anime-repo/main/index.min.json
i am maintaining this but keep in mind that i'm NOT a developer so expect issues to take a while to fix
If your interested in installing just the apks they can be found [Here](https://github.com/almightyhak/aniyomi-anime-repo)
[Template](https://github.com/aniyomiorg/aniyomi-extensions/blob/master/CONTRIBUTING.md)
## Contact

View file

@ -0,0 +1,73 @@
package eu.kanade.tachiyomi.lib.voeextractor
import android.webkit.CookieManager
import eu.kanade.tachiyomi.network.GET
import okhttp3.Cookie
import okhttp3.HttpUrl
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.Response
class DdosGuardInterceptor(private val client: OkHttpClient) : Interceptor {
private val cookieManager by lazy { CookieManager.getInstance() }
override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request()
val response = chain.proceed(originalRequest)
// Check if DDos-GUARD is on
if (response.code !in ERROR_CODES || response.header("Server") !in SERVER_CHECK) {
return response
}
response.close()
val cookies = cookieManager.getCookie(originalRequest.url.toString())
val oldCookie = if (cookies != null && cookies.isNotEmpty()) {
cookies.split(";").mapNotNull { Cookie.parse(originalRequest.url, it) }
} else {
emptyList()
}
val ddg2Cookie = oldCookie.firstOrNull { it.name == "__ddg2_" }
if (!ddg2Cookie?.value.isNullOrEmpty()) {
return chain.proceed(originalRequest)
}
val newCookie = getNewCookie(originalRequest.url) ?: return chain.proceed(originalRequest)
val newCookieHeader = buildString {
(oldCookie + newCookie).forEachIndexed { index, cookie ->
if (index > 0) append("; ")
append(cookie.name).append('=').append(cookie.value)
}
}
return chain.proceed(originalRequest.newBuilder().addHeader("cookie", newCookieHeader).build())
}
fun getNewCookie(url: HttpUrl): Cookie? {
val cookies = cookieManager.getCookie(url.toString())
val oldCookie = if (cookies != null && cookies.isNotEmpty()) {
cookies.split(";").mapNotNull { Cookie.parse(url, it) }
} else {
emptyList()
}
val ddg2Cookie = oldCookie.firstOrNull { it.name == "__ddg2_" }
if (!ddg2Cookie?.value.isNullOrEmpty()) {
return ddg2Cookie
}
val wellKnown = client.newCall(GET("https://check.ddos-guard.net/check.js"))
.execute().body.string()
.substringAfter("'", "")
.substringBefore("'", "")
val checkUrl = "${url.scheme}://${url.host + wellKnown}"
return client.newCall(GET(checkUrl)).execute().header("set-cookie")?.let {
Cookie.parse(url, it)
}
}
companion object {
private val ERROR_CODES = listOf(403)
private val SERVER_CHECK = listOf("ddos-guard")
}
}

View file

@ -14,7 +14,9 @@ class VoeExtractor(private val client: OkHttpClient) {
private val json: Json by injectLazy()
private val playlistUtils by lazy { PlaylistUtils(client) }
private val clientDdos by lazy { client.newBuilder().addInterceptor(DdosGuardInterceptor(client)).build() }
private val playlistUtils by lazy { PlaylistUtils(clientDdos) }
private val linkRegex = "(http|https)://([\\w_-]+(?:\\.[\\w_-]+)+)([\\w.,@?^=%&:/~+#-]*[\\w@?^=%&/~+#-])".toRegex()
@ -24,7 +26,16 @@ class VoeExtractor(private val client: OkHttpClient) {
data class VideoLinkDTO(val file: String)
fun videosFromUrl(url: String, prefix: String = ""): List<Video> {
val document = client.newCall(GET(url)).execute().asJsoup()
var document = clientDdos.newCall(GET(url)).execute().asJsoup()
if (document.selectFirst("script")?.data()?.contains("if (typeof localStorage !== 'undefined')") == true) {
val originalUrl = document.selectFirst("script")?.data()
?.substringAfter("window.location.href = '")
?.substringBefore("';") ?: return emptyList()
document = clientDdos.newCall(GET(originalUrl)).execute().asJsoup()
}
val script = document.selectFirst("script:containsData(const sources), script:containsData(var sources), script:containsData(wc0)")
?.data()
?: return emptyList()

View file

@ -1,62 +0,0 @@
# 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.
# Google Drive
Table of Content
- [FAQ](#FAQ)
- [How do i add entries?](#how-do-i-add-entries)
- [What are all these options for drive paths?](#what-are-all-these-options-for-drive-paths)
- [I added the drive paths but it still get "Enter drive path(s) in extension settings."](#i-added-the-drive-paths-but-it-still-get-enter-drive-paths-in-extension-settings)
- [I cannot log in through webview](#i-cannot-log-in-through-webview)
## FAQ
### How do I customize info?
The Google Drive Extension allow for editing the same way as [local anime](https://aniyomi.org/docs/guides/local-anime-source/advanced) .
### How do I add entries?
The Google Drive Extension *only* supports google drive folders, so no shared drives (but folders inside shared drives works fine!). If you have a folder, which contains sub-folders of an anime, such as:
```
https://drive.google.com/drive/folders/some-long-id
├── anime1/
│ ├── episode 1.mkv
│ ├── episode 2.mkv
│ └── ...
└── anime2/
├── episode 1.mkv
├── episode 2.mkv
└── ...
```
Then it you should go to extension settings, and add the url there. You can add multiple drive paths by separating them with a semicolon `;`. To select between the paths, open up the extension and click the filter, from there you can select a specific drive.
If you instead have a folder that contains the episodes directly, such as:
```
https://drive.google.com/drive/folders/some-long-id
├── episode 1.mkv
├── episode 2.mkv
└── ...
```
Then you should open the extension, click filters, then paste the folder link in the `Add single folder` filter.
### What are all these options for drive paths?
The extension allows for some options when adding the drive path:
1. You can customize the name of a drive path by prepending the url with [<insert name>]. This will change the display name when selecting different drive paths in filters. Example: `[Weekly episodes]https://drive.google.com/drive/folders/some-long-id`
2. You can limit the recursion depth by adding a `#` to the end of the url together with a number. If you set it to `1`, the extension will not go into any sub-folders when loading episodes. If you set it to `2`, the extension will traverse into any sub-folders, but not sub-folders of sub-folders, and so on and so forth. It's useful if one folder has a separate folder for each seasons that you want to traverse through, but if another folder has separate folder for openings/endings that you *don't* want to traverse through. Example: `https://drive.google.com/drive/folders/some-long-id#3`
3. It is also possible to specify a range of episodes to load. It needs to be added together with the recursion depth as seen in step 2. Note: it only works if the recursion depth is set to `1`. The range is inclusive, so doing #1,2,7 will load the 2nd up to, and including, the 7th item. Example: `https://drive.google.com/drive/folders/some-long-id#1,2,7`
It is possible to mix these options, and they work for both ways to add folders.
### I added the drive paths but it still get "Enter drive path(s) in extension settings."
This can be caused by the caching that Aniyomi does. Reinstalling the extension will fix this issue (reinstalling an extension does not remove any extension settings)
### I cannot log in through webview
Google can sometimes think that webview isn't a secure browser, and will thus refuse to let you log in. There are a few things you can try to mitigate this:
1. In the top right, click the three dots then click `Clear cookies`
2. In the top right, click the three dots then click `Refresh`
3. Click the `Try again` button after the website doesn't let you log in
4. Make sure that your webview is up to date
Try a combination of these steps, and after a few tries it should eventually let you log in.

View file

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

View file

@ -1,720 +0,0 @@
package eu.kanade.tachiyomi.animeextension.all.googledrive
import android.app.Application
import android.content.SharedPreferences
import android.text.Editable
import android.text.TextWatcher
import android.widget.Button
import android.widget.EditText
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.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.encodeToString
import kotlinx.serialization.json.Json
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.ProtocolException
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import okhttp3.internal.commonEmptyRequestBody
import org.jsoup.nodes.Document
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
import java.net.URLEncoder
import java.security.MessageDigest
class GoogleDrive : ConfigurableAnimeSource, AnimeHttpSource() {
override val name = "Google Drive"
override val id = 4222017068256633289
override var baseUrl = "https://drive.google.com"
// Hack to manipulate what gets opened in webview
private val baseUrlInternal by lazy {
preferences.domainList.split(";").firstOrNull()
}
override val lang = "all"
override val supportsLatest = false
private val json: Json by injectLazy()
private val preferences: SharedPreferences by lazy {
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
}
// Overriding headersBuilder() seems to cause issues with webview
private val getHeaders = headers.newBuilder().apply {
add("Accept", "*/*")
add("Connection", "keep-alive")
add("Cookie", getCookie("https://drive.google.com"))
add("Host", "drive.google.com")
}.build()
private var nextPageToken: String? = ""
// ============================== Popular ===============================
override suspend fun getPopularAnime(page: Int): AnimesPage =
parsePage(popularAnimeRequest(page), page)
override fun popularAnimeRequest(page: Int): Request {
require(!baseUrlInternal.isNullOrEmpty()) { "Enter drive path(s) in extension settings." }
val match = DRIVE_FOLDER_REGEX.matchEntire(baseUrlInternal!!)!!
val folderId = match.groups["id"]!!.value
val recurDepth = match.groups["depth"]?.value ?: ""
baseUrl = "https://drive.google.com/drive/folders/$folderId"
return GET(
"https://drive.google.com/drive/folders/$folderId$recurDepth",
headers = getHeaders,
)
}
override fun popularAnimeParse(response: Response): AnimesPage = throw UnsupportedOperationException()
// =============================== Latest ===============================
override fun latestUpdatesRequest(page: Int): Request = throw UnsupportedOperationException()
override fun latestUpdatesParse(response: Response): AnimesPage = throw UnsupportedOperationException()
// =============================== Search ===============================
override fun searchAnimeParse(response: Response): AnimesPage = throw UnsupportedOperationException()
override suspend fun getSearchAnime(
page: Int,
query: String,
filters: AnimeFilterList,
): AnimesPage {
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)
if (query.isEmpty()) {
parsePage(req, page)
} else {
val parentId = req.url.pathSegments.last()
val cleanQuery = URLEncoder.encode(query, "UTF-8")
val genMultiFormReq = searchReq(parentId, cleanQuery)
parsePage(req, page, genMultiFormReq)
}
} else {
addSinglePage(urlFilter.state)
}
}
override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request {
require(!baseUrlInternal.isNullOrEmpty()) { "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 = DRIVE_FOLDER_REGEX.matchEntire(serverUrl)!!
val folderId = match.groups["id"]!!.value
val recurDepth = match.groups["depth"]?.value ?: ""
baseUrl = "https://drive.google.com/drive/folders/$folderId"
return GET(
"https://drive.google.com/drive/folders/$folderId$recurDepth",
headers = getHeaders,
)
}
// ============================== FILTERS ===============================
override fun getFilterList(): AnimeFilterList = AnimeFilterList(
ServerFilter(getDomains()),
AnimeFilter.Separator(),
AnimeFilter.Header("Add single folder"),
URLFilter(),
)
private class ServerFilter(domains: Array<Pair<String, String>>) : UriPartFilter(
"Select drive path",
domains,
)
private fun getDomains(): Array<Pair<String, String>> {
if (preferences.domainList.isBlank()) return emptyArray()
return preferences.domainList.split(";").map {
val name = DRIVE_FOLDER_REGEX.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<Pair<String, String>>) :
AnimeFilter.Select<String>(displayName, vals.map { it.first }.toTypedArray()) {
fun toUriPart() = vals[state].second
}
private class URLFilter : AnimeFilter.Text("Url")
// =========================== Anime Details ============================
override fun animeDetailsRequest(anime: SAnime): Request {
val parsed = json.decodeFromString<LinkData>(anime.url)
return GET(parsed.url, headers = getHeaders)
}
override suspend fun getAnimeDetails(anime: SAnime): SAnime {
val parsed = json.decodeFromString<LinkData>(anime.url)
if (parsed.type == "single") return anime
val folderId = DRIVE_FOLDER_REGEX.matchEntire(parsed.url)!!.groups["id"]!!.value
val driveDocument = try {
client.newCall(GET(parsed.url, headers = getHeaders)).execute().asJsoup()
} catch (a: ProtocolException) {
null
} ?: return anime
// Get cover
val coverResponse = client.newCall(
createPost(driveDocument, folderId, nextPageToken, searchReqWithType(folderId, "cover", IMAGE_MIMETYPE)),
).execute().parseAs<PostResponse> { JSON_REGEX.find(it)!!.groupValues[1] }
coverResponse.items?.firstOrNull()?.let {
anime.thumbnail_url = "https://drive.google.com/uc?id=${it.id}"
}
// Get details
val detailsResponse = client.newCall(
createPost(driveDocument, folderId, nextPageToken, searchReqWithType(folderId, "details.json", "")),
).execute().parseAs<PostResponse> { JSON_REGEX.find(it)!!.groupValues[1] }
detailsResponse.items?.firstOrNull()?.let {
val newPostHeaders = getHeaders.newBuilder().apply {
add("Content-Type", "application/x-www-form-urlencoded;charset=utf-8")
set("Host", "drive.usercontent.google.com")
add("Origin", "https://drive.google.com")
add("Referer", "https://drive.google.com/")
add("X-Drive-First-Party", "DriveWebUi")
add("X-Json-Requested", "true")
}.build()
val newPostUrl = "https://drive.usercontent.google.com/uc?id=${it.id}&authuser=0&export=download"
val newResponse = client.newCall(
POST(newPostUrl, headers = newPostHeaders, body = commonEmptyRequestBody),
).execute().parseAs<DownloadResponse> { JSON_REGEX.find(it)!!.groupValues[1] }
val downloadHeaders = headers.newBuilder().apply {
add("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8")
add("Connection", "keep-alive")
add("Cookie", getCookie("https://drive.usercontent.google.com"))
add("Host", "drive.usercontent.google.com")
}.build()
client.newCall(
GET(newResponse.downloadUrl, headers = downloadHeaders),
).execute().parseAs<DetailsJson>().let { t ->
t.title?.let { anime.title = it }
t.author?.let { anime.author = it }
t.artist?.let { anime.artist = it }
t.description?.let { anime.description = it }
t.genre?.let { anime.genre = it.joinToString(", ") }
t.status?.let { anime.status = it.toIntOrNull() ?: SAnime.UNKNOWN }
}
}
return anime
}
override fun animeDetailsParse(response: Response): SAnime = throw UnsupportedOperationException()
// ============================== Episodes ==============================
override suspend fun getEpisodeList(anime: SAnime): List<SEpisode> {
val episodeList = mutableListOf<SEpisode>()
val parsed = json.decodeFromString<LinkData>(anime.url)
if (parsed.type == "single") {
return listOf(
SEpisode.create().apply {
name = "Video"
scanlator = parsed.info!!.size
url = parsed.url
episode_number = 1F
date_upload = -1L
},
)
}
val match = DRIVE_FOLDER_REGEX.matchEntire(parsed.url)!! // .groups["id"]!!.value
val maxRecursionDepth = match.groups["depth"]?.let {
it.value.substringAfter("#").substringBefore(",").toInt()
} ?: 2
val (start, stop) = match.groups["range"]?.let {
it.value.substringAfter(",").split(",").map { it.toInt() }
} ?: listOf(null, null)
fun traverseFolder(folderUrl: String, path: String, recursionDepth: Int = 0) {
if (recursionDepth == maxRecursionDepth) return
val folderId = DRIVE_FOLDER_REGEX.matchEntire(folderUrl)!!.groups["id"]!!.value
val driveDocument = try {
client.newCall(GET(folderUrl, headers = getHeaders)).execute().asJsoup()
} catch (a: ProtocolException) {
throw Exception("Unable to get items, check webview")
}
if (driveDocument.selectFirst("title:contains(Error 404 \\(Not found\\))") != null) return
var pageToken: String? = ""
var counter = 1
while (pageToken != null) {
val response = client.newCall(
createPost(driveDocument, folderId, pageToken),
).execute()
val parsed = response.parseAs<PostResponse> {
JSON_REGEX.find(it)!!.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 size = it.fileSize?.toLongOrNull()?.let { formatBytes(it) } ?: ""
val pathName = if (preferences.trimEpisodeInfo) path.trimInfo() else path
if (start != null && maxRecursionDepth == 1 && counter < start) {
counter++
return@forEachIndexed
}
if (stop != null && maxRecursionDepth == 1 && counter > stop) return
episodeList.add(
SEpisode.create().apply {
name =
if (preferences.trimEpisodeName) it.title.trimInfo() else it.title
url = "https://drive.google.com/uc?id=${it.id}"
episode_number =
ITEM_NUMBER_REGEX.find(it.title.trimInfo())?.groupValues?.get(1)
?.toFloatOrNull() ?: (index + 1).toFloat()
date_upload = -1L
scanlator = if (preferences.scanlatorOrder) {
"/$pathName$size"
} else {
"$size • /$pathName"
}
},
)
counter++
}
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
}
}
traverseFolder(parsed.url, "")
return episodeList.reversed()
}
override fun episodeListParse(response: Response): List<SEpisode> = throw UnsupportedOperationException()
// ============================ Video Links =============================
override suspend fun getVideoList(episode: SEpisode): List<Video> =
GoogleDriveExtractor(client, headers).videosFromUrl(episode.url.substringAfter("?id="))
// ============================= Utilities ==============================
private fun addSinglePage(folderUrl: String): AnimesPage {
val match =
DRIVE_FOLDER_REGEX.matchEntire(folderUrl) ?: throw Exception("Invalid drive url")
val recurDepth = match.groups["depth"]?.value ?: ""
val anime = SAnime.create().apply {
title = match.groups["name"]?.value?.substringAfter("[")?.substringBeforeLast("]")
?: "Folder"
url = LinkData(
"https://drive.google.com/drive/folders/${match.groups["id"]!!.value}$recurDepth",
"multi",
).toJsonString()
thumbnail_url = ""
}
return AnimesPage(listOf(anime), false)
}
private fun createPost(
document: Document,
folderId: String,
pageToken: String?,
getMultiFormPath: (String, String, String) -> String = { folderIdStr, nextPageTokenStr, keyStr ->
defaultGetRequest(folderIdStr, nextPageTokenStr, keyStr)
},
): Request {
val keyScript = document.select("script").first { script ->
KEY_REGEX.find(script.data()) != null
}.data()
val key = KEY_REGEX.find(keyScript)?.groupValues?.get(1) ?: ""
val versionScript = document.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 ?: ""
val requestUrl = getMultiFormPath(folderId, pageToken ?: "", key)
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().apply {
add("Content-Type", "text/plain; charset=UTF-8")
add("Origin", "https://drive.google.com")
add("Cookie", getCookie("https://drive.google.com"))
}.build()
return POST(postUrl, body = body, headers = postHeaders)
}
private fun parsePage(
request: Request,
page: Int,
genMultiFormReq: ((String, String, String) -> String)? = null,
): AnimesPage {
val animeList = mutableListOf<SAnime>()
val recurDepth = request.url.encodedFragment?.let { "#$it" } ?: ""
val folderId = DRIVE_FOLDER_REGEX.matchEntire(request.url.toString())!!.groups["id"]!!.value
val driveDocument = try {
client.newCall(request).execute().asJsoup()
} catch (a: ProtocolException) {
throw Exception("Unable to get items, check webview")
}
if (driveDocument.selectFirst("title:contains(Error 404 \\(Not found\\))") != null) {
return AnimesPage(emptyList(), false)
}
if (page == 1) nextPageToken = ""
val post = if (genMultiFormReq == null) {
createPost(driveDocument, folderId, nextPageToken)
} else {
createPost(
driveDocument,
folderId,
nextPageToken,
genMultiFormReq,
)
}
val response = client.newCall(post).execute()
val parsed = response.parseAs<PostResponse> {
JSON_REGEX.find(it)!!.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")) {
animeList.add(
SAnime.create().apply {
title = if (preferences.trimAnimeInfo) it.title.trimInfo() else it.title
url = LinkData(
"https://drive.google.com/uc?id=${it.id}",
"single",
LinkDataInfo(
it.title,
it.fileSize?.toLongOrNull()?.let { formatBytes(it) } ?: "",
),
).toJsonString()
thumbnail_url = ""
},
)
}
if (it.mimeType.endsWith(".folder")) {
animeList.add(
SAnime.create().apply {
title = if (preferences.trimAnimeInfo) it.title.trimInfo() else it.title
url = LinkData(
"https://drive.google.com/drive/folders/${it.id}$recurDepth",
"multi",
).toJsonString()
thumbnail_url = ""
},
)
}
}
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 {
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 LinkData.toJsonString(): String {
return json.encodeToString(this)
}
private fun isFolder(text: String) = DRIVE_FOLDER_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 setupEditTextFolderValidator(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(::isFolder)
editText.error = if (!isValid) {
"${
text.split(";").first { !isFolder(it) }
} is not a valid google drive folder"
} else {
null
}
editText.rootView.findViewById<Button>(android.R.id.button1)
?.isEnabled = editText.error == null
}
},
)
}
companion object {
private const val DOMAIN_PREF_KEY = "domain_list"
private const val DOMAIN_PREF_DEFAULT = ""
private const val TRIM_ANIME_KEY = "trim_anime_info"
private const val TRIM_ANIME_DEFAULT = false
private const val TRIM_EPISODE_NAME_KEY = "trim_episode_name"
private const val TRIM_EPISODE_NAME_DEFAULT = true
private const val TRIM_EPISODE_INFO_KEY = "trim_episode_info"
private const val TRIM_EPISODE_INFO_DEFAULT = false
private const val SCANLATOR_ORDER_KEY = "scanlator_order"
private const val SCANLATOR_ORDER_DEFAULT = false
private val DRIVE_FOLDER_REGEX = Regex(
"""(?<name>\[[^\[\];]+\])?https?:\/\/(?:docs|drive)\.google\.com\/drive(?:\/[^\/]+)*?\/folders\/(?<id>[\w-]{28,})(?:\?[^;#]+)?(?<depth>#\d+(?<range>,\d+,\d+)?)?${'$'}""",
)
private val KEY_REGEX = Regex(""""(\w{39})"""")
private val VERSION_REGEX = Regex(""""([^"]+web-frontend[^"]+)"""")
private val JSON_REGEX = Regex("""(?:)\s*(\{(.+)\})\s*(?:)""", RegexOption.DOT_MATCHES_ALL)
private const val BOUNDARY = "=====vc17a3rwnndj====="
private val ITEM_NUMBER_REGEX = Regex(""" - (?:S\d+E)?(\d+)\b""")
}
private val SharedPreferences.domainList
get() = getString(DOMAIN_PREF_KEY, DOMAIN_PREF_DEFAULT)!!
private val SharedPreferences.trimAnimeInfo
get() = getBoolean(TRIM_ANIME_KEY, TRIM_ANIME_DEFAULT)
private val SharedPreferences.trimEpisodeName
get() = getBoolean(TRIM_EPISODE_NAME_KEY, TRIM_EPISODE_NAME_DEFAULT)
private val SharedPreferences.trimEpisodeInfo
get() = getBoolean(TRIM_EPISODE_INFO_KEY, TRIM_EPISODE_INFO_DEFAULT)
private val SharedPreferences.scanlatorOrder
get() = getBoolean(SCANLATOR_ORDER_KEY, SCANLATOR_ORDER_DEFAULT)
// ============================== Settings ==============================
override fun setupPreferenceScreen(screen: PreferenceScreen) {
EditTextPreference(screen.context).apply {
key = DOMAIN_PREF_KEY
title = "Enter drive paths to be shown in extension"
summary = """Enter links of drive folders to be shown in extension
|Enter as a semicolon `;` separated list
""".trimMargin()
this.setDefaultValue(DOMAIN_PREF_DEFAULT)
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 #<integer> to limit the depth of recursion when loading episodes, defaults is 2. For example: https://drive.google.com/drive/folders/whatever#5
|- (optional) add #depth,start,stop (all integers) to specify range when loading episodes. Only works if depth is 1. For example: https://drive.google.com/drive/folders/whatever#1,2,6
""".trimMargin()
setOnBindEditTextListener(::setupEditTextFolderValidator)
setOnPreferenceChangeListener { _, newValue ->
try {
val res =
preferences.edit().putString(DOMAIN_PREF_KEY, 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
}
}
}.also(screen::addPreference)
SwitchPreferenceCompat(screen.context).apply {
key = TRIM_ANIME_KEY
title = "Trim info from anime titles"
setDefaultValue(TRIM_ANIME_DEFAULT)
setOnPreferenceChangeListener { _, newValue ->
preferences.edit().putBoolean(key, newValue as Boolean).commit()
}
}.also(screen::addPreference)
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)
SwitchPreferenceCompat(screen.context).apply {
key = TRIM_EPISODE_INFO_KEY
title = "Trim info from episode info"
setDefaultValue(TRIM_EPISODE_INFO_DEFAULT)
setOnPreferenceChangeListener { _, newValue ->
preferences.edit().putBoolean(key, newValue as Boolean).commit()
}
}.also(screen::addPreference)
SwitchPreferenceCompat(screen.context).apply {
key = SCANLATOR_ORDER_KEY
title = "Switch order of file path and size"
setDefaultValue(SCANLATOR_ORDER_DEFAULT)
setOnPreferenceChangeListener { _, newValue ->
preferences.edit().putBoolean(key, newValue as Boolean).commit()
}
}.also(screen::addPreference)
}
}

View file

@ -1,45 +0,0 @@
package eu.kanade.tachiyomi.animeextension.all.googledrive
import kotlinx.serialization.Serializable
@Serializable
data class PostResponse(
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,
)
}
@Serializable
data class LinkData(
val url: String,
val type: String,
val info: LinkDataInfo? = null,
)
@Serializable
data class LinkDataInfo(
val title: String,
val size: String,
)
@Serializable
data class DownloadResponse(
val downloadUrl: String,
)
@Serializable
data class DetailsJson(
val title: String? = null,
val author: String? = null,
val artist: String? = null,
val description: String? = null,
val genre: List<String>? = null,
val status: String? = null,
)

View file

@ -1,18 +0,0 @@
package eu.kanade.tachiyomi.animeextension.all.googledrive
fun searchReq(parentId: String, query: String): (String, String, String) -> String {
return searchReqWithType(parentId, query, type = FOLDER_MIMETYPE)
}
fun searchReqWithType(parentId: String, query: String, type: String): (String, String, String) -> String {
return { _: String, nextPageToken: String, key: String ->
"/drive/v2internal/files?openDrive=false&reason=111&syncType=0&errorRecovery=false&q=title%20contains%20'$query'$type%20and%20trashed%20%3D%20false%20and%20'$parentId'%20in%20ancestors&fields=kind%2CnextPageToken%2Citems(kind%2CmodifiedDate%2ChasVisitorPermissions%2CcontainsUnsubscribedChildren%2CmodifiedByMeDate%2ClastViewedByMeDate%2CalternateLink%2CfileSize%2Cowners(kind%2CpermissionId%2CemailAddressFromAccount%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%2CcanModifyContentRestriction%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=$nextPageToken&maxResults=50&rawUserQuery=parent%3A$parentId%20type%3Afolder%20title%3A$query&supportsTeamDrives=true&includeItemsFromAllDrives=true&corpora=default&orderBy=relevance%20desc&retryCount=0&key=$key HTTP/1.1"
}
}
fun defaultGetRequest(folderId: String, nextPageToken: String, key: String): String {
return "/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=$nextPageToken&maxResults=100&supportsTeamDrives=true&includeItemsFromAllDrives=true&corpora=default&orderBy=folder%2Ctitle_natural%20asc&retryCount=0&key=$key HTTP/1.1"
}
const val IMAGE_MIMETYPE = "%20and%20(mimeType%20in%20'image%2Fbmp'%2C%20'application%2Fvnd.google-gsuite.encrypted%3B%20content%3D%22image%2Fbmp%22'%2C%20'image%2Fjpeg'%2C%20'application%2Fvnd.google-gsuite.encrypted%3B%20content%3D%22image%2Fjpeg%22'%2C%20'image%2Fpng'%2C%20'application%2Fvnd.google-gsuite.encrypted%3B%20content%3D%22image%2Fpng%22'%2C%20'image%2Fgif'%2C%20'application%2Fvnd.google-gsuite.encrypted%3B%20content%3D%22image%2Fgif%22'%2C%20'image%2Ftiff'%2C%20'application%2Fvnd.google-gsuite.encrypted%3B%20content%3D%22image%2Ftiff%22'%2C%20'image%2Fx-ms-bmp'%2C%20'application%2Fvnd.google-gsuite.encrypted%3B%20content%3D%22image%2Fx-ms-bmp%22'%2C%20'image%2Fsvg%2Bxml'%2C%20'application%2Fvnd.google-gsuite.encrypted%3B%20content%3D%22image%2Fsvg%2Bxml%22'%2C%20'image%2Fvnd.microsoft.icon'%2C%20'application%2Fvnd.google-gsuite.encrypted%3B%20content%3D%22image%2Fvnd.microsoft.icon%22'%2C%20'image%2Fheif'%2C%20'application%2Fvnd.google-gsuite.encrypted%3B%20content%3D%22image%2Fheif%22'%2C%20'image%2Fheic'%2C%20'application%2Fvnd.google-gsuite.encrypted%3B%20content%3D%22image%2Fheic%22'%2C%20'image%2Fwebp'%2C%20'application%2Fvnd.google-gsuite.encrypted%3B%20content%3D%22image%2Fwebp%22'%20or%20shortcutDetails.targetMimeType%20in%20'image%2Fbmp'%2C%20'application%2Fvnd.google-gsuite.encrypted%3B%20content%3D%22image%2Fbmp%22'%2C%20'image%2Fjpeg'%2C%20'application%2Fvnd.google-gsuite.encrypted%3B%20content%3D%22image%2Fjpeg%22'%2C%20'image%2Fpng'%2C%20'application%2Fvnd.google-gsuite.encrypted%3B%20content%3D%22image%2Fpng%22'%2C%20'image%2Fgif'%2C%20'application%2Fvnd.google-gsuite.encrypted%3B%20content%3D%22image%2Fgif%22'%2C%20'image%2Ftiff'%2C%20'application%2Fvnd.google-gsuite.encrypted%3B%20content%3D%22image%2Ftiff%22'%2C%20'image%2Fx-ms-bmp'%2C%20'application%2Fvnd.google-gsuite.encrypted%3B%20content%3D%22image%2Fx-ms-bmp%22'%2C%20'image%2Fsvg%2Bxml'%2C%20'application%2Fvnd.google-gsuite.encrypted%3B%20content%3D%22image%2Fsvg%2Bxml%22'%2C%20'image%2Fvnd.microsoft.icon'%2C%20'application%2Fvnd.google-gsuite.encrypted%3B%20content%3D%22image%2Fvnd.microsoft.icon%22'%2C%20'image%2Fheif'%2C%20'application%2Fvnd.google-gsuite.encrypted%3B%20content%3D%22image%2Fheif%22'%2C%20'image%2Fheic'%2C%20'application%2Fvnd.google-gsuite.encrypted%3B%20content%3D%22image%2Fheic%22'%2C%20'image%2Fwebp'%2C%20'application%2Fvnd.google-gsuite.encrypted%3B%20content%3D%22image%2Fwebp%22')"
const val FOLDER_MIMETYPE = "%20and%20(mimeType%20in%20'application%2Fvnd.google-apps.folder'%20or%20shortcutDetails.targetMimeType%20in%20'application%2Fvnd.google-apps.folder')"

View file

@ -1,7 +0,0 @@
ext {
extName = 'GoogleDriveIndex'
extClass = '.GoogleDriveIndex'
extVersionCode = 7
}
apply from: "$rootDir/common.gradle"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

View file

@ -1,743 +0,0 @@
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
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.network.awaitSuccess
import eu.kanade.tachiyomi.util.asJsoup
import eu.kanade.tachiyomi.util.parseAs
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import okhttp3.Credentials
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
import java.net.URLEncoder
class GoogleDriveIndex : ConfigurableAnimeSource, AnimeHttpSource() {
override val name = "GoogleDriveIndex"
override val baseUrl by lazy {
preferences.domainList.split(",").first().removeName()
}
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 = network.client.newBuilder()
.addInterceptor { chain ->
var request = chain.request()
if (request.url.username.isNotBlank() && request.url.password.isNotBlank()) {
val credential = Credentials.basic(request.url.username, request.url.password)
request = request.newBuilder()
.header("Authorization", credential)
.build()
val newUrl = request.url.newBuilder()
.username("")
.password("")
.build()
request = request.newBuilder()
.url(newUrl)
.build()
}
chain.proceed(request)
}
.build()
// ============================== Popular ===============================
override fun popularAnimeRequest(page: Int): Request {
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 = ""
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.asReferer())
.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 = parsePage(response, baseUrl)
// =============================== Latest ===============================
override fun latestUpdatesRequest(page: Int): Request = throw UnsupportedOperationException()
override fun latestUpdatesParse(response: Response): AnimesPage = throw UnsupportedOperationException()
// =============================== Search ===============================
override fun searchAnimeParse(response: Response): AnimesPage = throw UnsupportedOperationException()
override suspend fun getSearchAnime(
page: Int,
query: String,
filters: AnimeFilterList,
): AnimesPage {
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)
client.newCall(req).awaitSuccess()
.let { response ->
searchAnimeParse(response, req.url.toString())
}
} else {
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 {
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 (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 when {
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.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())
POST(
searchUrl,
body = popBody,
headers = searchHeaders.add("Referer", "$searchUrl?q=$cleanQuery").build(),
)
}
}
}
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<Pair<String, String>>) : UriPartFilter(
"Select server",
domains,
)
private fun getDomains(): Array<Pair<String, String>> {
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()
}
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
}
private class URLFilter : AnimeFilter.Text("Url")
// =========================== Anime Details ============================
override suspend fun getAnimeDetails(anime: SAnime): SAnime {
val parsed = json.decodeFromString<LinkData>(anime.url)
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", URLEncoder.encode(idParsed.referer, "UTF-8"))
.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") {
return anime
}
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", newParsed.url.toHttpUrl().host)
.add("Origin", "https://${newParsed.url.toHttpUrl().host}")
.add("Referer", URLEncoder.encode(newParsed.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 parsed = client.newCall(
POST(newParsed.url, body = popBody, headers = popHeaders),
).execute().parseAs<ResponseData> { it.decrypt() }
parsed.data.files.forEach { item ->
if (item.mimeType.startsWith("image/") && item.name.startsWith("cover", true)) {
anime.thumbnail_url = joinUrl(newParsed.url, item.name)
}
if (item.name.equals("details.json", true)) {
val details = client.newCall(
GET(joinUrl(newParsed.url, item.name)),
).execute().body.string()
val detailsParsed = json.decodeFromString<Details>(details)
detailsParsed.title?.let { anime.title = it }
detailsParsed.author?.let { anime.author = it }
detailsParsed.artist?.let { anime.artist = it }
detailsParsed.description?.let { anime.description = it }
detailsParsed.genre?.let { anime.genre = it.joinToString(", ") }
detailsParsed.status?.let { anime.status = it.toIntOrNull() ?: SAnime.UNKNOWN }
}
}
newToken = parsed.nextPageToken
newPageIndex += 1
}
return anime
}
override fun animeDetailsParse(response: Response): SAnime = throw UnsupportedOperationException()
// ============================== Episodes ==============================
override suspend fun getEpisodeList(anime: SAnime): List<SEpisode> {
val episodeList = mutableListOf<SEpisode>()
val parsed = json.decodeFromString<LinkData>(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
} 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", URLEncoder.encode(idParsed.referer, "UTF-8"))
.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 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, recursionDepth: Int = 0) {
if (recursionDepth == maxRecursionDepth) return
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 parsed = client.newCall(
POST(url, body = popBody, headers = popHeaders),
).execute().parseAs<ResponseData> { it.decrypt() }
parsed.data.files.forEach { item ->
if (item.mimeType.endsWith("folder")) {
val newUrl = joinUrl(url, item.name).addSuffix("/")
traverseDirectory(newUrl, recursionDepth + 1)
}
if (item.mimeType.startsWith("video/")) {
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
// 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 {
""
}
// 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) }
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++
}
}
newToken = parsed.nextPageToken
newPageIndex += 1
}
}
traverseDirectory(newParsed.url)
}
return episodeList.reversed()
}
override fun episodeListParse(response: Response): List<SEpisode> = throw UnsupportedOperationException()
// ============================ Video Links =============================
override suspend fun getVideoList(episode: SEpisode): List<Video> {
val url = episode.url
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))
}
// ============================= Utilities ==============================
private fun HttpUrl.hostAndCred(): String {
return if (this.password.isNotBlank() && this.username.isNotBlank()) {
"${this.username}:${this.password}@${this.host}"
} else {
this.host
}
}
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 >= 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 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)
}
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")) {
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",
joinUrl(URL_REGEX.matchEntire(url)!!.groups["url"]!!.value, item.name).addSuffix("/"),
fragment = url.toHttpUrl().encodedFragment,
).toJsonString()
}
},
)
}
if (
item.mimeType.startsWith("video/") &&
!(preferences.ignoreFolder && isSearch)
) {
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",
joinUrl(URL_REGEX.matchEntire(url)!!.groups["url"]!!.value, item.name),
item.size?.toLongOrNull()?.let { formatFileSize(it) },
fragment = url.toHttpUrl().encodedFragment,
).toJsonString()
}
},
)
}
}
pageToken = parsed.nextPageToken
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<Button>(android.R.id.button1)
?.isEnabled = editText.error == null
}
},
)
}
companion object {
private const val DOMAIN_PREF_KEY = "domain_list"
private const val DOMAIN_PREF_DEFAULT = ""
private const val SEARCH_FOLDER_IGNORE_KEY = "ignore_non_folder"
private const val SEARCH_FOLDER_IGNORE_DEFAULT = true
private const val TRIM_EPISODE_NAME_KEY = "trim_episode_name"
private const val TRIM_EPISODE_NAME_DEFAULT = true
private const val TRIM_ANIME_NAME_KEY = "trim_anime_name"
private const val TRIM_ANIME_NAME_DEFAULT = true
private val URL_REGEX = Regex("""(?<name>\[[^\[\];]+\])?(?<url>https(?:[^,#]+))(?<depth>#\d+(?<range>,\d+,\d+)?)?${'$'}""")
}
private val SharedPreferences.domainList
get() = getString(DOMAIN_PREF_KEY, DOMAIN_PREF_DEFAULT)!!
private val SharedPreferences.ignoreFolder
get() = getBoolean(SEARCH_FOLDER_IGNORE_KEY, SEARCH_FOLDER_IGNORE_DEFAULT)
private val SharedPreferences.trimEpisodeName
get() = getBoolean(TRIM_EPISODE_NAME_KEY, TRIM_EPISODE_NAME_DEFAULT)
private val SharedPreferences.trimAnimeName
get() = getBoolean(TRIM_ANIME_NAME_KEY, TRIM_ANIME_NAME_DEFAULT)
// ============================== Settings ==============================
override fun setupPreferenceScreen(screen: PreferenceScreen) {
EditTextPreference(screen.context).apply {
key = DOMAIN_PREF_KEY
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(DOMAIN_PREF_DEFAULT)
dialogTitle = "Path list"
dialogMessage = """Separate paths with a comma. For password protected sites,
|format as: "https://username:password@example.worker.dev/0:/"
|- (optional) Add [] before url to customize name. For example: [drive 5]https://site.workers.dev/0:
|- (optional) add #<integer> to limit the depth of recursion when loading episodes, defaults is 2. For example: https://site.workers.dev/0:#5
|- (optional) add #depth,start,stop (all integers) to specify range when loading episodes. Only works if depth is 1. For example: https://site.workers.dev/0:#1,2,6
""".trimMargin()
setOnBindEditTextListener(::setupEditTextUrlValidator)
setOnPreferenceChangeListener { _, newValue ->
try {
val res = preferences.edit().putString(DOMAIN_PREF_KEY, 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
}
}
}.also(screen::addPreference)
SwitchPreferenceCompat(screen.context).apply {
key = SEARCH_FOLDER_IGNORE_KEY
title = "Only include folders on search"
setDefaultValue(SEARCH_FOLDER_IGNORE_DEFAULT)
setOnPreferenceChangeListener { _, newValue ->
preferences.edit().putBoolean(key, newValue as Boolean).commit()
}
}.also(screen::addPreference)
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)
SwitchPreferenceCompat(screen.context).apply {
key = TRIM_ANIME_NAME_KEY
title = "Trim info from anime name"
setDefaultValue(TRIM_ANIME_NAME_DEFAULT)
setOnPreferenceChangeListener { _, newValue ->
preferences.edit().putBoolean(key, newValue as Boolean).commit()
}
}.also(screen::addPreference)
}
}

View file

@ -1,49 +0,0 @@
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,
val fragment: String? = null,
)
@Serializable
data class IdUrl(
val id: String,
val url: String,
val referer: String,
val type: String,
)
@Serializable
data class Details(
val title: String? = null,
val author: String? = null,
val artist: String? = null,
val description: String? = null,
val genre: List<String>? = null,
val status: String? = null,
)

View file

@ -1,7 +0,0 @@
ext {
extName = 'Jellyfin'
extClass = '.JellyfinFactory'
extVersionCode = 15
}
apply from: "$rootDir/common.gradle"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

View file

@ -1,803 +0,0 @@
package eu.kanade.tachiyomi.animeextension.all.jellyfin
import android.annotation.SuppressLint
import android.app.Application
import android.content.Context
import android.content.SharedPreferences
import android.text.InputType
import android.widget.Toast
import androidx.preference.EditTextPreference
import androidx.preference.ListPreference
import androidx.preference.MultiSelectListPreference
import androidx.preference.PreferenceScreen
import androidx.preference.SwitchPreferenceCompat
import eu.kanade.tachiyomi.animesource.ConfigurableAnimeSource
import eu.kanade.tachiyomi.animesource.UnmeteredSource
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.Track
import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.animesource.online.AnimeHttpSource
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.util.parseAs
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import okhttp3.Dns
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.security.MessageDigest
import java.security.cert.X509Certificate
import javax.net.ssl.SSLContext
import javax.net.ssl.TrustManager
import javax.net.ssl.X509TrustManager
class Jellyfin(private val suffix: String) : ConfigurableAnimeSource, AnimeHttpSource(), UnmeteredSource {
override val baseUrl by lazy { getPrefBaseUrl() }
override val lang = "all"
override val name by lazy { "Jellyfin (${getCustomLabel()})" }
override val supportsLatest = true
private fun getUnsafeOkHttpClient(): OkHttpClient {
// Create a trust manager that does not validate certificate chains
val trustAllCerts = arrayOf<TrustManager>(
@SuppressLint("CustomX509TrustManager")
object : X509TrustManager {
@SuppressLint("TrustAllX509TrustManager")
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) {
}
@SuppressLint("TrustAllX509TrustManager")
override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?) {
}
override fun getAcceptedIssuers() = arrayOf<X509Certificate>()
},
)
// Install the all-trusting trust manager
val sslContext = SSLContext.getInstance("SSL")
sslContext.init(null, trustAllCerts, java.security.SecureRandom())
// Create an ssl socket factory with our all-trusting manager
val sslSocketFactory = sslContext.socketFactory
return network.client.newBuilder()
.sslSocketFactory(sslSocketFactory, trustAllCerts[0] as X509TrustManager)
.hostnameVerifier { _, _ -> true }.build()
}
override val client by lazy {
if (preferences.getTrustCert) {
getUnsafeOkHttpClient()
} else {
network.client
}.newBuilder()
.dns(Dns.SYSTEM)
.build()
}
override val id by lazy {
val key = "jellyfin" + (if (suffix == "1") "" else " ($suffix)") + "/all/$versionId"
val bytes = MessageDigest.getInstance("MD5").digest(key.toByteArray())
(0..7).map { bytes[it].toLong() and 0xff shl 8 * (7 - it) }.reduce(Long::or) and Long.MAX_VALUE
}
internal val preferences: SharedPreferences by lazy {
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
}
private var username = preferences.getUserName
private var password = preferences.getPassword
private var parentId = preferences.getMediaLibId
private var apiKey = preferences.getApiKey
private var userId = preferences.getUserId
init {
login(false)
}
private fun login(new: Boolean, context: Context? = null): Boolean? {
if (apiKey == null || userId == null || new) {
username = preferences.getUserName
password = preferences.getPassword
if (username.isEmpty() || password.isEmpty()) {
if (username != "demo") return null
}
val (newKey, newUid) = runBlocking {
withContext(Dispatchers.IO) {
JellyfinAuthenticator(preferences, getPrefBaseUrl(), client)
.login(username, password)
}
}
if (newKey != null && newUid != null) {
apiKey = newKey
userId = newUid
} else {
context?.let { Toast.makeText(it, "Login failed.", Toast.LENGTH_LONG).show() }
return false
}
}
return true
}
// ============================== Popular ===============================
override fun popularAnimeRequest(page: Int): Request {
require(parentId.isNotEmpty()) { "Select library in the extension settings." }
val startIndex = (page - 1) * SEASONS_LIMIT
val url = "$baseUrl/Users/$userId/Items".toHttpUrl().newBuilder().apply {
addQueryParameter("api_key", apiKey)
addQueryParameter("StartIndex", startIndex.toString())
addQueryParameter("Limit", SEASONS_LIMIT.toString())
addQueryParameter("Recursive", "true")
addQueryParameter("SortBy", "SortName")
addQueryParameter("SortOrder", "Ascending")
addQueryParameter("IncludeItemTypes", "Movie,Season,BoxSet")
addQueryParameter("ImageTypeLimit", "1")
addQueryParameter("ParentId", parentId)
addQueryParameter("EnableImageTypes", "Primary")
}.build()
return GET(url)
}
override fun popularAnimeParse(response: Response): AnimesPage {
val splitCollections = preferences.getSplitCol
val page = response.request.url.queryParameter("StartIndex")!!.toInt() / SEASONS_LIMIT + 1
val data = response.parseAs<ItemsDto>()
val animeList = data.items.flatMap {
if (it.type == "BoxSet" && splitCollections) {
val url = popularAnimeRequest(page).url.newBuilder().apply {
setQueryParameter("ParentId", it.id)
}.build()
popularAnimeParse(
client.newCall(GET(url)).execute(),
).animes
} else {
listOf(it.toSAnime(baseUrl, userId!!, apiKey!!))
}
}
return AnimesPage(animeList, SEASONS_LIMIT * page < data.itemCount)
}
// =============================== Latest ===============================
override fun latestUpdatesRequest(page: Int): Request {
val url = popularAnimeRequest(page).url.newBuilder().apply {
setQueryParameter("SortBy", "DateCreated,SortName")
setQueryParameter("SortOrder", "Descending")
}.build()
return GET(url)
}
override fun latestUpdatesParse(response: Response): AnimesPage =
popularAnimeParse(response)
// =============================== Search ===============================
override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request {
val url = popularAnimeRequest(page).url.newBuilder().apply {
// Search for series, rather than seasons, since season names can just be "Season 1"
setQueryParameter("IncludeItemTypes", "Movie,Series")
setQueryParameter("Limit", SERIES_LIMIT.toString())
setQueryParameter("SearchTerm", query)
}.build()
return GET(url)
}
override fun searchAnimeParse(response: Response): AnimesPage {
val page = response.request.url.queryParameter("StartIndex")!!.toInt() / SERIES_LIMIT + 1
val data = response.parseAs<ItemsDto>()
// Get all seasons from series
val animeList = data.items.flatMap { series ->
val seasonsUrl = popularAnimeRequest(1).url.newBuilder().apply {
setQueryParameter("ParentId", series.id)
removeAllQueryParameters("StartIndex")
removeAllQueryParameters("Limit")
}.build()
val seasonsData = client.newCall(
GET(seasonsUrl),
).execute().parseAs<ItemsDto>()
seasonsData.items.map { it.toSAnime(baseUrl, userId!!, apiKey!!) }
}
return AnimesPage(animeList, SERIES_LIMIT * page < data.itemCount)
}
// =========================== Anime Details ============================
override fun animeDetailsRequest(anime: SAnime): Request {
if (!anime.url.startsWith("http")) throw Exception("Migrate from jellyfin to jellyfin")
return GET(anime.url)
}
override fun animeDetailsParse(response: Response): SAnime {
val data = response.parseAs<ItemDto>()
val infoData = if (preferences.useSeriesData && data.seriesId != null) {
val url = response.request.url.let { url ->
url.newBuilder().apply {
removePathSegment(url.pathSize - 1)
addPathSegment(data.seriesId)
}.build()
}
client.newCall(
GET(url),
).execute().parseAs<ItemDto>()
} else {
data
}
return infoData.toSAnime(baseUrl, userId!!, apiKey!!)
}
// ============================== Episodes ==============================
override fun episodeListRequest(anime: SAnime): Request {
if (!anime.url.startsWith("http")) throw Exception("Migrate from jellyfin to jellyfin")
val httpUrl = anime.url.toHttpUrl()
val fragment = httpUrl.fragment!!
// Get episodes of season
val url = if (fragment.startsWith("seriesId")) {
httpUrl.newBuilder().apply {
encodedPath("/")
encodedQuery(null)
fragment(null)
addPathSegment("Shows")
addPathSegment(fragment.split(",").last())
addPathSegment("Episodes")
addQueryParameter("api_key", apiKey)
addQueryParameter("seasonId", httpUrl.pathSegments.last())
addQueryParameter("userId", userId)
addQueryParameter("Fields", "Overview,MediaSources")
}.build()
} else if (fragment.startsWith("movie")) {
httpUrl.newBuilder().fragment(null).build()
} else if (fragment.startsWith("boxSet")) {
val itemId = httpUrl.pathSegments[3]
httpUrl.newBuilder().apply {
removePathSegment(3)
addQueryParameter("Recursive", "true")
addQueryParameter("SortBy", "SortName")
addQueryParameter("SortOrder", "Ascending")
addQueryParameter("IncludeItemTypes", "Movie,Season,BoxSet,Series")
addQueryParameter("ParentId", itemId)
}.build()
} else if (fragment.startsWith("series")) {
val itemId = httpUrl.pathSegments[3]
httpUrl.newBuilder().apply {
encodedPath("/")
encodedQuery(null)
addPathSegment("Shows")
addPathSegment(itemId)
addPathSegment("Episodes")
addQueryParameter("api_key", apiKey)
}.build()
} else {
httpUrl
}
return GET(url)
}
override fun episodeListParse(response: Response): List<SEpisode> {
val httpUrl = response.request.url
val episodeList = if (httpUrl.fragment == "boxSet") {
val data = response.parseAs<ItemsDto>()
val animeList = data.items.map {
it.toSAnime(baseUrl, userId!!, apiKey!!)
}.sortedByDescending { it.title }
animeList.flatMap {
client.newCall(episodeListRequest(it))
.execute()
.let { res ->
episodeListParse(res, "${it.title} - ")
}
}
} else {
episodeListParse(response, "")
}
return if (preferences.sortEp) {
episodeList.sortedByDescending { it.date_upload }
} else {
episodeList
}
}
private fun episodeListParse(response: Response, prefix: String): List<SEpisode> {
val httpUrl = response.request.url
val epDetails = preferences.getEpDetails
return if (response.request.url.toString().startsWith("$baseUrl/Users/")) {
val data = response.parseAs<ItemDto>()
listOf(data.toSEpisode(baseUrl, userId!!, apiKey!!, epDetails, EpisodeType.MOVIE, prefix))
} else if (httpUrl.fragment == "series") {
val data = response.parseAs<ItemsDto>()
data.items.map {
val name = prefix + (it.seasonName?.let { "$it - " } ?: "")
it.toSEpisode(baseUrl, userId!!, apiKey!!, epDetails, EpisodeType.EPISODE, name)
}
} else {
val data = response.parseAs<ItemsDto>()
data.items.map {
it.toSEpisode(baseUrl, userId!!, apiKey!!, epDetails, EpisodeType.EPISODE, prefix)
}
}.reversed()
}
enum class EpisodeType {
EPISODE,
MOVIE,
}
// ============================ Video Links =============================
override fun videoListRequest(episode: SEpisode): Request {
if (!episode.url.startsWith("http")) throw Exception("Migrate from jellyfin to jellyfin")
return GET(episode.url)
}
override fun videoListParse(response: Response): List<Video> {
val id = response.parseAs<ItemDto>().id
val sessionData = client.newCall(
GET("$baseUrl/Items/$id/PlaybackInfo?userId=$userId&api_key=$apiKey"),
).execute().parseAs<SessionDto>()
val videoList = mutableListOf<Video>()
val subtitleList = mutableListOf<Track>()
val externalSubtitleList = mutableListOf<Track>()
val prefSub = preferences.getSubPref
val prefAudio = preferences.getAudioPref
var audioIndex = 1
var subIndex: Int? = null
var width = 1920
var height = 1080
sessionData.mediaSources.first().mediaStreams.forEach { media ->
when (media.type) {
"Video" -> {
width = media.width!!
height = media.height!!
}
"Audio" -> {
if (media.lang != null && media.lang == prefAudio) {
audioIndex = media.index
}
}
"Subtitle" -> {
if (media.supportsExternalStream) {
val subtitleUrl = "$baseUrl/Videos/$id/$id/Subtitles/${media.index}/0/Stream.${media.codec}?api_key=$apiKey"
if (media.lang != null) {
if (media.lang == prefSub) {
try {
if (media.isExternal) {
externalSubtitleList.add(0, Track(subtitleUrl, media.displayTitle!!))
}
subtitleList.add(0, Track(subtitleUrl, media.displayTitle!!))
} catch (e: Exception) {
subIndex = media.index
}
} else {
if (media.isExternal) {
externalSubtitleList.add(Track(subtitleUrl, media.displayTitle!!))
}
subtitleList.add(Track(subtitleUrl, media.displayTitle!!))
}
} else {
if (media.isExternal) {
externalSubtitleList.add(Track(subtitleUrl, media.displayTitle!!))
}
subtitleList.add(Track(subtitleUrl, media.displayTitle!!))
}
}
}
}
}
// Loop over qualities
JellyfinConstants.QUALITIES_LIST.forEach { quality ->
if (width < quality.width && height < quality.height) {
val url = "$baseUrl/Videos/$id/stream?static=True&api_key=$apiKey"
videoList.add(Video(url, "Source", url, subtitleTracks = externalSubtitleList))
return videoList.reversed()
} else {
val url = "$baseUrl/videos/$id/main.m3u8".toHttpUrl().newBuilder().apply {
addQueryParameter("api_key", apiKey)
addQueryParameter("VideoCodec", "h264")
addQueryParameter("AudioCodec", "aac,mp3")
addQueryParameter("AudioStreamIndex", audioIndex.toString())
subIndex?.let { addQueryParameter("SubtitleStreamIndex", it.toString()) }
addQueryParameter("VideoCodec", "h264")
addQueryParameter("VideoCodec", "h264")
addQueryParameter(
"VideoBitrate",
quality.videoBitrate.toString(),
)
addQueryParameter(
"AudioBitrate",
quality.audioBitrate.toString(),
)
addQueryParameter("PlaySessionId", sessionData.playSessionId)
addQueryParameter("TranscodingMaxAudioChannels", "6")
addQueryParameter("RequireAvc", "false")
addQueryParameter("SegmentContainer", "ts")
addQueryParameter("MinSegments", "1")
addQueryParameter("BreakOnNonKeyFrames", "true")
addQueryParameter("h264-profile", "high,main,baseline,constrainedbaseline")
addQueryParameter("h264-level", "51")
addQueryParameter("h264-deinterlace", "true")
addQueryParameter("TranscodeReasons", "VideoCodecNotSupported,AudioCodecNotSupported,ContainerBitrateExceedsLimit")
}
videoList.add(Video(url.toString(), quality.description, url.toString(), subtitleTracks = subtitleList))
}
}
val url = "$baseUrl/Videos/$id/stream?static=True&api_key=$apiKey"
videoList.add(Video(url, "Source", url, subtitleTracks = externalSubtitleList))
return videoList.reversed()
}
// ============================= Utilities ==============================
companion object {
const val APIKEY_KEY = "api_key"
const val USERID_KEY = "user_id"
internal const val EXTRA_SOURCES_COUNT_KEY = "extraSourcesCount"
internal const val EXTRA_SOURCES_COUNT_DEFAULT = "3"
private val EXTRA_SOURCES_ENTRIES = (1..10).map { it.toString() }.toTypedArray()
private const val PREF_CUSTOM_LABEL_KEY = "pref_label"
private const val PREF_CUSTOM_LABEL_DEFAULT = ""
private const val HOSTURL_KEY = "host_url"
private const val HOSTURL_DEFAULT = "http://127.0.0.1:8096"
private const val USERNAME_KEY = "username"
private const val USERNAME_DEFAULT = ""
private const val PASSWORD_KEY = "password"
private const val PASSWORD_DEFAULT = ""
private const val MEDIALIB_KEY = "library_pref"
private const val MEDIALIB_DEFAULT = ""
private const val SEASONS_LIMIT = 20
private const val SERIES_LIMIT = 5
private const val PREF_EP_DETAILS_KEY = "pref_episode_details_key"
private val PREF_EP_DETAILS = arrayOf("Overview", "Runtime", "Size")
private val PREF_EP_DETAILS_DEFAULT = emptySet<String>()
private const val PREF_SUB_KEY = "preferred_subLang"
private const val PREF_SUB_DEFAULT = "eng"
private const val PREF_AUDIO_KEY = "preferred_audioLang"
private const val PREF_AUDIO_DEFAULT = "jpn"
private const val PREF_INFO_TYPE = "preferred_meta_type"
private const val PREF_INFO_DEFAULT = false
private const val PREF_TRUST_CERT_KEY = "preferred_trust_all_certs"
private const val PREF_TRUST_CERT_DEFAULT = false
private const val PREF_SPLIT_COLLECTIONS_KEY = "preferred_split_col"
private const val PREF_SPLIT_COLLECTIONS_DEFAULT = false
private const val PREF_SORT_EPISODES_KEY = "preferred_sort_ep"
private const val PREF_SORT_EPISODES_DEFAULT = false
}
private fun getCustomLabel(): String =
preferences.getString(PREF_CUSTOM_LABEL_KEY, suffix)!!.ifBlank { suffix }
private fun getPrefBaseUrl(): String =
preferences.getString(HOSTURL_KEY, HOSTURL_DEFAULT)!!
override fun setupPreferenceScreen(screen: PreferenceScreen) {
if (suffix == "1") {
ListPreference(screen.context).apply {
key = EXTRA_SOURCES_COUNT_KEY
title = "Number of sources"
summary = "Number of jellyfin sources to create. There will always be at least one Jellyfin source."
entries = EXTRA_SOURCES_ENTRIES
entryValues = EXTRA_SOURCES_ENTRIES
setDefaultValue(EXTRA_SOURCES_COUNT_DEFAULT)
setOnPreferenceChangeListener { _, newValue ->
try {
val setting = preferences.edit().putString(EXTRA_SOURCES_COUNT_KEY, newValue as String).commit()
Toast.makeText(screen.context, "Restart Aniyomi to apply new setting.", Toast.LENGTH_LONG).show()
setting
} catch (e: Exception) {
e.printStackTrace()
false
}
}
}.also(screen::addPreference)
}
EditTextPreference(screen.context).apply {
key = PREF_CUSTOM_LABEL_KEY
title = "Custom Label"
summary = "Show the given label for the source instead of the default."
setDefaultValue(PREF_CUSTOM_LABEL_DEFAULT)
setOnPreferenceChangeListener { _, newValue ->
runCatching {
val value = (newValue as String).trim().ifBlank { PREF_CUSTOM_LABEL_DEFAULT }
Toast.makeText(screen.context, "Restart Aniyomi to apply new setting.", Toast.LENGTH_LONG).show()
preferences.edit().putString(key, value).commit()
}.getOrDefault(false)
}
}.also(screen::addPreference)
val mediaLibPref = medialibPreference(screen)
screen.addPreference(
screen.editTextPreference(
HOSTURL_KEY,
"Host URL",
HOSTURL_DEFAULT,
baseUrl,
false,
"",
mediaLibPref,
),
)
screen.addPreference(
screen.editTextPreference(
USERNAME_KEY,
"Username",
USERNAME_DEFAULT,
username,
false,
"The account username",
mediaLibPref,
),
)
screen.addPreference(
screen.editTextPreference(
PASSWORD_KEY,
"Password",
PASSWORD_DEFAULT,
password,
true,
"••••••••",
mediaLibPref,
),
)
screen.addPreference(mediaLibPref)
MultiSelectListPreference(screen.context).apply {
key = PREF_EP_DETAILS_KEY
title = "Additional details for episodes"
summary = "Show additional details about an episode in the scanlator field"
entries = PREF_EP_DETAILS
entryValues = PREF_EP_DETAILS
setDefaultValue(PREF_EP_DETAILS_DEFAULT)
setOnPreferenceChangeListener { _, newValue ->
@Suppress("UNCHECKED_CAST")
preferences.edit().putStringSet(key, newValue as Set<String>).commit()
}
}.also(screen::addPreference)
ListPreference(screen.context).apply {
key = PREF_SUB_KEY
title = "Preferred sub language"
entries = JellyfinConstants.PREF_ENTRIES
entryValues = JellyfinConstants.PREF_VALUES
setDefaultValue(PREF_SUB_DEFAULT)
summary = "%s"
setOnPreferenceChangeListener { _, newValue ->
val selected = newValue as String
val index = findIndexOfValue(selected)
val entry = entryValues[index] as String
preferences.edit().putString(key, entry).commit()
}
}.also(screen::addPreference)
ListPreference(screen.context).apply {
key = PREF_AUDIO_KEY
title = "Preferred audio language"
entries = JellyfinConstants.PREF_ENTRIES
entryValues = JellyfinConstants.PREF_VALUES
setDefaultValue(PREF_AUDIO_DEFAULT)
summary = "%s"
setOnPreferenceChangeListener { _, newValue ->
val selected = newValue as String
val index = findIndexOfValue(selected)
val entry = entryValues[index] as String
preferences.edit().putString(key, entry).commit()
}
}.also(screen::addPreference)
SwitchPreferenceCompat(screen.context).apply {
key = PREF_INFO_TYPE
title = "Retrieve metadata from series"
summary = """Enable this to retrieve metadata from series instead of season when applicable.""".trimMargin()
setDefaultValue(PREF_INFO_DEFAULT)
setOnPreferenceChangeListener { _, newValue ->
val new = newValue as Boolean
preferences.edit().putBoolean(key, new).commit()
}
}.also(screen::addPreference)
SwitchPreferenceCompat(screen.context).apply {
key = PREF_TRUST_CERT_KEY
title = "Trust all certificates"
summary = "Requires app restart to take effect."
setDefaultValue(PREF_TRUST_CERT_DEFAULT)
setOnPreferenceChangeListener { _, newValue ->
val new = newValue as Boolean
preferences.edit().putBoolean(key, new).commit()
}
}.also(screen::addPreference)
SwitchPreferenceCompat(screen.context).apply {
key = PREF_SPLIT_COLLECTIONS_KEY
title = "Split collections"
summary = "Split each item in a collection into its own entry"
setDefaultValue(PREF_SPLIT_COLLECTIONS_DEFAULT)
setOnPreferenceChangeListener { _, newValue ->
val new = newValue as Boolean
preferences.edit().putBoolean(key, new).commit()
}
}.also(screen::addPreference)
SwitchPreferenceCompat(screen.context).apply {
key = PREF_SORT_EPISODES_KEY
title = "Sort episodes by release date"
summary = "Useful for collections, otherwise items in a collection are grouped by name."
setDefaultValue(PREF_SORT_EPISODES_DEFAULT)
setOnPreferenceChangeListener { _, newValue ->
val new = newValue as Boolean
preferences.edit().putBoolean(key, new).commit()
}
}.also(screen::addPreference)
}
private val SharedPreferences.getApiKey
get() = getString(APIKEY_KEY, null)
private val SharedPreferences.getUserId
get() = getString(USERID_KEY, null)
private val SharedPreferences.getUserName
get() = getString(USERNAME_KEY, USERNAME_DEFAULT)!!
private val SharedPreferences.getPassword
get() = getString(PASSWORD_KEY, PASSWORD_DEFAULT)!!
private val SharedPreferences.getMediaLibId
get() = getString(MEDIALIB_KEY, MEDIALIB_DEFAULT)!!
private val SharedPreferences.getEpDetails
get() = getStringSet(PREF_EP_DETAILS_KEY, PREF_EP_DETAILS_DEFAULT)!!
private val SharedPreferences.getSubPref
get() = getString(PREF_SUB_KEY, PREF_SUB_DEFAULT)!!
private val SharedPreferences.getAudioPref
get() = getString(PREF_AUDIO_KEY, PREF_AUDIO_DEFAULT)!!
private val SharedPreferences.useSeriesData
get() = getBoolean(PREF_INFO_TYPE, PREF_INFO_DEFAULT)
private val SharedPreferences.getTrustCert
get() = getBoolean(PREF_TRUST_CERT_KEY, PREF_TRUST_CERT_DEFAULT)
private val SharedPreferences.getSplitCol
get() = getBoolean(PREF_SPLIT_COLLECTIONS_KEY, PREF_SPLIT_COLLECTIONS_DEFAULT)
private val SharedPreferences.sortEp
get() = getBoolean(PREF_SORT_EPISODES_KEY, PREF_SORT_EPISODES_DEFAULT)
private abstract class MediaLibPreference(context: Context) : ListPreference(context) {
abstract fun reload()
}
private fun medialibPreference(screen: PreferenceScreen) =
object : MediaLibPreference(screen.context) {
override fun reload() {
this.apply {
key = MEDIALIB_KEY
title = "Select Media Library"
summary = "%s"
Thread {
try {
val mediaLibsResponse = client.newCall(
GET("$baseUrl/Users/$userId/Items?api_key=$apiKey"),
).execute()
val mediaJson = mediaLibsResponse.parseAs<ItemsDto>().items
val entriesArray = mediaJson.map { it.name }
val entriesValueArray = mediaJson.map { it.id }
entries = entriesArray.toTypedArray()
entryValues = entriesValueArray.toTypedArray()
} catch (ex: Exception) {
entries = emptyArray()
entryValues = emptyArray()
}
}.start()
setOnPreferenceChangeListener { _, newValue ->
val selected = newValue as String
val index = findIndexOfValue(selected)
val entry = entryValues[index] as String
parentId = entry
preferences.edit().putString(key, entry).commit()
}
}
}
}.apply { reload() }
private fun getSummary(isPassword: Boolean, value: String, placeholder: String) = when {
isPassword && value.isNotEmpty() || !isPassword && value.isEmpty() -> placeholder
else -> value
}
private fun PreferenceScreen.editTextPreference(key: String, title: String, default: String, value: String, isPassword: Boolean = false, placeholder: String, mediaLibPref: MediaLibPreference): EditTextPreference {
return EditTextPreference(context).apply {
this.key = key
this.title = title
summary = getSummary(isPassword, value, placeholder)
this.setDefaultValue(default)
dialogTitle = title
setOnBindEditTextListener {
it.inputType = if (isPassword) {
InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
} else {
InputType.TYPE_CLASS_TEXT
}
}
setOnPreferenceChangeListener { _, newValue ->
try {
val newValueString = newValue as String
val res = preferences.edit().putString(key, newValueString).commit()
summary = getSummary(isPassword, newValueString, placeholder)
val loginRes = login(true, context)
if (loginRes == true) {
mediaLibPref.reload()
}
res
} catch (e: Exception) {
false
}
}
}
}
}

View file

@ -1,94 +0,0 @@
package eu.kanade.tachiyomi.animeextension.all.jellyfin
import android.content.SharedPreferences
import android.os.Build
import android.util.Log
import eu.kanade.tachiyomi.AppInfo
import eu.kanade.tachiyomi.animeextension.all.jellyfin.Jellyfin.Companion.APIKEY_KEY
import eu.kanade.tachiyomi.animeextension.all.jellyfin.Jellyfin.Companion.USERID_KEY
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.util.parseAs
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import okhttp3.Headers
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.RequestBody.Companion.toRequestBody
import uy.kohesive.injekt.injectLazy
class JellyfinAuthenticator(
private val preferences: SharedPreferences,
private val baseUrl: String,
private val client: OkHttpClient,
) {
private val json: Json by injectLazy()
fun login(username: String, password: String): Pair<String?, String?> {
return runCatching {
val authResult = authenticateWithPassword(username, password)
val key = authResult.accessToken
val userId = authResult.sessionInfo.userId
saveLogin(key, userId)
Pair(key, userId)
}.getOrElse {
Log.e(LOG_TAG, it.stackTraceToString())
Pair(null, null)
}
}
private fun authenticateWithPassword(username: String, password: String): LoginDto {
var deviceId = getPrefDeviceId()
if (deviceId.isNullOrEmpty()) {
deviceId = getRandomString()
setPrefDeviceId(deviceId)
}
val aniyomiVersion = AppInfo.getVersionName()
val androidVersion = Build.VERSION.RELEASE
val authHeader = Headers.headersOf(
"X-Emby-Authorization",
"MediaBrowser Client=\"$CLIENT\", Device=\"Android $androidVersion\", DeviceId=\"$deviceId\", Version=\"$aniyomiVersion\"",
)
val body = json.encodeToString(
buildJsonObject {
put("Username", username)
put("Pw", password)
},
).toRequestBody("application/json; charset=utf-8".toMediaType())
val request = POST("$baseUrl/Users/authenticatebyname", headers = authHeader, body = body)
return client.newCall(request).execute().parseAs()
}
private fun getRandomString(): String {
val allowedChars = ('A'..'Z') + ('a'..'z') + ('0'..'9')
return (1..172)
.map { allowedChars.random() }
.joinToString("")
}
private fun saveLogin(key: String, userId: String) {
preferences.edit()
.putString(APIKEY_KEY, key)
.putString(USERID_KEY, userId)
.apply()
}
private fun getPrefDeviceId(): String? = preferences.getString(
DEVICEID_KEY,
null,
)
private fun setPrefDeviceId(value: String) = preferences.edit().putString(
DEVICEID_KEY,
value,
).apply()
companion object {
private const val DEVICEID_KEY = "device_id"
private const val CLIENT = "Aniyomi"
private const val LOG_TAG = "JellyfinAuthenticator"
}
}

View file

@ -1,129 +0,0 @@
package eu.kanade.tachiyomi.animeextension.all.jellyfin
object JellyfinConstants {
val QUALITIES_LIST = arrayOf(
Quality(480, 360, 292000, 128000, "360p - 420 kbps"),
Quality(854, 480, 528000, 192000, "480p - 720 kbps"),
Quality(854, 480, 1308000, 192000, "480p - 1.5 Mbps"),
Quality(854, 480, 2808000, 192000, "480p - 3 Mbps"),
Quality(1280, 720, 3808000, 192000, "720p - 4 Mbps"),
Quality(1280, 720, 5808000, 192000, "720p - 6 Mbps"),
Quality(1280, 720, 7808000, 192000, "720p - 8 Mbps"),
Quality(1920, 1080, 9808000, 192000, "1080p - 10 Mbps"),
Quality(1920, 1080, 14808000, 192000, "1080p - 15 Mbps"),
Quality(1920, 1080, 19808000, 192000, "1080p - 20 Mbps"),
Quality(1920, 1080, 39808000, 192000, "1080p - 40 Mbps"),
Quality(1920, 1080, 59808000, 192000, "1080p - 60 Mbps"),
Quality(3840, 2160, 80000000, 192000, "4K - 80 Mbps"),
Quality(3840, 2160, 120000000, 192000, "4K - 120 Mbps"),
)
data class Quality(
val width: Int,
val height: Int,
val videoBitrate: Int,
val audioBitrate: Int,
val description: String,
)
val PREF_VALUES = arrayOf(
"aar", "abk", "ace", "ach", "ada", "ady", "afh", "afr", "ain", "aka", "akk", "ale", "alt", "amh", "ang", "anp", "apa",
"ara", "arc", "arg", "arn", "arp", "arw", "asm", "ast", "ath", "ava", "ave", "awa", "aym", "aze", "bai", "bak", "bal",
"bam", "ban", "bas", "bej", "bel", "bem", "ben", "ber", "bho", "bik", "bin", "bis", "bla", "bod", "bos", "bra", "bre",
"bua", "bug", "bul", "byn", "cad", "car", "cat", "ceb", "ces", "cha", "chb", "che", "chg", "chk", "chm", "chn", "cho",
"chp", "chr", "chu", "chv", "chy", "cnr", "cop", "cor", "cos", "cre", "crh", "csb", "cym", "dak", "dan", "dar", "del",
"den", "deu", "dgr", "din", "div", "doi", "dsb", "dua", "dum", "dyu", "dzo", "efi", "egy", "eka", "ell", "elx", "eng",
"enm", "epo", "est", "eus", "ewe", "ewo", "fan", "fao", "fas", "fat", "fij", "fil", "fin", "fiu", "fon", "fra", "frm",
"fro", "frr", "frs", "fry", "ful", "fur", "gaa", "gay", "gba", "gez", "gil", "gla", "gle", "glg", "glv", "gmh", "goh",
"gon", "gor", "got", "grb", "grc", "grn", "gsw", "guj", "gwi", "hai", "hat", "hau", "haw", "heb", "her", "hil", "hin",
"hit", "hmn", "hmo", "hrv", "hsb", "hun", "hup", "hye", "iba", "ibo", "ido", "iii", "ijo", "iku", "ile", "ilo", "ina",
"inc", "ind", "inh", "ipk", "isl", "ita", "jav", "jbo", "jpn", "jpr", "jrb", "kaa", "kab", "kac", "kal", "kam", "kan",
"kar", "kas", "kat", "kau", "kaw", "kaz", "kbd", "kha", "khm", "kho", "kik", "kin", "kir", "kmb", "kok", "kom", "kon",
"kor", "kos", "kpe", "krc", "krl", "kru", "kua", "kum", "kur", "kut", "lad", "lah", "lam", "lao", "lat", "lav", "lez",
"lim", "lin", "lit", "lol", "loz", "ltz", "lua", "lub", "lug", "lui", "lun", "luo", "lus", "mad", "mag", "mah", "mai",
"mak", "mal", "man", "mar", "mas", "mdf", "mdr", "men", "mga", "mic", "min", "mkd", "mkh", "mlg", "mlt", "mnc", "mni",
"moh", "mon", "mos", "mri", "msa", "mus", "mwl", "mwr", "mya", "myv", "nah", "nap", "nau", "nav", "nbl", "nde", "ndo",
"nds", "nep", "new", "nia", "nic", "niu", "nld", "nno", "nob", "nog", "non", "nor", "nqo", "nso", "nub", "nwc", "nya",
"nym", "nyn", "nyo", "nzi", "oci", "oji", "ori", "orm", "osa", "oss", "ota", "oto", "pag", "pal", "pam", "pan", "pap",
"pau", "peo", "phn", "pli", "pol", "pon", "por", "pro", "pus", "que", "raj", "rap", "rar", "roh", "rom", "ron", "run",
"rup", "rus", "sad", "sag", "sah", "sam", "san", "sas", "sat", "scn", "sco", "sel", "sga", "shn", "sid", "sin", "slk",
"slv", "sma", "sme", "smj", "smn", "smo", "sms", "sna", "snd", "snk", "sog", "som", "son", "sot", "spa", "sqi", "srd",
"srn", "srp", "srr", "ssw", "suk", "sun", "sus", "sux", "swa", "swe", "syc", "syr", "tah", "tai", "tam", "tat", "tel",
"tem", "ter", "tet", "tgk", "tgl", "tha", "tig", "tir", "tiv", "tkl", "tlh", "tli", "tmh", "tog", "ton", "tpi", "tsi",
"tsn", "tso", "tuk", "tum", "tup", "tur", "tvl", "twi", "tyv", "udm", "uga", "uig", "ukr", "umb", "urd", "uzb", "vai",
"ven", "vie", "vol", "vot", "wal", "war", "was", "wen", "wln", "wol", "xal", "xho", "yao", "yap", "yid", "yor", "zap",
"zbl", "zen", "zgh", "zha", "zho", "zul", "zun", "zza",
)
val PREF_ENTRIES = arrayOf(
"Qafaraf; Afar Af; Afaraf; Qafar af", "Аҧсуа бызшәа Aƥsua bızšwa; Аҧсшәа Aƥsua", "بهسا اچيه", "Lwo", "Dangme",
"Адыгабзэ; Кӏахыбзэ", "El-Afrihili", "Afrikaans", "アイヌ・イタㇰ Ainu-itak", "Akan", "𒀝𒅗𒁺𒌑", "Уна́ӈам тунуу́; Унаӈан умсуу",
"Алтай тили", "አማርኛ Amârıñâ", "Ænglisc; Anglisc; Englisc", "Angika", "Apache languages", "العَرَبِيَّة al'Arabiyyeẗ",
"Official Aramaic (700300 BCE); Imperial Aramaic (700300 BCE)", "aragonés", "Mapudungun; Mapuche", "Hinónoʼeitíít",
"Lokono", "অসমীয়া", "Asturianu; Llïonés", "Athapascan languages", "Магӏарул мацӏ; Авар мацӏ", "Avestan", "अवधी",
"Aymar aru", "Azərbaycan dili; آذربایجان دیلی; Азәрбајҹан дили", "Bamiléké", "Башҡорт теле; Başqort tele",
"بلوچی", "ߓߊߡߊߣߊߣߞߊߣ", "ᬪᬵᬱᬩᬮᬶ; ᬩᬲᬩᬮᬶ; Basa Bali", "Mbene; Ɓasaá", "Bidhaawyeet", "Беларуская мова Belaruskaâ mova",
"Chibemba", "বাংলা Bāŋlā", "Tamaziɣt; Tamazight; ⵜⴰⵎⴰⵣⵉⵖⵜ; ⵝⴰⵎⴰⵣⵉⵗⵝ; ⵜⴰⵎⴰⵣⵉⵗⵜ", "भोजपुरी", "Bikol", "Ẹ̀dó",
"Bislama", "ᓱᖽᐧᖿ", "བོད་སྐད་ Bodskad; ལྷ་སའི་སྐད་ Lhas'iskad", "bosanski", "Braj", "Brezhoneg", "буряад хэлэн",
"ᨅᨔ ᨕᨘᨁᨗ", "български език bălgarski ezik", "ብሊና; ብሊን", "Hasí:nay", "Kari'nja", "català,valencià", "Sinugbuanong Binisayâ",
"čeština; český jazyk", "Finu' Chamoru", "Muysccubun", "Нохчийн мотт; نَاخچیین موٓتت; ნახჩიე მუოთთ", "جغتای",
"Chuukese", "марий йылме", "chinuk wawa; wawa; chinook lelang; lelang", "Chahta'", "ᑌᓀᓱᒼᕄᓀ (Dënesųłiné)",
"ᏣᎳᎩ ᎦᏬᏂᎯᏍᏗ Tsalagi gawonihisdi", "Славе́нскїй ѧ҆зы́къ", "Чӑвашла", "Tsėhésenėstsestȯtse", "crnogorski / црногорски",
"ϯⲙⲉⲑⲣⲉⲙⲛ̀ⲭⲏⲙⲓ; ⲧⲙⲛ̄ⲧⲣⲙ̄ⲛ̄ⲕⲏⲙⲉ", "Kernowek", "Corsu; Lingua corsa", "Cree", "Къырымтатарджа; Къырымтатар тили; Ҡырымтатарҗа; Ҡырымтатар тили",
"Kaszëbsczi jãzëk", "Cymraeg; y Gymraeg", "Dakhótiyapi; Dakȟótiyapi", "dansk", "дарган мез", "Delaware", "Dene K'e",
"Deutsch", "Dogrib", "Thuɔŋjäŋ", "ދިވެހި; ދިވެހިބަސް Divehi", "𑠖𑠵𑠌𑠤𑠮; डोगरी; ڈوگرى", "Dolnoserbski; Dolnoserbšćina",
"Duala", "Dutch, Middle (ca. 10501350)", "Julakan", "རྫོང་ཁ་ Ĵoŋkha", "Efik", "Egyptian (Ancient)", "Ekajuk",
"Νέα Ελληνικά Néa Ellêniká", "Elamite", "English", "English, Middle (11001500)", "Esperanto", "eesti keel",
"euskara", "Èʋegbe", "Ewondo", "Fang", "føroyskt", "فارسی Fārsiy", "Mfantse; Fante; Fanti", "Na Vosa Vakaviti",
"Wikang Filipino", "suomen kieli", "Finno-Ugrian languages", "Fon gbè", "français", "françois; franceis", "Franceis; François; Romanz",
"Frasch; Fresk; Freesk; Friisk", "Oostfreesk; Plattdüütsk", "Frysk", "Fulfulde; Pulaar; Pular", "Furlan",
"", "Basa Gayo", "Gbaya", "ግዕዝ", "Taetae ni Kiribati", "Gàidhlig", "Gaeilge", "galego", "Gaelg; Gailck", "Diutsch",
"Diutisk", "Gondi", "Bahasa Hulontalo", "Gothic", "Grebo", "Ἑλληνική", "Avañe'ẽ", "Schwiizerdütsch", "ગુજરાતી Gujarātī",
"Dinjii Zhu Ginjik", "X̱aat Kíl; X̱aadas Kíl; X̱aayda Kil; Xaad kil", "kreyòl ayisyen", "Harshen Hausa; هَرْشَن",
"ʻŌlelo Hawaiʻi", "עברית 'Ivriyþ", "Otjiherero", "Ilonggo", "हिन्दी Hindī", "𒉈𒅆𒇷", "lus Hmoob; lug Moob; lol Hmongb; 𖬇𖬰𖬞 𖬌𖬣𖬵",
"Hiri Motu", "hrvatski", "hornjoserbšćina", "magyar nyelv", "Na:tinixwe Mixine:whe'", "Հայերէն Hayerèn; Հայերեն Hayeren",
"Jaku Iban", "Asụsụ Igbo", "Ido", "ꆈꌠꉙ Nuosuhxop", "Ịjọ", "ᐃᓄᒃᑎᑐᑦ Inuktitut", "Interlingue; Occidental", "Pagsasao nga Ilokano; Ilokano",
"Interlingua (International Auxiliary Language Association)", "Indo-Aryan languages", "bahasa Indonesia",
"ГӀалгӀай мотт", "Iñupiaq", "íslenska", "italiano; lingua italiana", "ꦧꦱꦗꦮ / Basa Jawa", "la .lojban.", "日本語 Nihongo",
"Dzhidi", "عربية يهودية / ערבית יהודית", "Qaraqalpaq tili; Қарақалпақ тили", "Tamaziɣt Taqbaylit; Tazwawt",
"Jingpho", "Kalaallisut; Greenlandic", "Kamba", "ಕನ್ನಡ Kannađa", "Karen languages", "कॉशुर / كأشُر", "ქართული Kharthuli",
"Kanuri", "ꦧꦱꦗꦮ", "қазақ тілі qazaq tili; қазақша qazaqşa", "Адыгэбзэ (Къэбэрдейбзэ) Adıgăbză (Qăbărdeĭbză)",
"কা কতিয়েন খাশি", "ភាសាខ្មែរ Phiəsaakhmær", "Khotanese; Sakan", "Gĩkũyũ", "Ikinyarwanda", "кыргызча kırgızça; кыргыз тили kırgız tili",
"Kimbundu", "कोंकणी", "Коми кыв", "Kongo", "한국어 Han'gug'ô", "Kosraean", "Kpɛlɛwoo", "Къарачай-Малкъар тил; Таулу тил",
"karjal; kariela; karjala", "कुड़ुख़", "Kuanyama; Kwanyama", "къумукъ тил/qumuq til", "kurdî / کوردی", "Kutenai",
"Judeo-español", "بھارت کا", "Lamba", "ພາສາລາວ Phasalaw", "Lingua latīna", "Latviešu valoda", "Лезги чӏал",
"Lèmburgs", "Lingala", "lietuvių kalba", "Lomongo", "Lozi", "Lëtzebuergesch", "Cilubà / Tshiluba", "Kiluba",
"Luganda", "Cham'teela", "Chilunda", "Dholuo", "Mizo ṭawng", "Madhura", "मगही", "Kajin M̧ajeļ", "मैथिली; মৈথিলী",
"Basa Mangkasara' / ᨅᨔ ᨆᨀᨔᨑ", "മലയാളം Malayāļã", "Mandi'nka kango", "मराठी Marāţhī", "ɔl", "мокшень кяль",
"Mandar", "Mɛnde yia", "Gaoidhealg", "Míkmawísimk", "Baso Minang", "македонски јазик makedonski jazik", "Mon-Khmer languages",
"Malagasy", "Malti", "ᠮᠠᠨᠵᡠ ᡤᡳᠰᡠᠨ Manju gisun", "Manipuri", "Kanienkéha", "монгол хэл mongol xel; ᠮᠣᠩᠭᠣᠯ ᠬᠡᠯᠡ",
"Mooré", "Te Reo Māori", "Bahasa Melayu", "Mvskoke", "mirandés; lhéngua mirandesa", "मारवाड़ी", "မြန်မာစာ Mrãmācā; မြန်မာစကား Mrãmākā:",
"эрзянь кель", "Nahuatl languages", "napulitano", "dorerin Naoero", "Diné bizaad; Naabeehó bizaad", "isiNdebele seSewula",
"siNdebele saseNyakatho", "ndonga", "Plattdütsch; Plattdüütsch", "नेपाली भाषा Nepālī bhāśā", "नेपाल भाषा; नेवाः भाय्",
"Li Niha", "Niger-Kordofanian languages", "ko e vagahau Niuē", "Nederlands; Vlaams", "norsk nynorsk", "norsk bokmål",
"Ногай тили", "Dǫnsk tunga; Norrœnt mál", "norsk", "N'Ko", "Sesotho sa Leboa", "لغات نوبية", "पुलां भाय्; पुलाङु नेपाल भाय्",
"Chichewa; Chinyanja", "Nyamwezi", "Nyankole", "Runyoro", "Nzima", "occitan; lenga d'òc", "Ojibwa", "ଓଡ଼ିଆ",
"Afaan Oromoo", "Wazhazhe ie / 𐓏𐓘𐓻𐓘𐓻𐓟 𐒻𐓟", "Ирон ӕвзаг Iron ævzag", "لسان عثمانى / lisân-ı Osmânî", "Otomian languages",
"Salitan Pangasinan", "Pārsīk; Pārsīg", "Amánung Kapampangan; Amánung Sísuan", "ਪੰਜਾਬੀ / پنجابی Pãjābī",
"Papiamentu", "a tekoi er a Belau", "Persian, Old (ca. 600400 B.C.)", "𐤃𐤁𐤓𐤉𐤌 𐤊𐤍𐤏𐤍𐤉𐤌 Dabariym Kana'aniym",
"Pāli", "Język polski", "Pohnpeian", "português", "Provençal, Old (to 1500); Old Occitan (to 1500)", "پښتو Pax̌tow",
"Runa simi; kichwa simi; Nuna shimi", "राजस्थानी", "Vananga rapa nui", "Māori Kūki 'Āirani", "Rumantsch; Rumàntsch; Romauntsch; Romontsch",
"romani čhib", "limba română", "Ikirundi", "armãneashce; armãneashti; rrãmãneshti", "русский язык russkiĭ âzık",
"Sandaweeki", "yângâ tî sängö", "Сахалыы", "ארמית", "संस्कृतम् Sąskŕtam; 𑌸𑌂𑌸𑍍𑌕𑍃𑌤𑌮𑍍", "Sasak", "ᱥᱟᱱᱛᱟᱲᱤ", "Sicilianu",
"Braid Scots; Lallans", "Selkup", "Goídelc", "ၵႂၢမ်းတႆးယႂ်", "Sidaamu Afoo", "සිංහල Sĩhala", "slovenčina; slovenský jazyk",
"slovenski jezik; slovenščina", "Åarjelsaemien gïele", "davvisámegiella", "julevsámegiella", "anarâškielâ",
"Gagana faʻa Sāmoa", "sääʹmǩiõll", "chiShona", "سنڌي / सिन्धी / ਸਿੰਧੀ", "Sooninkanxanne", "Sogdian", "af Soomaali",
"Songhai languages", "Sesotho [southern]", "español; castellano", "Shqip", "sardu; limba sarda; lingua sarda",
"Sranan Tongo", "српски / srpski", "Seereer", "siSwati", "Kɪsukuma", "ᮘᮞ ᮞᮥᮔ᮪ᮓ / Basa Sunda", "Sosoxui", "𒅴𒂠",
"Kiswahili", "svenska", "Classical Syriac", "ܠܫܢܐ ܣܘܪܝܝܐ Lešānā Suryāyā", "Reo Tahiti; Reo Mā'ohi", "ภาษาไท; ภาษาไต",
"தமிழ் Tamił", "татар теле / tatar tele / تاتار", "తెలుగు Telugu", "KʌThemnɛ", "Terêna", "Lia-Tetun", "тоҷикӣ toçikī",
"Wikang Tagalog", "ภาษาไทย Phasathay", "ትግረ; ትግሬ; ኻሳ; ትግራይት", "ትግርኛ", "Tiv", "Tokelau", "Klingon; tlhIngan-Hol",
"Lingít", "Tamashek", "chiTonga", "lea faka-Tonga", "Tok Pisin", "Tsimshian", "Setswana", "Xitsonga", "Türkmençe / Түркменче / تورکمن تیلی تورکمنچ; türkmen dili / түркмен дили",
"chiTumbuka", "Tupi languages", "Türkçe", "Te Ggana Tuuvalu; Te Gagana Tuuvalu", "Twi", "тыва дыл", "удмурт кыл",
"Ugaritic", "ئۇيغۇرچە ; ئۇيغۇر تىلى", "Українська мова; Українська", "Úmbúndú", "اُردُو Urduw", "Oʻzbekcha / Ózbekça / ўзбекча / ئوزبېچه; oʻzbek tili / ўзбек тили / ئوبېک تیلی",
"ꕙꔤ", "Tshivenḓa", "Tiếng Việt", "Volapük", "vađđa ceeli", "Wolaitta; Wolaytta", "Winaray; Samareño; Lineyte-Samarnon; Binisayâ nga Winaray; Binisayâ nga Samar-Leyte; “Binisayâ nga Waray”",
"wá:šiw ʔítlu", "Serbsce / Serbski", "Walon", "Wolof", "Хальмг келн / Xaľmg keln", "isiXhosa", "Yao", "Yapese",
"ייִדיש; יידיש; אידיש Yidiš", "èdè Yorùbá", "Diidxazá/Dizhsa", "Blissymbols; Blissymbolics; Bliss", "Tuḍḍungiyya",
"ⵜⴰⵎⴰⵣⵉⵖⵜ ⵜⴰⵏⴰⵡⴰⵢⵜ", "Vahcuengh / 話僮", "中文 Zhōngwén; 汉语; 漢語 Hànyǔ", "isiZulu", "Shiwi'ma", "kirmanckî; dimilkî; kirdkî; zazakî",
)
}

View file

@ -1,234 +0,0 @@
package eu.kanade.tachiyomi.animeextension.all.jellyfin
import eu.kanade.tachiyomi.animeextension.all.jellyfin.Jellyfin.EpisodeType
import eu.kanade.tachiyomi.animesource.model.SAnime
import eu.kanade.tachiyomi.animesource.model.SEpisode
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.jsoup.Jsoup
import java.text.SimpleDateFormat
import java.util.Locale
@Serializable
data class LoginDto(
@SerialName("AccessToken") val accessToken: String,
@SerialName("SessionInfo") val sessionInfo: LoginSessionDto,
) {
@Serializable
data class LoginSessionDto(
@SerialName("UserId") val userId: String,
)
}
@Serializable
data class ItemsDto(
@SerialName("Items") val items: List<ItemDto>,
@SerialName("TotalRecordCount") val itemCount: Int,
)
@Serializable
data class ItemDto(
@SerialName("Name") val name: String,
@SerialName("Type") val type: String,
@SerialName("Id") val id: String,
@SerialName("LocationType") val locationType: String,
@SerialName("ImageTags") val imageTags: ImageDto,
@SerialName("SeriesId") val seriesId: String? = null,
@SerialName("SeriesName") val seriesName: String? = null,
// Details
@SerialName("Overview") val overview: String? = null,
@SerialName("Genres") val genres: List<String>? = null,
@SerialName("Studios") val studios: List<StudioDto>? = null,
// Only for series, not season
@SerialName("Status") val seriesStatus: String? = null,
@SerialName("SeasonName") val seasonName: String? = null,
// Episode
@SerialName("PremiereDate") val premiereData: String? = null,
@SerialName("RunTimeTicks") val runTime: Long? = null,
@SerialName("MediaSources") val mediaSources: List<MediaDto>? = null,
@SerialName("IndexNumber") val indexNumber: Int? = null,
) {
@Serializable
data class ImageDto(
@SerialName("Primary") val primary: String? = null,
)
@Serializable
data class StudioDto(
@SerialName("Name") val name: String,
)
fun toSAnime(baseUrl: String, userId: String, apiKey: String): SAnime = SAnime.create().apply {
val httpUrl = baseUrl.toHttpUrl().newBuilder().apply {
addPathSegment("Users")
addPathSegment(userId)
addPathSegment("Items")
addPathSegment(id)
addQueryParameter("api_key", apiKey)
}
thumbnail_url = "$baseUrl/Items/$id/Images/Primary?api_key=$apiKey"
when (type) {
"Season" -> {
// To prevent one extra GET request when fetching episodes
httpUrl.fragment("seriesId,${seriesId!!}")
if (locationType == "Virtual") {
title = seriesName!!
thumbnail_url = "$baseUrl/Items/$seriesId/Images/Primary?api_key=$apiKey"
} else {
title = "$seriesName $name"
}
// Use series as fallback
if (imageTags.primary == null) {
thumbnail_url = "$baseUrl/Items/$seriesId/Images/Primary?api_key=$apiKey"
}
}
"Movie" -> {
httpUrl.fragment("movie")
title = name
}
"BoxSet" -> {
httpUrl.fragment("boxSet")
title = name
}
"Series" -> {
httpUrl.fragment("series")
title = name
}
}
url = httpUrl.build().toString()
// Details
description = overview?.let {
Jsoup.parseBodyFragment(
it.replace("<br>", "br2n"),
).text().replace("br2n", "\n")
}
genre = genres?.joinToString(", ")
author = studios?.joinToString(", ") { it.name }
if (type == "Movie") {
status = SAnime.COMPLETED
} else {
status = seriesStatus.parseStatus()
}
}
private fun String?.parseStatus(): Int = when (this) {
"Ended" -> SAnime.COMPLETED
"Continuing" -> SAnime.ONGOING
else -> SAnime.UNKNOWN
}
// ============================== Episodes ==============================
fun toSEpisode(
baseUrl: String,
userId: String,
apiKey: String,
epDetails: Set<String>,
epType: EpisodeType,
prefix: String,
): SEpisode = SEpisode.create().apply {
when (epType) {
EpisodeType.MOVIE -> {
episode_number = 1F
name = "${prefix}Movie"
}
EpisodeType.EPISODE -> {
episode_number = indexNumber?.toFloat() ?: 1F
name = "${prefix}Ep. $indexNumber - ${this@ItemDto.name}"
}
}
val extraInfo = buildList {
if (epDetails.contains("Overview") && overview != null && epType == EpisodeType.EPISODE) {
add(overview)
}
if (epDetails.contains("Size") && mediaSources != null) {
mediaSources.first().size?.also {
add(it.formatBytes())
}
}
if (epDetails.contains("Runtime") && runTime != null) {
add(runTime.formatTicks())
}
}
scanlator = extraInfo.joinToString("")
premiereData?.also {
date_upload = parseDate(it.removeSuffix("Z"))
}
url = "$baseUrl/Users/$userId/Items/$id?api_key=$apiKey"
}
private fun Long.formatBytes(): String = when {
this >= 1_000_000_000 -> "%.2f GB".format(this / 1_000_000_000.0)
this >= 1_000_000 -> "%.2f MB".format(this / 1_000_000.0)
this >= 1_000 -> "%.2f KB".format(this / 1_000.0)
this > 1 -> "$this bytes"
this == 1L -> "$this byte"
else -> ""
}
private fun Long.formatTicks(): String {
val seconds = this / 10_000_000
val minutes = seconds / 60
val hours = minutes / 60
val remainingSeconds = seconds % 60
val remainingMinutes = minutes % 60
val formattedHours = if (hours > 0) "${hours}h " else ""
val formattedMinutes = if (remainingMinutes > 0) "${remainingMinutes}m " else ""
val formattedSeconds = "${remainingSeconds}s"
return "$formattedHours$formattedMinutes$formattedSeconds".trim()
}
private fun parseDate(dateStr: String): Long {
return runCatching { DATE_FORMATTER.parse(dateStr)?.time }
.getOrNull() ?: 0L
}
companion object {
private val DATE_FORMATTER by lazy {
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSSSSS", Locale.ENGLISH)
}
}
}
@Serializable
data class SessionDto(
@SerialName("MediaSources") val mediaSources: List<MediaDto>,
@SerialName("PlaySessionId") val playSessionId: String,
)
@Serializable
data class MediaDto(
@SerialName("Size") val size: Long? = null,
@SerialName("MediaStreams") val mediaStreams: List<MediaStreamDto>,
) {
@Serializable
data class MediaStreamDto(
@SerialName("Codec") val codec: String,
@SerialName("Index") val index: Int,
@SerialName("Type") val type: String,
@SerialName("SupportsExternalStream") val supportsExternalStream: Boolean,
@SerialName("IsExternal") val isExternal: Boolean,
@SerialName("Language") val lang: String? = null,
@SerialName("DisplayTitle") val displayTitle: String? = null,
@SerialName("Height") val height: Int? = null,
@SerialName("Width") val width: Int? = null,
)
}

View file

@ -1,18 +0,0 @@
package eu.kanade.tachiyomi.animeextension.all.jellyfin
import eu.kanade.tachiyomi.animesource.AnimeSource
import eu.kanade.tachiyomi.animesource.AnimeSourceFactory
class JellyfinFactory : AnimeSourceFactory {
override fun createSources(): List<AnimeSource> {
val firstJelly = Jellyfin("1")
val extraCount = firstJelly.preferences.getString(Jellyfin.EXTRA_SOURCES_COUNT_KEY, Jellyfin.EXTRA_SOURCES_COUNT_DEFAULT)!!.toInt()
return buildList(extraCount) {
add(firstJelly)
for (i in 2..extraCount) {
add(Jellyfin("$i"))
}
}
}
}

View file

@ -1,7 +0,0 @@
ext {
extName = 'AnimeFlix'
extClass = '.AnimeFlix'
extVersionCode = 7
}
apply from: "$rootDir/common.gradle"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

View file

@ -1,398 +0,0 @@
package eu.kanade.tachiyomi.animeextension.en.animeflix
import android.app.Application
import android.util.Base64
import androidx.preference.ListPreference
import androidx.preference.PreferenceScreen
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.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.network.GET
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.util.asJsoup
import eu.kanade.tachiyomi.util.parallelCatchingFlatMap
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.MultipartBody
import okhttp3.Request
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
class AnimeFlix : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
override val name = "AnimeFlix"
override val baseUrl = "https://animeflix.mobi"
override val lang = "en"
override val supportsLatest = true
private val json: Json by injectLazy()
private val preferences by lazy {
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
}
// ============================== Popular ===============================
override fun popularAnimeRequest(page: Int) = GET("$baseUrl/page/$page/")
override fun popularAnimeSelector() = "div#content_box > div.post-cards > article"
override fun popularAnimeFromElement(element: Element) = SAnime.create().apply {
setUrlWithoutDomain(element.selectFirst("a")!!.attr("href"))
// prevent base64 images
thumbnail_url = element.selectFirst("img")!!.run {
attr("data-pagespeed-high-res-src").ifEmpty { attr("src") }
}
title = element.selectFirst("header")!!.text()
}
override fun popularAnimeNextPageSelector() = "div.nav-links > a.next"
// =============================== Latest ===============================
override fun latestUpdatesRequest(page: Int): Request = GET("$baseUrl/latest-release/page/$page/")
override fun latestUpdatesSelector(): String = popularAnimeSelector()
override fun latestUpdatesFromElement(element: Element): SAnime = popularAnimeFromElement(element)
override fun latestUpdatesNextPageSelector(): String = popularAnimeNextPageSelector()
// =============================== Search ===============================
override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request {
val cleanQuery = query.replace(" ", "+").lowercase()
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() -> GET("$baseUrl/page/$page/?s=$cleanQuery", headers = headers)
genreFilter.state != 0 -> GET("$baseUrl/genre/${genreFilter.toUriPart()}/page/$page/", headers = headers)
subpageFilter.state != 0 -> GET("$baseUrl/${subpageFilter.toUriPart()}/page/$page/", headers = headers)
else -> popularAnimeRequest(page)
}
}
override fun searchAnimeSelector(): String = popularAnimeSelector()
override fun searchAnimeFromElement(element: Element): SAnime = popularAnimeFromElement(element)
override fun searchAnimeNextPageSelector(): String = popularAnimeNextPageSelector()
// ============================== Filters ===============================
override fun getFilterList(): AnimeFilterList = AnimeFilterList(
AnimeFilter.Header("Text search ignores filters"),
GenreFilter(),
SubPageFilter(),
)
private class GenreFilter : UriPartFilter(
"Genres",
arrayOf(
Pair("<select>", ""),
Pair("Action", "action"),
Pair("Adventure", "adventure"),
Pair("Isekai", "isekai"),
Pair("Drama", "drama"),
Pair("Psychological", "psychological"),
Pair("Ecchi", "ecchi"),
Pair("Sci-Fi", "sci-fi"),
Pair("Magic", "magic"),
Pair("Slice Of Life", "slice-of-life"),
Pair("Sports", "sports"),
Pair("Comedy", "comedy"),
Pair("Fantasy", "fantasy"),
Pair("Horror", "horror"),
Pair("Yaoi", "yaoi"),
),
)
private class SubPageFilter : UriPartFilter(
"Sub-page",
arrayOf(
Pair("<select>", ""),
Pair("Ongoing", "ongoing"),
Pair("Latest Release", "latest-release"),
Pair("Movies", "movies"),
),
)
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.create().apply {
title = document.selectFirst("div.single_post > header > h1")!!.text()
thumbnail_url = document.selectFirst("img.imdbwp__img")?.attr("src")
val infosDiv = document.selectFirst("div.thecontent h3:contains(Anime Info) ~ ul")!!
status = when (infosDiv.getInfo("Status").toString()) {
"Completed" -> SAnime.COMPLETED
"Currently Airing" -> SAnime.ONGOING
else -> SAnime.UNKNOWN
}
artist = infosDiv.getInfo("Studios")
author = infosDiv.getInfo("Producers")
genre = infosDiv.getInfo("Genres")
val animeInfo = infosDiv.select("li").joinToString("\n") { it.text() }
description = document.select("div.thecontent h3:contains(Summary) ~ p:not(:has(*)):not(:empty)")
.joinToString("\n\n") { it.ownText() } + "\n\n$animeInfo"
}
private fun Element.getInfo(info: String) =
selectFirst("li:contains($info)")?.ownText()?.trim()
// ============================== Episodes ==============================
val seasonRegex by lazy { Regex("""season (\d+)""", RegexOption.IGNORE_CASE) }
val qualityRegex by lazy { """(\d+)p""".toRegex() }
override suspend fun getEpisodeList(anime: SAnime): List<SEpisode> {
val document = client.newCall(GET(baseUrl + anime.url)).execute()
.asJsoup()
val seasonList = document.select("div.inline > h3:contains(Season),div.thecontent > h3:contains(Season)")
val episodeList = if (seasonList.distinctBy { seasonRegex.find(it.text())!!.groupValues[1] }.size > 1) {
val seasonsLinks = document.select("div.thecontent p:has(span:contains(Gdrive))").groupBy {
seasonRegex.find(it.previousElementSibling()!!.text())!!.groupValues[1]
}
seasonsLinks.flatMap { (seasonNumber, season) ->
val serverListSeason = season.map {
val previousText = it.previousElementSibling()!!.text()
val quality = qualityRegex.find(previousText)?.groupValues?.get(1) ?: "Unknown quality"
val url = it.selectFirst("a")!!.attr("href")
val episodesDocument = client.newCall(GET(url)).execute()
.asJsoup()
episodesDocument.select("div.entry-content > h3 > a").map {
EpUrl(quality, it.attr("href"), "Season $seasonNumber ${it.text()}")
}
}
transposeEpisodes(serverListSeason)
}
} else {
val driveList = document.select("div.thecontent p:has(span:contains(Gdrive))").map {
val quality = qualityRegex.find(it.previousElementSibling()!!.text())?.groupValues?.get(1) ?: "Unknown quality"
Pair(it.selectFirst("a")!!.attr("href"), quality)
}
// Load episodes
val serversList = driveList.map { drive ->
val episodesDocument = client.newCall(GET(drive.first)).execute()
.asJsoup()
episodesDocument.select("div.entry-content > h3 > a").map {
EpUrl(drive.second, it.attr("href"), it.text())
}
}
transposeEpisodes(serversList)
}
return episodeList.reversed()
}
private fun transposeEpisodes(serversList: List<List<EpUrl>>) =
transpose(serversList).mapIndexed { index, serverList ->
SEpisode.create().apply {
name = serverList.first().name
episode_number = (index + 1).toFloat()
setUrlWithoutDomain(json.encodeToString(serverList))
}
}
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 urls = json.decodeFromString<List<EpUrl>>(episode.url)
val leechUrls = urls.map {
val firstLeech = client.newCall(GET(it.url)).execute()
.asJsoup()
.selectFirst("script:containsData(downlaod_button)")!!
.data()
.substringAfter("<a href=\"")
.substringBefore("\">")
val path = client.newCall(GET(firstLeech)).execute()
.body.string()
.substringAfter("replace(\"")
.substringBefore("\"")
val link = "https://" + firstLeech.toHttpUrl().host + path
EpUrl(it.quality, link, it.name)
}
val videoList = leechUrls.parallelCatchingFlatMap { url ->
if (url.url.toHttpUrl().encodedPath == "/404") return@parallelCatchingFlatMap emptyList()
val (videos, mediaUrl) = extractVideo(url)
when {
videos.isEmpty() -> {
extractGDriveLink(mediaUrl, url.quality).ifEmpty {
getDirectLink(mediaUrl, "instant", "/mfile/")?.let {
listOf(Video(it, "${url.quality}p - GDrive Instant link", it))
} ?: emptyList()
}
}
else -> videos
}
}
return videoList.sort()
}
override fun videoFromElement(element: Element): Video = throw UnsupportedOperationException()
override fun videoListSelector(): String = throw UnsupportedOperationException()
override fun videoUrlParse(document: Document): String = throw UnsupportedOperationException()
// ============================= Utilities ==============================
// https://github.com/aniyomiorg/aniyomi-extensions/blob/master/src/en/uhdmovies/src/eu/kanade/tachiyomi/animeextension/en/uhdmovies/UHDMovies.kt
private fun extractVideo(epUrl: EpUrl): Pair<List<Video>, String> {
val matchResult = qualityRegex.find(epUrl.name)
val quality = matchResult?.groupValues?.get(1) ?: epUrl.quality
return (1..3).toList().flatMap { type ->
extractWorkerLinks(epUrl.url, quality, type)
}.let { Pair(it, epUrl.url) }
}
private fun extractWorkerLinks(mediaUrl: String, quality: String, type: Int): List<Video> {
val reqLink = mediaUrl.replace("/file/", "/wfile/") + "?type=$type"
val resp = client.newCall(GET(reqLink)).execute().asJsoup()
val sizeMatch = SIZE_REGEX.find(resp.select("div.card-header").text().trim())
val size = sizeMatch?.groups?.get(1)?.value?.let { " - $it" } ?: ""
return resp.select("div.card-body div.mb-4 > a").mapIndexed { index, linkElement ->
val link = linkElement.attr("href")
val decodedLink = if (link.contains("workers.dev")) {
link
} else {
String(Base64.decode(link.substringAfter("download?url="), Base64.DEFAULT))
}
Video(
url = decodedLink,
quality = "${quality}p - CF $type Worker ${index + 1}$size",
videoUrl = decodedLink,
)
}
}
private fun getDirectLink(url: String, action: String = "direct", newPath: String = "/file/"): String? {
val doc = client.newCall(GET(url, headers)).execute().asJsoup()
val script = doc.selectFirst("script:containsData(async function taskaction)")
?.data()
?: return url
val key = script.substringAfter("key\", \"").substringBefore('"')
val form = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart("action", action)
.addFormDataPart("key", key)
.addFormDataPart("action_token", "")
.build()
val headers = headersBuilder().set("x-token", url.toHttpUrl().host).build()
val req = client.newCall(POST(url.replace("/file/", newPath), headers, form)).execute()
return runCatching {
json.decodeFromString<DriveLeechDirect>(req.body.string()).url
}.getOrNull()
}
private fun extractGDriveLink(mediaUrl: String, quality: String): List<Video> {
val neoUrl = getDirectLink(mediaUrl) ?: mediaUrl
val response = client.newCall(GET(neoUrl)).execute().asJsoup()
val gdBtn = response.selectFirst("div.card-body a.btn")!!
val gdLink = gdBtn.attr("href")
val sizeMatch = SIZE_REGEX.find(gdBtn.text())
val size = sizeMatch?.groups?.get(1)?.value?.let { " - $it" } ?: ""
val gdResponse = client.newCall(GET(gdLink)).execute().asJsoup()
val link = gdResponse.select("form#download-form")
return if (link.isNullOrEmpty()) {
emptyList()
} else {
val realLink = link.attr("action")
listOf(Video(realLink, "$quality - Gdrive$size", realLink))
}
}
override fun List<Video>.sort(): List<Video> {
val quality = preferences.getString(PREF_QUALITY_KEY, PREF_QUALITY_DEFAULT)!!
return sortedWith(
compareBy { it.quality.contains(quality) },
).reversed()
}
private fun <E> transpose(xs: List<List<E>>): List<List<E>> {
// Helpers
fun <E> List<E>.head(): E = this.first()
fun <E> List<E>.tail(): List<E> = this.takeLast(this.size - 1)
fun <E> E.append(xs: List<E>): List<E> = listOf(this).plus(xs)
xs.filter { it.isNotEmpty() }.let { ys ->
return when (ys.isNotEmpty()) {
true -> ys.map { it.head() }.append(transpose(ys.map { it.tail() }))
else -> emptyList()
}
}
}
@Serializable
data class EpUrl(
val quality: String,
val url: String,
val name: String,
)
@Serializable
data class DriveLeechDirect(val url: String? = null)
companion object {
private val SIZE_REGEX = "\\[((?:.(?!\\[))+)][ ]*\$".toRegex(RegexOption.IGNORE_CASE)
private const val PREF_QUALITY_KEY = "pref_quality"
private const val PREF_QUALITY_TITLE = "Preferred quality"
private const val PREF_QUALITY_DEFAULT = "1080"
private val PREF_QUALITY_ENTRIES = arrayOf("1080p", "720p", "480p", "360p")
private val PREF_QUALITY_VALUES = arrayOf("1080", "720", "480", "360")
}
// ============================== Settings ==============================
override fun setupPreferenceScreen(screen: PreferenceScreen) {
ListPreference(screen.context).apply {
key = PREF_QUALITY_KEY
title = PREF_QUALITY_TITLE
entries = PREF_QUALITY_ENTRIES
entryValues = PREF_QUALITY_VALUES
setDefaultValue(PREF_QUALITY_DEFAULT)
summary = "%s"
setOnPreferenceChangeListener { _, newValue ->
val selected = newValue as String
val index = findIndexOfValue(selected)
val entry = entryValues[index] as String
preferences.edit().putString(key, entry).commit()
}
}.also(screen::addPreference)
}
}

View file

@ -1,12 +0,0 @@
ext {
extName = 'Animeflix.live'
extClass = '.AnimeflixLive'
extVersionCode = 4
}
apply from: "$rootDir/common.gradle"
dependencies {
implementation(project(':lib:gogostream-extractor'))
implementation(project(':lib:playlist-utils'))
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

View file

@ -1,503 +0,0 @@
package eu.kanade.tachiyomi.animeextension.en.animeflixlive
import GenreFilter
import SortFilter
import SubPageFilter
import TypeFilter
import android.app.Application
import android.content.SharedPreferences
import androidx.preference.ListPreference
import androidx.preference.PreferenceScreen
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.Track
import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.animesource.online.AnimeHttpSource
import eu.kanade.tachiyomi.lib.playlistutils.PlaylistUtils
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.util.asJsoup
import eu.kanade.tachiyomi.util.parallelCatchingFlatMapBlocking
import eu.kanade.tachiyomi.util.parseAs
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import okhttp3.Headers
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Request
import okhttp3.Response
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
import java.net.URLDecoder
import java.util.Calendar
import java.util.Locale
import java.util.TimeZone
import kotlin.math.min
class AnimeflixLive : ConfigurableAnimeSource, AnimeHttpSource() {
override val name = "Animeflix.live"
override val baseUrl by lazy { preferences.baseUrl }
private val apiUrl by lazy { preferences.apiUrl }
override val lang = "en"
override val supportsLatest = true
private val json: Json by injectLazy()
private val preferences: SharedPreferences by lazy {
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
}
private val apiHeaders = headersBuilder().apply {
add("Accept", "*/*")
add("Host", apiUrl.toHttpUrl().host)
add("Origin", baseUrl)
add("Referer", "$baseUrl/")
}.build()
private val docHeaders = headersBuilder().apply {
add("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8")
add("Host", apiUrl.toHttpUrl().host)
add("Referer", "$baseUrl/")
}.build()
// ============================== Popular ===============================
override fun popularAnimeRequest(page: Int): Request =
GET("$apiUrl/popular?page=${page - 1}", apiHeaders)
override fun popularAnimeParse(response: Response): AnimesPage {
val parsed = response.parseAs<List<AnimeDto>>()
val titlePref = preferences.titleType
val animeList = parsed.map {
it.toSAnime(titlePref)
}
return AnimesPage(animeList, animeList.size == PAGE_SIZE)
}
// =============================== Latest ===============================
override fun latestUpdatesRequest(page: Int): Request =
GET("$apiUrl/trending?page=${page - 1}", apiHeaders)
override fun latestUpdatesParse(response: Response): AnimesPage {
val parsed = response.parseAs<TrendingDto>()
val titlePref = preferences.titleType
val animeList = parsed.trending.map {
it.toSAnime(titlePref)
}
return AnimesPage(animeList, animeList.size == PAGE_SIZE)
}
// =============================== Search ===============================
override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request {
val sort = filters.filterIsInstance<SortFilter>().first().getValue()
val type = filters.filterIsInstance<TypeFilter>().first().getValues()
val genre = filters.filterIsInstance<GenreFilter>().first().getValues()
val subPage = filters.filterIsInstance<SubPageFilter>().first().getValue()
if (subPage.isNotBlank()) {
return GET("$apiUrl/$subPage?page=${page - 1}", apiHeaders)
}
if (query.isEmpty()) {
throw Exception("Search must not be empty")
}
val filtersObj = buildJsonObject {
put("sort", sort)
if (type.isNotEmpty()) {
put("type", json.encodeToString(type))
}
if (genre.isNotEmpty()) {
put("genre", json.encodeToString(genre))
}
}.toJsonString()
val url = apiUrl.toHttpUrl().newBuilder().apply {
addPathSegment("info")
addPathSegment("")
addQueryParameter("query", query)
addQueryParameter("limit", "15")
addQueryParameter("filters", filtersObj)
addQueryParameter("k", query.substr(0, 3).sk())
}.build()
return GET(url, apiHeaders)
}
override fun searchAnimeParse(response: Response): AnimesPage {
val parsed = response.parseAs<List<AnimeDto>>()
val titlePref = preferences.titleType
val animeList = parsed.map {
it.toSAnime(titlePref)
}
val hasNextPage = if (response.request.url.queryParameter("limit") == null) {
animeList.size == 44
} else {
animeList.size == 15
}
return AnimesPage(animeList, hasNextPage)
}
// ============================== Filters ===============================
override fun getFilterList(): AnimeFilterList = AnimeFilterList(
SortFilter(),
TypeFilter(),
GenreFilter(),
AnimeFilter.Separator(),
AnimeFilter.Header("NOTE: Subpage overrides search and other filters!"),
SubPageFilter(),
)
// =========================== Anime Details ============================
override fun animeDetailsRequest(anime: SAnime): Request {
return GET("$apiUrl/getslug/${anime.url}", apiHeaders)
}
override fun getAnimeUrl(anime: SAnime): String {
return "$baseUrl/search/${anime.title}?anime=${anime.url}"
}
override fun animeDetailsParse(response: Response): SAnime {
val titlePref = preferences.titleType
return response.parseAs<DetailsDto>().toSAnime(titlePref)
}
// ============================== Episodes ==============================
override fun episodeListRequest(anime: SAnime): Request {
val lang = preferences.lang
val url = apiUrl.toHttpUrl().newBuilder().apply {
addPathSegment("episodes")
addQueryParameter("id", anime.url)
addQueryParameter("dub", (lang == "Dub").toString())
addQueryParameter("c", anime.url.sk())
}.build()
return GET(url, apiHeaders)
}
override fun episodeListParse(response: Response): List<SEpisode> {
val slug = response.request.url.queryParameter("id")!!
return response.parseAs<EpisodeResponseDto>().episodes.map {
it.toSEpisode(slug)
}.sortedByDescending { it.episode_number }
}
// ============================ Video Links =============================
override fun videoListRequest(episode: SEpisode): Request {
val url = "$apiUrl${episode.url}".toHttpUrl().newBuilder().apply {
addQueryParameter("server", "")
addQueryParameter("c", episode.url.substringAfter("/watch/").sk())
}.build()
return GET(url, apiHeaders)
}
override fun videoListParse(response: Response): List<Video> {
val videoList = mutableListOf<Video>()
val initialPlayerUrl = apiUrl + response.parseAs<ServerDto>().source
val initialServer = initialPlayerUrl.toHttpUrl().queryParameter("server")!!
val initialPlayerDocument = client.newCall(
GET(initialPlayerUrl, docHeaders),
).execute().asJsoup().unescape()
videoList.addAll(
videosFromPlayer(
initialPlayerDocument,
initialServer.replaceFirstChar { c -> c.titlecase(Locale.ROOT) },
),
)
// Go through rest of servers
val servers = initialPlayerDocument.selectFirst("script:containsData(server-settings)")!!.data()
val serversHtml = SERVER_REGEX.findAll(servers).map {
Jsoup.parseBodyFragment(it.groupValues[1])
}.toList()
videoList.addAll(
serversHtml.parallelCatchingFlatMapBlocking {
val server = serverMapping[
it.selectFirst("button")!!
.attr("onclick")
.substringAfter("postMessage('")
.substringBefore("'"),
]
if (server == initialServer) {
return@parallelCatchingFlatMapBlocking emptyList()
}
val serverUrl = response.request.url.newBuilder()
.setQueryParameter("server", server)
.build()
val playerUrl = apiUrl + client.newCall(
GET(serverUrl, apiHeaders),
).execute().parseAs<ServerDto>().source
if (server != playerUrl.toHttpUrl().queryParameter("server")!!) {
return@parallelCatchingFlatMapBlocking emptyList()
}
val playerDocument = client.newCall(
GET(playerUrl, docHeaders),
).execute().asJsoup().unescape()
videosFromPlayer(
playerDocument,
server.replaceFirstChar { c -> c.titlecase(Locale.ROOT) },
)
},
)
return videoList
}
private val serverMapping = mapOf(
"settings-0" to "moon",
"settings-1" to "sun",
"settings-2" to "zoro",
"settings-3" to "gogo",
)
private val playlistUtils by lazy { PlaylistUtils(client, headers) }
private fun getVideoHeaders(baseHeaders: Headers, referer: String, videoUrl: String): Headers {
return baseHeaders.newBuilder().apply {
add("Accept", "*/*")
add("Accept-Language", "en-US,en;q=0.5")
add("Host", videoUrl.toHttpUrl().host)
add("Origin", "https://${apiUrl.toHttpUrl().host}")
add("Referer", "$apiUrl/")
add("Sec-Fetch-Dest", "empty")
add("Sec-Fetch-Mode", "cors")
add("Sec-Fetch-Site", "cross-site")
}.build()
}
private fun Document.unescape(): Document {
val unescapeScript = this.selectFirst("script:containsData(unescape)")
return if (unescapeScript == null) {
this
} else {
val data = URLDecoder.decode(unescapeScript.data(), "UTF-8")
Jsoup.parse(data, this.location())
}
}
private fun videosFromPlayer(document: Document, name: String): List<Video> {
val dataScript = document.selectFirst("script:containsData(m3u8)")
?.data() ?: return emptyList()
val subtitleList = document.select("video > track[kind=captions]").map {
Track(it.attr("id"), it.attr("label"))
}
var masterPlaylist = M3U8_REGEX.find(dataScript)?.groupValues?.get(1)
?: return emptyList()
if (name.equals("moon", true)) {
masterPlaylist += dataScript.substringAfter("`${'$'}{url}")
.substringBefore("`")
}
return playlistUtils.extractFromHls(
masterPlaylist,
videoHeadersGen = ::getVideoHeaders,
videoNameGen = { q -> "$name - $q" },
subtitleList = subtitleList,
)
}
// ============================= Utilities ==============================
override fun List<Video>.sort(): List<Video> {
val quality = preferences.quality
val server = preferences.server
return this.sortedWith(
compareBy(
{ it.quality.contains(quality) },
{ it.quality.contains(server, true) },
),
).reversed()
}
private fun JsonObject.toJsonString(): String {
return json.encodeToString(this)
}
private fun String.sk(): String {
val t = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
val n = 17 + (t.get(Calendar.DAY_OF_MONTH) - t.get(Calendar.MONTH)) / 2
return this.toCharArray().fold("") { acc, c ->
acc + c.code.toString(n).padStart(2, '0')
}
}
private fun String.substr(start: Int, end: Int): String {
val stop = min(end, this.length)
return this.substring(start, stop)
}
companion object {
private val SERVER_REGEX = Regex("""'1' === '1'.*?(<button.*?</button>)""", RegexOption.DOT_MATCHES_ALL)
private val M3U8_REGEX = Regex("""const ?\w*? ?= ?`(.*?)`""")
private const val PAGE_SIZE = 24
private const val PREF_DOMAIN_KEY = "pref_domain_key"
private const val PREF_DOMAIN_DEFAULT = "https://animeflix.live,https://api.animeflix.dev"
private val PREF_DOMAIN_ENTRIES = arrayOf("animeflix.live", "animeflix.ro")
private val PREF_DOMAIN_ENTRY_VALUES = arrayOf(
"https://animeflix.live,https://api.animeflix.dev",
"https://animeflix.ro,https://api.animeflixtv.to",
)
private const val PREF_TITLE_KEY = "pref_title_type_key"
private const val PREF_TITLE_DEFAULT = "English"
private val PREF_TITLE_ENTRIES = arrayOf("English", "Native", "Romaji")
private const val PREF_LANG_KEY = "pref_lang_key"
private const val PREF_LANG_DEFAULT = "Sub"
private val PREF_LANG_ENTRIES = arrayOf("Sub", "Dub")
private const val PREF_QUALITY_KEY = "pref_quality_key"
private const val PREF_QUALITY_DEFAULT = "1080"
private val PREF_QUALITY_ENTRY_VALUES = arrayOf("1080", "720", "480", "360")
private val PREF_QUALITY_ENTRIES = PREF_QUALITY_ENTRY_VALUES.map { "${it}p" }.toTypedArray()
private const val PREF_SERVER_KEY = "pref_server_key"
private const val PREF_SERVER_DEFAULT = "Moon"
private val PREF_SERVER_ENTRIES = arrayOf("Moon", "Sun", "Zoro", "Gogo")
}
// ============================== Settings ==============================
override fun setupPreferenceScreen(screen: PreferenceScreen) {
ListPreference(screen.context).apply {
key = PREF_DOMAIN_KEY
title = "Preferred domain (requires app restart)"
entries = PREF_DOMAIN_ENTRIES
entryValues = PREF_DOMAIN_ENTRY_VALUES
setDefaultValue(PREF_DOMAIN_DEFAULT)
summary = "%s"
setOnPreferenceChangeListener { _, newValue ->
val selected = newValue as String
val index = findIndexOfValue(selected)
val entry = entryValues[index] as String
preferences.edit().putString(key, entry).commit()
}
}.also(screen::addPreference)
ListPreference(screen.context).apply {
key = PREF_TITLE_KEY
title = "Preferred Title Type"
entries = PREF_TITLE_ENTRIES
entryValues = PREF_TITLE_ENTRIES
setDefaultValue(PREF_TITLE_DEFAULT)
summary = "%s"
setOnPreferenceChangeListener { _, newValue ->
val selected = newValue as String
val index = findIndexOfValue(selected)
val entry = entryValues[index] as String
preferences.edit().putString(key, entry).commit()
}
}.also(screen::addPreference)
ListPreference(screen.context).apply {
key = PREF_LANG_KEY
title = "Preferred Language"
entries = PREF_LANG_ENTRIES
entryValues = PREF_LANG_ENTRIES
setDefaultValue(PREF_LANG_DEFAULT)
summary = "%s"
setOnPreferenceChangeListener { _, newValue ->
val selected = newValue as String
val index = findIndexOfValue(selected)
val entry = entryValues[index] as String
preferences.edit().putString(key, entry).commit()
}
}.also(screen::addPreference)
ListPreference(screen.context).apply {
key = PREF_QUALITY_KEY
title = "Preferred quality"
entries = PREF_QUALITY_ENTRIES
entryValues = PREF_QUALITY_ENTRY_VALUES
setDefaultValue(PREF_QUALITY_DEFAULT)
summary = "%s"
setOnPreferenceChangeListener { _, newValue ->
val selected = newValue as String
val index = findIndexOfValue(selected)
val entry = entryValues[index] as String
preferences.edit().putString(key, entry).commit()
}
}.also(screen::addPreference)
ListPreference(screen.context).apply {
key = PREF_SERVER_KEY
title = "Preferred server"
entries = PREF_SERVER_ENTRIES
entryValues = PREF_SERVER_ENTRIES
setDefaultValue(PREF_SERVER_DEFAULT)
summary = "%s"
setOnPreferenceChangeListener { _, newValue ->
val selected = newValue as String
val index = findIndexOfValue(selected)
val entry = entryValues[index] as String
preferences.edit().putString(key, entry).commit()
}
}.also(screen::addPreference)
}
private val SharedPreferences.baseUrl
get() = getString(PREF_DOMAIN_KEY, PREF_DOMAIN_DEFAULT)!!
.split(",").first()
private val SharedPreferences.apiUrl
get() = getString(PREF_DOMAIN_KEY, PREF_DOMAIN_DEFAULT)!!
.split(",").last()
private val SharedPreferences.titleType
get() = getString(PREF_TITLE_KEY, PREF_TITLE_DEFAULT)!!
private val SharedPreferences.lang
get() = getString(PREF_LANG_KEY, PREF_LANG_DEFAULT)!!
private val SharedPreferences.quality
get() = getString(PREF_QUALITY_KEY, PREF_QUALITY_DEFAULT)!!
private val SharedPreferences.server
get() = getString(PREF_SERVER_KEY, PREF_QUALITY_DEFAULT)!!
}

View file

@ -1,123 +0,0 @@
package eu.kanade.tachiyomi.animeextension.en.animeflixlive
import eu.kanade.tachiyomi.animesource.model.SAnime
import eu.kanade.tachiyomi.animesource.model.SEpisode
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import org.jsoup.Jsoup
import kotlin.math.ceil
import kotlin.math.floor
@Serializable
class TrendingDto(
val trending: List<AnimeDto>,
)
@Serializable
class AnimeDto(
val slug: String,
@SerialName("title") val titleObj: TitleObject,
val images: ImageObject,
) {
@Serializable
class TitleObject(
val english: String? = null,
val native: String? = null,
val romaji: String? = null,
)
@Serializable
class ImageObject(
val large: String? = null,
val medium: String? = null,
val small: String? = null,
)
fun toSAnime(titlePref: String): SAnime = SAnime.create().apply {
title = when (titlePref) {
"English" -> titleObj.english ?: titleObj.romaji ?: titleObj.native ?: "Title N/A"
"Romaji" -> titleObj.romaji ?: titleObj.english ?: titleObj.native ?: "Title N/A"
else -> titleObj.native ?: titleObj.romaji ?: titleObj.english ?: "Title N/A"
}
thumbnail_url = images.large ?: images.medium ?: images.small ?: ""
url = slug
}
}
@Serializable
class DetailsDto(
val slug: String,
@SerialName("title") val titleObj: TitleObject,
val description: String,
val genres: List<String>,
val status: String? = null,
val images: ImageObject,
) {
@Serializable
class TitleObject(
val english: String? = null,
val native: String? = null,
val romaji: String? = null,
)
@Serializable
class ImageObject(
val large: String? = null,
val medium: String? = null,
val small: String? = null,
)
fun toSAnime(titlePref: String): SAnime = SAnime.create().apply {
title = when (titlePref) {
"English" -> titleObj.english ?: titleObj.romaji ?: titleObj.native ?: "Title N/A"
"Romaji" -> titleObj.romaji ?: titleObj.english ?: titleObj.native ?: "Title N/A"
else -> titleObj.native ?: titleObj.romaji ?: titleObj.english ?: "Title N/A"
}
thumbnail_url = images.large ?: images.medium ?: images.small ?: ""
url = slug
genre = genres.joinToString()
status = this@DetailsDto.status.parseStatus()
description = Jsoup.parseBodyFragment(
this@DetailsDto.description.replace("<br>", "br2n"),
).text().replace("br2n", "\n")
}
private fun String?.parseStatus(): Int = when (this?.lowercase()) {
"releasing" -> SAnime.ONGOING
"finished" -> SAnime.COMPLETED
"cancelled" -> SAnime.CANCELLED
else -> SAnime.UNKNOWN
}
}
@Serializable
class EpisodeResponseDto(
val episodes: List<EpisodeDto>,
) {
@Serializable
class EpisodeDto(
val number: Float,
val title: String? = null,
) {
fun toSEpisode(slug: String): SEpisode = SEpisode.create().apply {
val epNum = if (floor(number) == ceil(number)) {
number.toInt().toString()
} else {
number.toString()
}
url = "/watch/$slug-episode-$epNum"
episode_number = number
name = if (title == null) {
"Episode $epNum"
} else {
"Ep. $epNum - $title"
}
}
}
}
@Serializable
class ServerDto(
val source: String,
)

View file

@ -1,81 +0,0 @@
import eu.kanade.tachiyomi.animesource.model.AnimeFilter
open class UriPartFilter(
name: String,
private val vals: Array<Pair<String, String>>,
defaultValue: String? = null,
) : AnimeFilter.Select<String>(
name,
vals.map { it.first }.toTypedArray(),
vals.indexOfFirst { it.second == defaultValue }.takeIf { it != -1 } ?: 0,
) {
fun getValue(): String {
return vals[state].second
}
}
open class UriMultiSelectOption(name: String, val value: String) : AnimeFilter.CheckBox(name)
open class UriMultiSelectFilter(
name: String,
private val vals: Array<Pair<String, String>>,
) : AnimeFilter.Group<UriMultiSelectOption>(name, vals.map { UriMultiSelectOption(it.first, it.second) }) {
fun getValues(): List<String> {
return state.filter { it.state }.map { it.value }
}
}
class SortFilter : UriPartFilter(
"Sort",
arrayOf(
Pair("Recently Updated", "recently_updated"),
Pair("Recently Added", "recently_added"),
Pair("Release Date ↓", "release_date_down"),
Pair("Release Date ↑", "release_date_up"),
Pair("Name A-Z", "title_az"),
Pair("Best Rating", "scores"),
Pair("Most Watched", "most_watched"),
Pair("Anime Length", "number_of_episodes"),
),
)
class TypeFilter : UriMultiSelectFilter(
"Type",
arrayOf(
Pair("TV", "TV"),
Pair("Movie", "MOVIE"),
Pair("OVA", "OVA"),
Pair("ONA", "ONA"),
Pair("Special", "SPECIAL"),
),
)
class GenreFilter : UriMultiSelectFilter(
"Genre",
arrayOf(
Pair("Action", "Action"),
Pair("Adventure", "Adventure"),
Pair("Comedy", "Comedy"),
Pair("Drama", "Drama"),
Pair("Ecchi", "Ecchi"),
Pair("Fantasy", "Fantasy"),
Pair("Horror", "Horror"),
Pair("Mecha", "Mecha"),
Pair("Mystery", "Mystery"),
Pair("Psychological", "Psychological"),
Pair("Romance", "Romance"),
Pair("Sci-Fi", "Sci-Fi"),
Pair("Sports", "Sports"),
Pair("Supernatural", "Supernatural"),
Pair("Thriller", "Thriller"),
),
)
class SubPageFilter : UriPartFilter(
"Sub-page",
arrayOf(
Pair("<select>", ""),
Pair("Movies", "movies"),
Pair("Series", "series"),
),
)

View file

@ -1,7 +1,7 @@
ext {
extName = 'Aniwave'
extClass = '.Aniwave'
extVersionCode = 69
extVersionCode = 73
}
apply from: "$rootDir/common.gradle"

View file

@ -2,7 +2,10 @@ package eu.kanade.tachiyomi.animeextension.en.nineanime
import android.app.Application
import android.content.SharedPreferences
import android.util.Log
import android.webkit.URLUtil
import android.widget.Toast
import androidx.preference.EditTextPreference
import androidx.preference.ListPreference
import androidx.preference.MultiSelectListPreference
import androidx.preference.PreferenceScreen
@ -38,7 +41,12 @@ class Aniwave : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
override val id: Long = 98855593379717478
override val baseUrl by lazy {
val customDomain = preferences.getString(PREF_CUSTOM_DOMAIN_KEY, null)
if (customDomain.isNullOrBlank()) {
preferences.getString(PREF_DOMAIN_KEY, PREF_DOMAIN_DEFAULT)!!
} else {
customDomain
}
}
override val lang = "en"
@ -89,7 +97,7 @@ class Aniwave : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request {
val filters = AniwaveFilters.getSearchParameters(filters)
val vrf = if (query.isNotBlank()) utils.vrfEncrypt(query) else ""
val vrf = if (query.isNotBlank()) utils.vrfEncrypt(ENCRYPTION_KEY, query) else ""
var url = "$baseUrl/filter?keyword=$query"
if (filters.genre.isNotBlank()) url += filters.genre
@ -101,7 +109,7 @@ class Aniwave : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
if (filters.language.isNotBlank()) url += filters.language
if (filters.rating.isNotBlank()) url += filters.rating
return GET("$url&sort=${filters.sort}&page=$page&$vrf", refererHeaders)
return GET("$url&sort=${filters.sort}&page=$page&vrf=$vrf", refererHeaders)
}
override fun searchAnimeSelector(): String = popularAnimeSelector()
@ -116,15 +124,18 @@ class Aniwave : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
// =========================== Anime Details ============================
override fun animeDetailsParse(document: Document): SAnime = SAnime.create().apply {
title = document.select("h1.title").text()
genre = document.select("div:contains(Genre) > span > a").joinToString { it.text() }
description = document.select("div.synopsis > div.shorting > div.content").text()
author = document.select("div:contains(Studio) > span > a").text()
status = parseStatus(document.select("div:contains(Status) > span").text())
override fun animeDetailsParse(document: Document): SAnime {
val anime = SAnime.create()
val newDocument = resolveSearchAnime(anime, document)
anime.apply {
title = newDocument.select("h1.title").text()
genre = newDocument.select("div:contains(Genre) > span > a").joinToString { it.text() }
description = newDocument.select("div.synopsis > div.shorting > div.content").text()
author = newDocument.select("div:contains(Studio) > span > a").text()
status = parseStatus(newDocument.select("div:contains(Status) > span").text())
val altName = "Other name(s): "
document.select("h1.title").attr("data-jp").let {
newDocument.select("h1.title").attr("data-jp").let {
if (it.isNotBlank()) {
description = when {
description.isNullOrBlank() -> altName + it
@ -133,13 +144,19 @@ class Aniwave : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
}
}
}
return anime
}
// ============================== Episodes ==============================
override fun episodeListRequest(anime: SAnime): Request {
val id = client.newCall(GET(baseUrl + anime.url)).execute().asJsoup()
.selectFirst("div[data-id]")!!.attr("data-id")
val vrf = utils.vrfEncrypt(id)
Log.i(name, "episodeListRequest")
val response = client.newCall(GET(baseUrl + anime.url)).execute()
var document = response.asJsoup()
document = resolveSearchAnime(anime, document)
val id = document.selectFirst("div[data-id]")?.attr("data-id") ?: throw Exception("ID not found")
val vrf = utils.vrfEncrypt(ENCRYPTION_KEY, id)
val listHeaders = headers.newBuilder().apply {
add("Accept", "application/json, text/javascript, */*; q=0.01")
@ -147,7 +164,7 @@ class Aniwave : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
add("X-Requested-With", "XMLHttpRequest")
}.build()
return GET("$baseUrl/ajax/episode/list/$id?$vrf#${anime.url}", listHeaders)
return GET("$baseUrl/ajax/episode/list/$id?vrf=$vrf#${anime.url}", listHeaders)
}
override fun episodeListSelector() = "div.episodes ul > li > a"
@ -195,8 +212,8 @@ class Aniwave : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
override fun videoListRequest(episode: SEpisode): Request {
val ids = episode.url.substringBefore("&")
val vrf = utils.vrfEncrypt(ids)
val url = "/ajax/server/list/$ids?$vrf"
val vrf = utils.vrfEncrypt(ENCRYPTION_KEY, ids)
val url = "/ajax/server/list/$ids?vrf=$vrf"
val epurl = episode.url.substringAfter("epurl=")
val listHeaders = headers.newBuilder().apply {
@ -217,7 +234,7 @@ class Aniwave : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
override fun videoListParse(response: Response): List<Video> {
val epurl = response.request.url.fragment!!
val document = response.parseAs<ResultResponse>().toDocument()
val hosterSelection = preferences.getStringSet(PREF_HOSTER_KEY, PREF_HOSTER_DEFAULT)!!
val hosterSelection = getHosters()
val typeSelection = preferences.getStringSet(PREF_TYPE_TOGGLE_KEY, PREF_TYPES_TOGGLE_DEFAULT)!!
return document.select("div.servers > div").parallelFlatMapBlocking { elem ->
@ -248,7 +265,7 @@ class Aniwave : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
private val mp4uploadExtractor by lazy { Mp4uploadExtractor(client) }
private fun extractVideo(server: VideoData, epUrl: String): List<Video> {
val vrf = utils.vrfEncrypt(server.serverId)
val vrf = utils.vrfEncrypt(ENCRYPTION_KEY, server.serverId)
val listHeaders = headers.newBuilder().apply {
add("Accept", "application/json, text/javascript, */*; q=0.01")
@ -257,24 +274,20 @@ class Aniwave : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
}.build()
val response = client.newCall(
GET("$baseUrl/ajax/server/${server.serverId}?$vrf", listHeaders),
GET("$baseUrl/ajax/server/${server.serverId}?vrf=$vrf", listHeaders),
).execute()
if (response.code != 200) return emptyList()
return runCatching {
val parsed = response.parseAs<ServerResponse>()
val embedLink = utils.vrfDecrypt(parsed.result.url)
val embedLink = utils.vrfDecrypt(DECRYPTION_KEY, parsed.result.url)
when (server.serverName) {
"vidplay", "mycloud" -> {
val hosterName = when (server.serverName) {
"vidplay" -> "VidPlay"
else -> "MyCloud"
"vidstream", "megaf" -> {
vidsrcExtractor.videosFromUrl(embedLink, server.serverName, server.type)
}
vidsrcExtractor.videosFromUrl(embedLink, hosterName, server.type)
}
"filemoon" -> filemoonExtractor.videosFromUrl(embedLink, "Filemoon - ${server.type} - ")
"moonf" -> filemoonExtractor.videosFromUrl(embedLink, "MoonF - ${server.type} - ")
"streamtape" -> streamtapeExtractor.videoFromUrl(embedLink, "StreamTape - ${server.type}")?.let(::listOf) ?: emptyList()
"mp4upload" -> mp4uploadExtractor.videosFromUrl(embedLink, headers, suffix = " - ${server.type}")
"mp4u" -> mp4uploadExtractor.videosFromUrl(embedLink, headers, suffix = " - ${server.type}")
else -> emptyList()
}
}.getOrElse { emptyList() }
@ -312,6 +325,35 @@ class Aniwave : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
}
}
private fun resolveSearchAnime(anime: SAnime, document: Document): Document {
if (document.location().startsWith("$baseUrl/filter?keyword=")) { // redirected to search
val element = document.selectFirst(searchAnimeSelector())
val foundAnimePath = element?.selectFirst("a[href]")?.attr("href") ?: throw Exception("Search element not found (resolveSearch)")
anime.url = foundAnimePath // probably doesn't work as intended
return client.newCall(GET(baseUrl + foundAnimePath)).execute().asJsoup()
}
return document
}
private fun getHosters(): Set<String> {
val hosterSelection = preferences.getStringSet(PREF_HOSTER_KEY, PREF_HOSTER_DEFAULT)!!
var invalidRecord = false
hosterSelection.forEach { str ->
val index = HOSTERS_NAMES.indexOf(str)
if (index == -1) {
invalidRecord = true
}
}
// found invalid record, reset to defaults
if (invalidRecord) {
preferences.edit().putStringSet(PREF_HOSTER_KEY, PREF_HOSTER_DEFAULT).apply()
return PREF_HOSTER_DEFAULT.toSet()
}
return hosterSelection.toSet()
}
companion object {
private val SOFTSUB_REGEX by lazy { Regex("""\bsoftsub\b""", RegexOption.IGNORE_CASE) }
private val RELEASE_REGEX by lazy { Regex("""Release: (\d+\/\d+\/\d+ \d+:\d+)""") }
@ -323,6 +365,8 @@ class Aniwave : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
private const val PREF_DOMAIN_KEY = "preferred_domain"
private const val PREF_DOMAIN_DEFAULT = "https://aniwave.to"
private const val PREF_CUSTOM_DOMAIN_KEY = "custom_domain"
private const val PREF_QUALITY_KEY = "preferred_quality"
private const val PREF_QUALITY_DEFAULT = "1080"
@ -330,41 +374,51 @@ class Aniwave : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
private const val PREF_LANG_DEFAULT = "Sub"
private const val PREF_SERVER_KEY = "preferred_server"
private const val PREF_SERVER_DEFAULT = "vidplay"
private const val PREF_SERVER_DEFAULT = "Vidstream"
private const val PREF_MARK_FILLERS_KEY = "mark_fillers"
private const val PREF_MARK_FILLERS_DEFAULT = true
private const val PREF_HOSTER_KEY = "hoster_selection"
private val HOSTERS = arrayOf(
"VidPlay",
"MyCloud",
"Filemoon",
"Vidstream",
"Megaf",
"MoonF",
"StreamTape",
"Mp4Upload",
"MP4u",
)
private val HOSTERS_NAMES = arrayOf(
"vidplay",
"mycloud",
"filemoon",
"vidstream",
"megaf",
"moonf",
"streamtape",
"mp4upload",
"mp4u",
)
private val PREF_HOSTER_DEFAULT = HOSTERS_NAMES.toSet()
private const val PREF_TYPE_TOGGLE_KEY = "type_selection"
private val TYPES = arrayOf("Sub", "Softsub", "Dub")
private val PREF_TYPES_TOGGLE_DEFAULT = TYPES.toSet()
private const val DECRYPTION_KEY = "ctpAbOz5u7S6OMkx"
private const val ENCRYPTION_KEY = "T78s2WjTc7hSIZZR"
}
// ============================== Settings ==============================
override fun setupPreferenceScreen(screen: PreferenceScreen) {
// validate hosters preferences and if invalid reset
try {
getHosters()
} catch (e: Exception) {
Log.w(name, e.toString())
}
ListPreference(screen.context).apply {
key = PREF_DOMAIN_KEY
title = "Preferred domain"
entries = arrayOf("aniwave.to", "aniwave.li", "aniwave.ws", "aniwave.vc")
entryValues = arrayOf("https://aniwave.to", "https://aniwave.li", "https://aniwave.ws", "https://aniwave.vc")
entries = arrayOf("aniwave.to", "aniwavetv.to (unofficial)")
entryValues = arrayOf("https://aniwave.to", "https://aniwavetv.to")
setDefaultValue(PREF_DOMAIN_DEFAULT)
summary = "%s"
@ -460,5 +514,30 @@ class Aniwave : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
preferences.edit().putStringSet(key, newValue as Set<String>).commit()
}
}.also(screen::addPreference)
EditTextPreference(screen.context).apply {
key = PREF_CUSTOM_DOMAIN_KEY
title = "Custom domain"
setDefaultValue(null)
val currentValue = preferences.getString(PREF_CUSTOM_DOMAIN_KEY, null)
summary = if (currentValue.isNullOrBlank()) {
"Custom domain of your choosing"
} else {
"Domain: \"$currentValue\". \nLeave blank to disable. Overrides any domain preferences!"
}
setOnPreferenceChangeListener { _, newValue ->
val newDomain = newValue as String
if (newDomain.isBlank() || URLUtil.isValidUrl(newDomain)) {
summary = "Restart to apply changes"
Toast.makeText(screen.context, "Restart Aniyomi to apply changes", Toast.LENGTH_LONG).show()
preferences.edit().putString(key, newDomain).apply()
true
} else {
Toast.makeText(screen.context, "Invalid url. Url example: https://aniwave.to", Toast.LENGTH_LONG).show()
false
}
}
}.also(screen::addPreference)
}
}

View file

@ -7,51 +7,22 @@ import javax.crypto.spec.SecretKeySpec
class AniwaveUtils {
fun vrfEncrypt(input: String): String {
val rc4Key = SecretKeySpec("tGn6kIpVXBEUmqjD".toByteArray(), "RC4")
fun vrfEncrypt(key: String, input: String): String {
val rc4Key = SecretKeySpec(key.toByteArray(), "RC4")
val cipher = Cipher.getInstance("RC4")
cipher.init(Cipher.DECRYPT_MODE, rc4Key, cipher.parameters)
var vrf = cipher.doFinal(input.toByteArray())
vrf = Base64.encode(vrf, Base64.URL_SAFE or Base64.NO_WRAP)
vrf = Base64.encode(vrf, Base64.DEFAULT or Base64.NO_WRAP)
vrf = vrfShift(vrf)
vrf = vrf.reversed().toByteArray()
vrf = Base64.encode(vrf, Base64.URL_SAFE or Base64.NO_WRAP)
// vrf = rot13(vrf)
val stringVrf = vrf.toString(Charsets.UTF_8)
return "vrf=${java.net.URLEncoder.encode(stringVrf, "utf-8")}"
var vrfString = vrf.toString(Charsets.UTF_8)
return java.net.URLEncoder.encode(vrfString, "utf-8")
}
fun vrfDecrypt(input: String): String {
var vrf = input.toByteArray()
vrf = Base64.decode(vrf, Base64.URL_SAFE)
val rc4Key = SecretKeySpec("LUyDrL4qIxtIxOGs".toByteArray(), "RC4")
fun vrfDecrypt(key: String, input: String): String {
var vrf = Base64.decode(input.toByteArray(), Base64.URL_SAFE)
val rc4Key = SecretKeySpec(key.toByteArray(), "RC4")
val cipher = Cipher.getInstance("RC4")
cipher.init(Cipher.DECRYPT_MODE, rc4Key, cipher.parameters)
vrf = cipher.doFinal(vrf)
return URLDecoder.decode(vrf.toString(Charsets.UTF_8), "utf-8")
}
private fun rot13(vrf: ByteArray): ByteArray {
for (i in vrf.indices) {
val byte = vrf[i]
if (byte in 'A'.code..'Z'.code) {
vrf[i] = ((byte - 'A'.code + 13) % 26 + 'A'.code).toByte()
} else if (byte in 'a'.code..'z'.code) {
vrf[i] = ((byte - 'a'.code + 13) % 26 + 'a'.code).toByte()
}
}
return vrf
}
private fun vrfShift(vrf: ByteArray): ByteArray {
for (i in vrf.indices) {
val shift = arrayOf(-2, -4, -5, 6, 2, -3, 3, 6)[i % 8]
vrf[i] = vrf[i].plus(shift).toByte()
}
return vrf
}
}

View file

@ -3,7 +3,7 @@ ext {
extClass = '.AnimeOnlineNinja'
themePkg = 'dooplay'
baseUrl = 'https://ww3.animeonline.ninja'
overrideVersionCode = 38
overrideVersionCode = 39
}
apply from: "$rootDir/common.gradle"

View file

@ -222,6 +222,21 @@ class AnimeOnlineNinja : DooPlay(
preferences.edit().putString(key, entry).commit()
}
}
ListPreference(screen.context).apply {
key = PREF_SERVER_KEY
title = "Preferred server"
entries = SERVER_LIST
entryValues = SERVER_LIST
setDefaultValue(PREF_SERVER_DEFAULT)
summary = "%s"
setOnPreferenceChangeListener { _, newValue ->
val selected = newValue as String
val index = findIndexOfValue(selected)
val entry = entryValues[index] as String
preferences.edit().putString(key, entry).commit()
}
}.also(screen::addPreference)
val vrfIterceptPref = CheckBoxPreference(screen.context).apply {
key = PREF_VRF_INTERCEPT_KEY
@ -240,9 +255,11 @@ class AnimeOnlineNinja : DooPlay(
override fun List<Video>.sort(): List<Video> {
val quality = preferences.getString(prefQualityKey, prefQualityDefault)!!
val lang = preferences.getString(PREF_LANG_KEY, PREF_LANG_DEFAULT)!!
val server = preferences.getString(PREF_SERVER_KEY, PREF_SERVER_DEFAULT)!!
return sortedWith(
compareBy(
{ it.quality.contains(lang) },
{ it.quality.contains(server, true) },
{ it.quality.contains(quality) },
),
).reversed()
@ -255,8 +272,11 @@ class AnimeOnlineNinja : DooPlay(
private const val PREF_LANG_KEY = "preferred_lang"
private const val PREF_LANG_TITLE = "Preferred language"
private const val PREF_LANG_DEFAULT = "SUB"
private const val PREF_SERVER_KEY = "preferred_server"
private const val PREF_SERVER_DEFAULT = "Uqload"
private val PREF_LANG_ENTRIES = arrayOf("SUB", "All", "ES", "LAT")
private val PREF_LANG_VALUES = arrayOf("SUB", "", "ES", "LAT")
private val SERVER_LIST = arrayOf("Filemoon", "DoodStream", "StreamTape", "MixDrop", "Uqload", "WolfStream", "saidochesto.top")
private const val PREF_VRF_INTERCEPT_KEY = "vrf_intercept"
private const val PREF_VRF_INTERCEPT_TITLE = "Intercept VRF links (Requiere Reiniciar)"

View file

@ -1,7 +1,7 @@
ext {
extName = 'Hackstore'
extClass = '.Hackstore'
extVersionCode = 10
extVersionCode = 11
}
apply from: "$rootDir/common.gradle"

View file

@ -132,8 +132,8 @@ class Hackstore : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
override fun episodeListParse(response: Response): List<SEpisode> {
val document = response.asJsoup()
val ismovie = response.request.url.toString().contains("/peliculas/")
return if (ismovie) {
val isMovie = response.request.url.toString().contains("/peliculas/")
return if (isMovie) {
listOf(
SEpisode.create().apply {
name = "PELÍCULA"
@ -142,14 +142,14 @@ class Hackstore : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
},
)
} else {
document.select(".movie-thumbnail").map { thumbnail ->
document.select(".movie-thumbnail").mapIndexed { idx, thumbnail ->
val episodeLink = thumbnail.select("a").attr("href")
val seasonMatch = Regex("-(\\d+)x(\\d+)/$").find(episodeLink)
val seasonNumber = seasonMatch?.groups?.get(1)?.value?.toInt() ?: 0
val episodeNumber = seasonMatch?.groups?.get(2)?.value?.toInt() ?: 0
SEpisode.create().apply {
name = "T$seasonNumber - E$episodeNumber"
episode_number = episodeNumber.toFloat()
episode_number = idx + 1f
setUrlWithoutDomain(episodeLink)
}
}
@ -196,7 +196,7 @@ class Hackstore : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
server.contains("streamtape") || server.contains("stp") || server.contains("stape") -> {
listOf(streamTapeExtractor.videoFromUrl(url, quality = "$prefix StreamTape")!!)
}
server.contains("voe") -> voeExtractor.videosFromUrl(url, prefix)
server.contains("voe") -> voeExtractor.videosFromUrl(url, "$prefix ")
server.contains("filemoon") -> filemoonExtractor.videosFromUrl(url, prefix = "$prefix Filemoon:")
server.contains("wishembed") || server.contains("streamwish") || server.contains("strwish") || server.contains("wish") -> {
streamWishExtractor.videosFromUrl(url, videoNameGen = { "$prefix StreamWish:$it" })

View file

@ -1,7 +1,7 @@
ext {
extName = 'Pelisplushd'
extClass = '.PelisplushdFactory'
extVersionCode = 53
extVersionCode = 54
}
apply from: "$rootDir/common.gradle"
@ -22,5 +22,6 @@ dependencies {
implementation(project(':lib:burstcloud-extractor'))
implementation(project(':lib:fastream-extractor'))
implementation(project(':lib:upstream-extractor'))
implementation(project(':lib:streamhidevid-extractor'))
implementation "dev.datlag.jsunpacker:jsunpacker:1.0.1"
}

View file

@ -5,7 +5,6 @@ import android.content.SharedPreferences
import android.util.Base64
import androidx.preference.ListPreference
import androidx.preference.PreferenceScreen
import eu.kanade.tachiyomi.animeextension.es.pelisplushd.extractors.StreamHideExtractor
import eu.kanade.tachiyomi.animesource.ConfigurableAnimeSource
import eu.kanade.tachiyomi.animesource.model.AnimeFilter
import eu.kanade.tachiyomi.animesource.model.AnimeFilterList
@ -19,6 +18,7 @@ import eu.kanade.tachiyomi.lib.fastreamextractor.FastreamExtractor
import eu.kanade.tachiyomi.lib.filemoonextractor.FilemoonExtractor
import eu.kanade.tachiyomi.lib.mp4uploadextractor.Mp4uploadExtractor
import eu.kanade.tachiyomi.lib.okruextractor.OkruExtractor
import eu.kanade.tachiyomi.lib.streamhidevidextractor.StreamHideVidExtractor
import eu.kanade.tachiyomi.lib.streamlareextractor.StreamlareExtractor
import eu.kanade.tachiyomi.lib.streamtapeextractor.StreamTapeExtractor
import eu.kanade.tachiyomi.lib.streamwishextractor.StreamWishExtractor
@ -110,10 +110,13 @@ open class Pelisplushd(override val name: String, override val baseUrl: String)
val apiUrl = data?.substringAfter("video[1] = '", "")?.substringBefore("';", "")
val alternativeServers = document.select("ul.TbVideoNv.nav.nav-tabs li:not(:first-child)")
if (!apiUrl.isNullOrEmpty()) {
val apiResponse = client.newCall(GET(apiUrl)).execute().asJsoup()
val apiResponse = client.newCall(GET(apiUrl)).execute()
val docResponse = apiResponse.asJsoup()
if (apiResponse.isSuccessful) {
val regIsUrl = "https?:\\/\\/(www\\.)?[-a-zA-Z0-9@:%._\\+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\\b([-a-zA-Z0-9()@:%_\\+.~#?&//=]*)".toRegex()
val encryptedList = apiResponse.select("#PlayerDisplay div[class*=\"OptionsLangDisp\"] div[class*=\"ODDIV\"] div[class*=\"OD\"] li")
encryptedList.parallelCatchingFlatMapBlocking {
val encryptedList = docResponse.select("#PlayerDisplay div[class*=\"OptionsLangDisp\"] div[class*=\"ODDIV\"] div[class*=\"OD\"] li")
encryptedList.flatMap {
runCatching {
val url = it.attr("onclick")
.substringAfter("go_to_player('")
.substringAfter("go_to_playerVast('")
@ -135,8 +138,10 @@ open class Pelisplushd(override val name: String, override val baseUrl: String)
}
serverVideoResolver(realUrl)
}.getOrNull() ?: emptyList()
}.also(videoList::addAll)
}
}
// verifier for old series
if (!apiUrl.isNullOrEmpty() && !apiUrl.contains("/video/") || alternativeServers.any()) {
@ -210,7 +215,8 @@ open class Pelisplushd(override val name: String, override val baseUrl: String)
embedUrl.contains("fastream") -> FastreamExtractor(client, headers).videosFromUrl(url, prefix = "Fastream:")
embedUrl.contains("upstream") -> UpstreamExtractor(client).videosFromUrl(url)
embedUrl.contains("streamtape") || embedUrl.contains("stp") || embedUrl.contains("stape") -> listOf(StreamTapeExtractor(client).videoFromUrl(url, quality = "StreamTape")!!)
embedUrl.contains("ahvsh") || embedUrl.contains("streamhide") || embedUrl.contains("guccihide") || embedUrl.contains("streamvid") -> StreamHideExtractor(client).videosFromUrl(url, "StreamHide")
embedUrl.contains("ahvsh") || embedUrl.contains("streamhide") || embedUrl.contains("guccihide") ||
embedUrl.contains("streamvid") || embedUrl.contains("vidhide") -> StreamHideVidExtractor(client).videosFromUrl(url)
else -> emptyList()
}
}.getOrNull() ?: emptyList()

View file

@ -5,7 +5,7 @@ import eu.kanade.tachiyomi.animesource.AnimeSourceFactory
class PelisplushdFactory : AnimeSourceFactory {
override fun createSources(): List<AnimeSource> = listOf(
Pelisplushd("PelisPlusHD", "https://ww1.pelisplushd.nu"),
Pelisplushd("PelisPlusHD", "https://pelisplushd.bz"),
Pelisplusto("PelisPlusTo", "https://ww3.pelisplus.to"),
Pelisplusph("PelisPlusPh", "https://www.pelisplushd.ph"),
)

View file

@ -2,7 +2,6 @@ package eu.kanade.tachiyomi.animeextension.es.pelisplushd
import androidx.preference.ListPreference
import androidx.preference.PreferenceScreen
import eu.kanade.tachiyomi.animeextension.es.pelisplushd.extractors.StreamHideExtractor
import eu.kanade.tachiyomi.animesource.model.AnimeFilter
import eu.kanade.tachiyomi.animesource.model.AnimeFilterList
import eu.kanade.tachiyomi.animesource.model.SAnime
@ -14,6 +13,7 @@ import eu.kanade.tachiyomi.lib.fastreamextractor.FastreamExtractor
import eu.kanade.tachiyomi.lib.filemoonextractor.FilemoonExtractor
import eu.kanade.tachiyomi.lib.mp4uploadextractor.Mp4uploadExtractor
import eu.kanade.tachiyomi.lib.okruextractor.OkruExtractor
import eu.kanade.tachiyomi.lib.streamhidevidextractor.StreamHideVidExtractor
import eu.kanade.tachiyomi.lib.streamlareextractor.StreamlareExtractor
import eu.kanade.tachiyomi.lib.streamtapeextractor.StreamTapeExtractor
import eu.kanade.tachiyomi.lib.streamwishextractor.StreamWishExtractor
@ -174,7 +174,8 @@ class Pelisplusph(override val name: String, override val baseUrl: String) : Pel
embedUrl.contains("fastream") -> FastreamExtractor(client, headers).videosFromUrl(url, prefix = "$prefix Fastream:")
embedUrl.contains("upstream") -> UpstreamExtractor(client).videosFromUrl(url, prefix = prefix)
embedUrl.contains("streamtape") || embedUrl.contains("stp") || embedUrl.contains("stape") -> listOf(StreamTapeExtractor(client).videoFromUrl(url, quality = "$prefix StreamTape")!!)
embedUrl.contains("ahvsh") || embedUrl.contains("streamhide") || embedUrl.contains("guccihide") || embedUrl.contains("streamvid") -> StreamHideExtractor(client).videosFromUrl(url, "$prefix StreamHide")
embedUrl.contains("ahvsh") || embedUrl.contains("streamhide") || embedUrl.contains("guccihide") ||
embedUrl.contains("streamvid") || embedUrl.contains("vidhide") -> StreamHideVidExtractor(client).videosFromUrl(url, "$prefix ")
else -> emptyList()
}
}.getOrNull() ?: emptyList()

View file

@ -3,7 +3,6 @@ package eu.kanade.tachiyomi.animeextension.es.pelisplushd
import android.util.Base64
import androidx.preference.ListPreference
import androidx.preference.PreferenceScreen
import eu.kanade.tachiyomi.animeextension.es.pelisplushd.extractors.StreamHideExtractor
import eu.kanade.tachiyomi.animesource.model.AnimeFilter
import eu.kanade.tachiyomi.animesource.model.AnimeFilterList
import eu.kanade.tachiyomi.animesource.model.SAnime
@ -15,6 +14,7 @@ import eu.kanade.tachiyomi.lib.fastreamextractor.FastreamExtractor
import eu.kanade.tachiyomi.lib.filemoonextractor.FilemoonExtractor
import eu.kanade.tachiyomi.lib.mp4uploadextractor.Mp4uploadExtractor
import eu.kanade.tachiyomi.lib.okruextractor.OkruExtractor
import eu.kanade.tachiyomi.lib.streamhidevidextractor.StreamHideVidExtractor
import eu.kanade.tachiyomi.lib.streamlareextractor.StreamlareExtractor
import eu.kanade.tachiyomi.lib.streamtapeextractor.StreamTapeExtractor
import eu.kanade.tachiyomi.lib.streamwishextractor.StreamWishExtractor
@ -215,7 +215,8 @@ class Pelisplusto(override val name: String, override val baseUrl: String) : Pel
embedUrl.contains("fastream") -> FastreamExtractor(client, headers).videosFromUrl(url, prefix = "Fastream:")
embedUrl.contains("upstream") -> UpstreamExtractor(client).videosFromUrl(url)
embedUrl.contains("streamtape") || embedUrl.contains("stp") || embedUrl.contains("stape") -> listOf(StreamTapeExtractor(client).videoFromUrl(url, quality = "StreamTape")!!)
embedUrl.contains("ahvsh") || embedUrl.contains("streamhide") || embedUrl.contains("guccihide") || embedUrl.contains("streamvid") -> StreamHideExtractor(client).videosFromUrl(url, "StreamHide")
embedUrl.contains("ahvsh") || embedUrl.contains("streamhide") || embedUrl.contains("guccihide") ||
embedUrl.contains("streamvid") || embedUrl.contains("vidhide") -> StreamHideVidExtractor(client).videosFromUrl(url)
else -> emptyList()
}
}.getOrNull() ?: emptyList()

View file

@ -1,205 +0,0 @@
package eu.kanade.tachiyomi.animeextension.es.pelisplushd.extractors
import java.util.regex.Pattern
import kotlin.math.pow
// https://github.com/cylonu87/JsUnpacker
class JsUnpacker(packedJS: String?) {
private var packedJS: String? = null
/**
* Detects whether the javascript is P.A.C.K.E.R. coded.
*
* @return true if it's P.A.C.K.E.R. coded.
*/
fun detect(): Boolean {
val js = packedJS!!.replace(" ", "")
val p = Pattern.compile("eval\\(function\\(p,a,c,k,e,[rd]")
val m = p.matcher(js)
return m.find()
}
/**
* Unpack the javascript
*
* @return the javascript unpacked or null.
*/
fun unpack(): String? {
val js = packedJS
runCatching {
var p =
Pattern.compile("""\}\s*\('(.*)',\s*(.*?),\s*(\d+),\s*'(.*?)'\.split\('\|'\)""", Pattern.DOTALL)
var m = p.matcher(js)
if (m.find() && m.groupCount() == 4) {
val payload = m.group(1).replace("\\'", "'")
val radixStr = m.group(2)
val countStr = m.group(3)
val symtab = m.group(4).split("\\|".toRegex()).toTypedArray()
val radix = radixStr.toIntOrNull() ?: 36
val count = countStr.toIntOrNull() ?: 0
if (symtab.size != count) {
throw Exception("Unknown p.a.c.k.e.r. encoding")
}
val unbase = Unbase(radix)
p = Pattern.compile("\\b\\w+\\b")
m = p.matcher(payload)
val decoded = StringBuilder(payload)
var replaceOffset = 0
while (m.find()) {
val word = m.group(0)
val x = unbase.unbase(word)
var value: String? = null
if (x < symtab.size && x >= 0) {
value = symtab[x]
}
if (value != null && value.isNotEmpty()) {
decoded.replace(m.start() + replaceOffset, m.end() + replaceOffset, value)
replaceOffset += value.length - word.length
}
}
return decoded.toString()
}
}
return null
}
private inner class Unbase(private val radix: Int) {
private val alphabet62 = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
private val alphabet95 =
" !\"#$%&\\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\\\]^_`abcdefghijklmnopqrstuvwxyz{|}~"
private var alphabet: String? = null
private var dictionary: HashMap<String, Int>? = null
fun unbase(str: String): Int {
var ret = 0
if (alphabet == null) {
ret = str.toInt(radix)
} else {
val tmp = StringBuilder(str).reverse().toString()
for (i in tmp.indices) {
ret += (radix.toDouble().pow(i.toDouble()) * dictionary!![tmp.substring(i, i + 1)]!!).toInt()
}
}
return ret
}
init {
if (radix > 36) {
when {
radix < 62 -> {
alphabet = alphabet62.substring(0, radix)
}
radix in 63..94 -> {
alphabet = alphabet95.substring(0, radix)
}
radix == 62 -> {
alphabet = alphabet62
}
radix == 95 -> {
alphabet = alphabet95
}
}
dictionary = HashMap(95)
for (i in 0 until alphabet!!.length) {
dictionary!![alphabet!!.substring(i, i + 1)] = i
}
}
}
}
/**
* @param packedJS javascript P.A.C.K.E.R. coded.
*/
init {
this.packedJS = packedJS
}
companion object {
private val C =
listOf(
0x63,
0x6f,
0x6d,
0x2e,
0x67,
0x6f,
0x6f,
0x67,
0x6c,
0x65,
0x2e,
0x61,
0x6e,
0x64,
0x72,
0x6f,
0x69,
0x64,
0x2e,
0x67,
0x6d,
0x73,
0x2e,
0x61,
0x64,
0x73,
0x2e,
0x4d,
0x6f,
0x62,
0x69,
0x6c,
0x65,
0x41,
0x64,
0x73,
)
private val Z =
listOf(
0x63,
0x6f,
0x6d,
0x2e,
0x66,
0x61,
0x63,
0x65,
0x62,
0x6f,
0x6f,
0x6b,
0x2e,
0x61,
0x64,
0x73,
0x2e,
0x41,
0x64,
)
fun String.load(): String? {
return try {
var load = this
for (q in C.indices) {
if (C[q % 4] > 270) {
load += C[q % 3]
} else {
load += C[q].toChar()
}
}
Class.forName(load.substring(load.length - C.size, load.length)).name
} catch (_: Exception) {
try {
var f = C[2].toChar().toString()
for (w in Z.indices) {
f += Z[w].toChar()
}
return Class.forName(f.substring(0b001, f.length)).name
} catch (_: Exception) {
null
}
}
}
}
}

View file

@ -1,34 +0,0 @@
package eu.kanade.tachiyomi.animeextension.es.pelisplushd.extractors
import eu.kanade.tachiyomi.animesource.model.Track
import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.network.GET
import okhttp3.OkHttpClient
class StreamHideExtractor(private val client: OkHttpClient) {
// from nineanime / ask4movie FilemoonExtractor
private val subtitleRegex = Regex("""#EXT-X-MEDIA:TYPE=SUBTITLES.*?NAME="(.*?)".*?URI="(.*?)"""")
fun videosFromUrl(url: String, name: String): List<Video> {
val page = client.newCall(GET(url)).execute().body.string()
val unpacked = JsUnpacker(page).unpack() ?: return emptyList()
val playlistUrl = unpacked.substringAfter("sources:")
.substringAfter("file:\"") // StreamHide
.substringAfter("src:\"") // StreamVid
.substringBefore('"')
val playlistData = client.newCall(GET(playlistUrl)).execute().body.string()
val subs = subtitleRegex.findAll(playlistData).map {
val urlPart = it.groupValues[2]
val subUrl = when {
!urlPart.startsWith("https:") ->
playlistUrl.substringBeforeLast("/") + "/$urlPart"
else -> urlPart
}
Track(subUrl, it.groupValues[1])
}.toList()
// The playlist usually only have one video quality.
return listOf(Video(playlistUrl, name, playlistUrl, subtitleTracks = subs))
}
}

View file

@ -1,7 +1,7 @@
ext {
extName = 'FrAnime'
extClass = '.FrAnime'
extVersionCode = 11
extVersionCode = 12
isNsfw = true
}
apply from: "$rootDir/common.gradle"

View file

@ -68,7 +68,7 @@ data class Season(
@Serializable
data class Episode(
@SerialName("title") val title: String,
@SerialName("title") val title: String = "!No Title!",
@SerialName("lang") val languages: EpisodeLanguages,
)

View file

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity
android:name=".pt.otakuanimes.OtakuAnimesUrlActivity"
android:excludeFromRecents="true"
android:exported="true"
android:theme="@android:style/Theme.NoDisplay">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="otakuanimesscc.com"
android:pathPattern="/..*"
android:scheme="https" />
<data
android:host="otakuanimesscc.com"
android:pathPattern="/anime/..*"
android:scheme="https" />
</intent-filter>
</activity>
</application>
</manifest>

View file

@ -0,0 +1,12 @@
ext {
extName = 'OtakuAnimes'
extClass = '.OtakuAnimes'
extVersionCode = 1
isNsfw = true
}
apply from: "$rootDir/common.gradle"
dependencies {
implementation(project(":lib:playlist-utils"))
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View file

@ -0,0 +1,234 @@
package eu.kanade.tachiyomi.animeextension.pt.otakuanimes
import android.app.Application
import androidx.preference.ListPreference
import androidx.preference.PreferenceScreen
import eu.kanade.tachiyomi.animesource.ConfigurableAnimeSource
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.playlistutils.PlaylistUtils
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.awaitSuccess
import eu.kanade.tachiyomi.util.asJsoup
import eu.kanade.tachiyomi.util.parallelCatchingFlatMapBlocking
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Request
import okhttp3.Response
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class OtakuAnimes : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
override val name = "Otaku Animes"
override val baseUrl = "https://otakuanimesscc.com"
override val lang = "pt-BR"
override val supportsLatest = true
private val preferences by lazy {
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
}
override fun headersBuilder() = super.headersBuilder()
.add("Referer", baseUrl)
.add("Origin", baseUrl)
// ============================== Popular ===============================
override fun popularAnimeRequest(page: Int) = GET(baseUrl, headers)
override fun popularAnimeSelector() = "div.calendarioL div.ultAnisContainerItem > a"
override fun popularAnimeFromElement(element: Element) = SAnime.create().apply {
setUrlWithoutDomain(element.attr("href"))
title = element.selectFirst("div.aniNome")!!.text().trim()
thumbnail_url = element.selectFirst("img")?.attr("data-lazy-src")
}
override fun popularAnimeNextPageSelector() = null
// =============================== Latest ===============================
override fun latestUpdatesRequest(page: Int) =
GET("$baseUrl/lista-de-animes/page/$page", headers)
override fun latestUpdatesSelector() = "div.ultAnisContainer div.ultAnisContainerItem > a"
override fun latestUpdatesFromElement(element: Element) = popularAnimeFromElement(element)
override fun latestUpdatesNextPageSelector() = "div.paginacao a.next"
// =============================== Search ===============================
override suspend fun getSearchAnime(
page: Int,
query: String,
filters: AnimeFilterList,
): AnimesPage {
return if (query.startsWith(PREFIX_SEARCH)) {
val path = query.removePrefix(PREFIX_SEARCH)
client.newCall(GET("$baseUrl/$path"))
.awaitSuccess()
.use(::searchAnimeByIdParse)
} else {
super.getSearchAnime(page, query, filters)
}
}
private fun searchAnimeByIdParse(response: Response): AnimesPage {
val details = animeDetailsParse(response).apply {
setUrlWithoutDomain(response.request.url.toString())
initialized = true
}
return AnimesPage(listOf(details), false)
}
override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request {
val url = "$baseUrl/page".toHttpUrl().newBuilder()
.addPathSegment(page.toString())
.addQueryParameter("s", query)
.build()
return GET(url, headers = headers)
}
override fun searchAnimeSelector() = "div.SectionBusca div.ultAnisContainerItem > a"
override fun searchAnimeFromElement(element: Element) = latestUpdatesFromElement(element)
override fun searchAnimeNextPageSelector() = latestUpdatesNextPageSelector()
// =========================== Anime Details ============================
override fun animeDetailsParse(document: Document): SAnime {
val doc = getRealDoc(document)
return SAnime.create().apply {
setUrlWithoutDomain(doc.location())
title = doc.selectFirst("div.animeFirstContainer h1")!!.text()
thumbnail_url = doc.selectFirst("div.animeCapa img")?.attr("data-lazy-src")
description = doc.selectFirst("div.animeSecondContainer > p")?.text()
genre = doc.select("ul.animeGen li").eachText()?.joinToString(", ")
}
}
// ============================== Episodes ==============================
override fun episodeListParse(response: Response): List<SEpisode> {
return getRealDoc(response.asJsoup())
.select(episodeListSelector())
.map(::episodeFromElement)
.reversed()
}
override fun episodeListSelector() = "div.sectionEpiInAnime a"
override fun episodeFromElement(element: Element) = SEpisode.create().apply {
setUrlWithoutDomain(element.attr("href"))
element.text().let {
name = it.trim()
episode_number = name.substringAfterLast(" ").toFloatOrNull() ?: 1F
}
}
// ============================ Video Links =============================
override fun videoListParse(response: Response): List<Video> {
val document = response.asJsoup()
return document.select("#player iframe")
.parallelCatchingFlatMapBlocking {
getVideosFromURL(it.attr("src"))
}
}
private val playlistUtils by lazy { PlaylistUtils(client) }
private fun getVideosFromURL(url: String): List<Video> {
return when {
"playerhls" in url -> {
return client.newCall(GET(url, headers)).execute().body.string()
.substringAfter("sources: [")
.substringBefore("],").split("{").drop(1).map {
val label = it.substringAfter("label: \"")
.substringBefore('"')
val playlistUrl = it.substringAfter("file: '")
.substringBefore("'")
.replace("\\", "")
return playlistUtils.extractFromHls(
playlistUrl,
videoNameGen = { label },
)
}
}
else -> emptyList()
}
}
override fun videoListSelector(): String {
throw UnsupportedOperationException()
}
override fun videoFromElement(element: Element): Video {
throw UnsupportedOperationException()
}
override fun videoUrlParse(document: Document): String {
throw UnsupportedOperationException()
}
// ============================== Settings ==============================
override fun setupPreferenceScreen(screen: PreferenceScreen) {
ListPreference(screen.context).apply {
key = PREF_QUALITY_KEY
title = PREF_QUALITY_TITLE
entries = PREF_QUALITY_VALUES
entryValues = PREF_QUALITY_VALUES
setDefaultValue(PREF_QUALITY_DEFAULT)
summary = "%s"
setOnPreferenceChangeListener { _, newValue ->
val selected = newValue as String
val index = findIndexOfValue(selected)
val entry = entryValues[index] as String
preferences.edit().putString(key, entry).commit()
}
}.also(screen::addPreference)
}
override fun List<Video>.sort(): List<Video> {
val quality = preferences.getString(PREF_QUALITY_KEY, PREF_QUALITY_DEFAULT)!!
return sortedWith(
compareBy(
{ it.quality.contains(quality) },
{ REGEX_QUALITY.find(it.quality)?.groupValues?.get(1)?.toIntOrNull() ?: 0 },
),
).reversed()
}
// ============================= Utilities ==============================
private fun getRealDoc(document: Document): Document {
val menu = document.selectFirst("a.aniBack")
if (menu != null) {
val originalUrl = menu.parent()!!.attr("href")
val response = client.newCall(GET(originalUrl, headers)).execute()
return response.asJsoup()
}
return document
}
companion object {
const val PREFIX_SEARCH = "path:"
private val REGEX_QUALITY by lazy { Regex("""(\d+)p""") }
private const val PREF_QUALITY_KEY = "preferred_quality"
private const val PREF_QUALITY_TITLE = "Qualidade preferida"
private const val PREF_QUALITY_DEFAULT = "720p"
private val PREF_QUALITY_VALUES = arrayOf("360p", "720p", "1080p")
}
}

View file

@ -0,0 +1,46 @@
package eu.kanade.tachiyomi.animeextension.pt.otakuanimes
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Intent
import android.os.Bundle
import android.util.Log
import kotlin.system.exitProcess
/**
* Springboard that accepts https://otakuanimesscc.com/a/<slug> and https://otakuanimesscc.com/<id> intents
* and redirects them to the main Aniyomi process.
*/
class OtakuAnimesUrlActivity : Activity() {
private val tag = javaClass.simpleName
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val pathSegments = intent?.data?.pathSegments
if (pathSegments != null && pathSegments.size > 0) {
val searchQuery = if (pathSegments.size > 1) {
"${pathSegments[0]}/${pathSegments[1]}"
} else {
pathSegments[0]
}
val mainIntent = Intent().apply {
action = "eu.kanade.tachiyomi.ANIMESEARCH"
putExtra("query", "${OtakuAnimes.PREFIX_SEARCH}$searchQuery")
putExtra("filter", packageName)
}
try {
startActivity(mainIntent)
} catch (e: ActivityNotFoundException) {
Log.e(tag, e.toString())
}
} else {
Log.e(tag, "could not parse uri from intent $intent")
}
finish()
exitProcess(0)
}
}

View file

@ -1,7 +1,7 @@
ext {
extName = 'HentaiZM'
extClass = '.HentaiZM'
extVersionCode = 3
extVersionCode = 4
isNsfw = true
}

View file

@ -30,7 +30,7 @@ class HentaiZM : ParsedAnimeHttpSource(), ConfigurableAnimeSource {
override val name = "HentaiZM"
override val baseUrl = "https://www.hentaizm.life"
override val baseUrl = "https://www.hentaizm.pro"
override val lang = "tr"