Fix neko sama + FRAnime #128
1
.github/pull_request_template.md
vendored
|
@ -8,3 +8,4 @@ Checklist:
|
|||
- [ ] Have explicitly kept the `id` if a source's name or language were changed
|
||||
- [ ] Have tested the modifications by compiling and running the extension through Android Studio
|
||||
- [ ] Have removed `web_hi_res_512.png` when adding a new extension
|
||||
- [ ] Have made sure all the icons are in png format
|
||||
|
|
14
.github/workflows/build_push.yml
vendored
|
@ -40,12 +40,26 @@ jobs:
|
|||
files_separator: " "
|
||||
safe_output: false
|
||||
|
||||
- name: Import GPG key
|
||||
uses: crazy-max/ghaction-import-gpg@v6 # v6.1.0
|
||||
with:
|
||||
gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }}
|
||||
passphrase: ${{ secrets.GPG_PASSPHRASE }}
|
||||
git_user_signingkey: true
|
||||
git_commit_gpgsign: true
|
||||
|
||||
# This step is going to commit, but this will not trigger another workflow.
|
||||
- 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 }}
|
||||
|
||||
# This step is going to commit, but this will not trigger another workflow.
|
||||
- name: Bump extensions that uses a modified lib
|
||||
if: steps.modified-libs.outputs.any_changed == 'true'
|
||||
run: |
|
||||
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
|
||||
|
||||
|
|
19
README.md
|
@ -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
|
||||
|
||||
|
|
5
lib-multisrc/anilist/build.gradle.kts
Normal file
|
@ -0,0 +1,5 @@
|
|||
plugins {
|
||||
id("lib-multisrc")
|
||||
}
|
||||
|
||||
baseVersionCode = 2
|
|
@ -0,0 +1,166 @@
|
|||
package eu.kanade.tachiyomi.multisrc.anilist
|
||||
|
||||
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.online.AnimeHttpSource
|
||||
import eu.kanade.tachiyomi.network.POST
|
||||
import eu.kanade.tachiyomi.util.parseAs
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.FormBody
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
abstract class AniListAnimeHttpSource : AnimeHttpSource() {
|
||||
override val supportsLatest = true
|
||||
val json by injectLazy<Json>()
|
||||
|
||||
/* =============================== Mapping AniList <> Source =============================== */
|
||||
abstract fun mapAnimeDetailUrl(animeId: Int): String
|
||||
|
||||
abstract fun mapAnimeId(animeDetailUrl: String): Int
|
||||
|
||||
open fun getPreferredTitleLanguage(): TitleLanguage {
|
||||
return TitleLanguage.ROMAJI
|
||||
}
|
||||
|
||||
/* ===================================== Popular Anime ===================================== */
|
||||
override fun popularAnimeRequest(page: Int): Request {
|
||||
return buildAnimeListRequest(
|
||||
query = ANIME_LIST_QUERY,
|
||||
variables = AnimeListVariables(
|
||||
page = page,
|
||||
sort = AnimeListVariables.MediaSort.POPULARITY_DESC,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
override fun popularAnimeParse(response: Response): AnimesPage {
|
||||
return parseAnimeListResponse(response)
|
||||
}
|
||||
|
||||
/* ===================================== Latest Anime ===================================== */
|
||||
override fun latestUpdatesRequest(page: Int): Request {
|
||||
return buildAnimeListRequest(
|
||||
query = LATEST_ANIME_LIST_QUERY,
|
||||
variables = AnimeListVariables(
|
||||
page = page,
|
||||
sort = AnimeListVariables.MediaSort.START_DATE_DESC,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
override fun latestUpdatesParse(response: Response): AnimesPage {
|
||||
return parseAnimeListResponse(response)
|
||||
}
|
||||
|
||||
/* ===================================== Search Anime ===================================== */
|
||||
override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request {
|
||||
return buildAnimeListRequest(
|
||||
query = ANIME_LIST_QUERY,
|
||||
variables = AnimeListVariables(
|
||||
page = page,
|
||||
sort = AnimeListVariables.MediaSort.SEARCH_MATCH,
|
||||
search = query.ifBlank { null },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
override fun searchAnimeParse(response: Response): AnimesPage {
|
||||
return parseAnimeListResponse(response)
|
||||
}
|
||||
|
||||
/* ===================================== Anime Details ===================================== */
|
||||
override fun animeDetailsRequest(anime: SAnime): Request {
|
||||
return buildRequest(
|
||||
query = ANIME_DETAILS_QUERY,
|
||||
variables = json.encodeToString(AnimeDetailsVariables(mapAnimeId(anime.url))),
|
||||
)
|
||||
}
|
||||
|
||||
override fun animeDetailsParse(response: Response): SAnime {
|
||||
val media = response.parseAs<AniListAnimeDetailsResponse>().data.media
|
||||
|
||||
return media.toSAnime()
|
||||
}
|
||||
|
||||
override fun getAnimeUrl(anime: SAnime): String {
|
||||
return anime.url
|
||||
}
|
||||
|
||||
/* ==================================== AniList Utility ==================================== */
|
||||
private fun buildAnimeListRequest(
|
||||
query: String,
|
||||
variables: AnimeListVariables,
|
||||
): Request {
|
||||
return buildRequest(query, json.encodeToString(variables))
|
||||
}
|
||||
|
||||
private fun buildRequest(query: String, variables: String): Request {
|
||||
val requestBody = FormBody.Builder()
|
||||
.add("query", query)
|
||||
.add("variables", variables)
|
||||
.build()
|
||||
|
||||
return POST(url = "https://graphql.anilist.co", body = requestBody)
|
||||
}
|
||||
|
||||
private fun parseAnimeListResponse(response: Response): AnimesPage {
|
||||
val page = response.parseAs<AniListAnimeListResponse>().data.page
|
||||
|
||||
return AnimesPage(
|
||||
animes = page.media.map { it.toSAnime() },
|
||||
hasNextPage = page.pageInfo.hasNextPage,
|
||||
)
|
||||
}
|
||||
|
||||
private fun AniListMedia.toSAnime(): SAnime {
|
||||
val otherNames = when (getPreferredTitleLanguage()) {
|
||||
TitleLanguage.ROMAJI -> listOfNotNull(title.english, title.native)
|
||||
TitleLanguage.ENGLISH -> listOfNotNull(title.romaji, title.native)
|
||||
TitleLanguage.NATIVE -> listOfNotNull(title.romaji, title.english)
|
||||
}
|
||||
val newDescription = buildString {
|
||||
append(
|
||||
description
|
||||
?.replace("<br>\n<br>", "\n")
|
||||
?.replace("<.*?>".toRegex(), ""),
|
||||
)
|
||||
if (otherNames.isNotEmpty()) {
|
||||
appendLine()
|
||||
appendLine()
|
||||
append("Other name(s): ${otherNames.joinToString(", ")}")
|
||||
}
|
||||
}
|
||||
val media = this
|
||||
|
||||
return SAnime.create().apply {
|
||||
url = mapAnimeDetailUrl(media.id)
|
||||
title = parseTitle(media.title)
|
||||
author = media.studios.nodes.joinToString(", ") { it.name }
|
||||
description = newDescription
|
||||
genre = media.genres.joinToString(", ")
|
||||
status = when (media.status) {
|
||||
AniListMedia.Status.RELEASING -> SAnime.ONGOING
|
||||
AniListMedia.Status.FINISHED -> SAnime.COMPLETED
|
||||
}
|
||||
thumbnail_url = media.coverImage.large
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseTitle(title: AniListMedia.Title): String {
|
||||
return when (getPreferredTitleLanguage()) {
|
||||
TitleLanguage.ROMAJI -> title.romaji
|
||||
TitleLanguage.ENGLISH -> title.english ?: title.romaji
|
||||
TitleLanguage.NATIVE -> title.native ?: title.romaji
|
||||
}
|
||||
}
|
||||
|
||||
enum class TitleLanguage {
|
||||
ROMAJI,
|
||||
ENGLISH,
|
||||
NATIVE,
|
||||
}
|
||||
}
|
|
@ -0,0 +1,73 @@
|
|||
package eu.kanade.tachiyomi.multisrc.anilist
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
internal const val MEDIA_QUERY = """
|
||||
id
|
||||
title {
|
||||
romaji
|
||||
english
|
||||
native
|
||||
}
|
||||
coverImage {
|
||||
large
|
||||
}
|
||||
description
|
||||
status
|
||||
genres
|
||||
studios(isMain: true) {
|
||||
nodes {
|
||||
name
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
internal const val ANIME_LIST_QUERY = """
|
||||
query (${"$"}page: Int, ${"$"}sort: [MediaSort], ${"$"}search: String) {
|
||||
Page(page: ${"$"}page, perPage: 30) {
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
}
|
||||
media(type: ANIME, sort: ${"$"}sort, search: ${"$"}search, status_in: [RELEASING, FINISHED], countryOfOrigin: "JP", isAdult: false) {
|
||||
$MEDIA_QUERY
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
internal const val LATEST_ANIME_LIST_QUERY = """
|
||||
query (${"$"}page: Int, ${"$"}sort: [MediaSort], ${"$"}search: String) {
|
||||
Page(page: ${"$"}page, perPage: 30) {
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
}
|
||||
media(type: ANIME, sort: ${"$"}sort, search: ${"$"}search, status_in: [RELEASING, FINISHED], countryOfOrigin: "JP", isAdult: false, startDate_greater: 1, episodes_greater: 1) {
|
||||
$MEDIA_QUERY
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
internal const val ANIME_DETAILS_QUERY = """
|
||||
query (${"$"}id: Int) {
|
||||
Media(id: ${"$"}id) {
|
||||
$MEDIA_QUERY
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
@Serializable
|
||||
internal data class AnimeListVariables(
|
||||
val page: Int,
|
||||
val sort: MediaSort,
|
||||
val search: String? = null,
|
||||
) {
|
||||
enum class MediaSort {
|
||||
POPULARITY_DESC,
|
||||
SEARCH_MATCH,
|
||||
START_DATE_DESC,
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
internal data class AnimeDetailsVariables(val id: Int)
|
|
@ -0,0 +1,57 @@
|
|||
package eu.kanade.tachiyomi.multisrc.anilist
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
internal data class AniListAnimeListResponse(val data: Data) {
|
||||
@Serializable
|
||||
data class Data(@SerialName("Page") val page: Page) {
|
||||
@Serializable
|
||||
data class Page(
|
||||
val pageInfo: PageInfo,
|
||||
val media: List<AniListMedia>,
|
||||
) {
|
||||
@Serializable
|
||||
data class PageInfo(val hasNextPage: Boolean)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
internal data class AniListAnimeDetailsResponse(val data: Data) {
|
||||
@Serializable
|
||||
data class Data(@SerialName("Media") val media: AniListMedia)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
internal data class AniListMedia(
|
||||
val id: Int,
|
||||
val title: Title,
|
||||
val coverImage: CoverImage,
|
||||
val description: String?,
|
||||
val status: Status,
|
||||
val genres: List<String>,
|
||||
val studios: Studios,
|
||||
) {
|
||||
@Serializable
|
||||
data class Title(
|
||||
val romaji: String,
|
||||
val english: String?,
|
||||
val native: String?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class CoverImage(val large: String)
|
||||
|
||||
enum class Status {
|
||||
RELEASING,
|
||||
FINISHED,
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class Studios(val nodes: List<Node>) {
|
||||
@Serializable
|
||||
data class Node(val name: String)
|
||||
}
|
||||
}
|
|
@ -1,18 +1,19 @@
|
|||
package eu.kanade.tachiyomi.lib.vidsrcextractor
|
||||
|
||||
import android.util.Base64
|
||||
import app.cash.quickjs.QuickJs
|
||||
import eu.kanade.tachiyomi.animesource.model.Track
|
||||
import eu.kanade.tachiyomi.animesource.model.Video
|
||||
import eu.kanade.tachiyomi.lib.playlistutils.PlaylistUtils
|
||||
import eu.kanade.tachiyomi.lib.vidsrcextractor.MediaResponseBody.Result
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.util.parseAs
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.Serializable
|
||||
import okhttp3.CacheControl
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.Headers
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.net.URLDecoder
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
@ -21,54 +22,32 @@ import javax.crypto.spec.SecretKeySpec
|
|||
class VidsrcExtractor(private val client: OkHttpClient, private val headers: Headers) {
|
||||
|
||||
private val playlistUtils by lazy { PlaylistUtils(client, headers) }
|
||||
private val json: Json by injectLazy()
|
||||
|
||||
private val cacheControl = CacheControl.Builder().noStore().build()
|
||||
private val noCacheClient = client.newBuilder()
|
||||
.cache(null)
|
||||
.build()
|
||||
|
||||
private val keys by lazy {
|
||||
noCacheClient.newCall(
|
||||
GET("https://raw.githubusercontent.com/KillerDogeEmpire/vidplay-keys/keys/keys.json", cache = cacheControl),
|
||||
).execute().parseAs<List<String>>()
|
||||
}
|
||||
|
||||
fun videosFromUrl(embedLink: String, hosterName: String, type: String = "", subtitleList: List<Track> = emptyList()): List<Video> {
|
||||
fun videosFromUrl(
|
||||
embedLink: String,
|
||||
hosterName: String,
|
||||
type: String = "",
|
||||
subtitleList: List<Track> = emptyList(),
|
||||
): List<Video> {
|
||||
val host = embedLink.toHttpUrl().host
|
||||
val apiUrl = getApiUrl(embedLink, keys)
|
||||
val apiUrl = getApiUrl(embedLink)
|
||||
|
||||
val apiHeaders = headers.newBuilder().apply {
|
||||
add("Accept", "application/json, text/javascript, */*; q=0.01")
|
||||
add("Host", host)
|
||||
add("Referer", URLDecoder.decode(embedLink, "UTF-8"))
|
||||
add("X-Requested-With", "XMLHttpRequest")
|
||||
}.build()
|
||||
val response = client.newCall(GET(apiUrl)).execute()
|
||||
val data = response.parseAs<MediaResponseBody>()
|
||||
|
||||
val response = client.newCall(
|
||||
GET(apiUrl, apiHeaders),
|
||||
).execute()
|
||||
|
||||
val data = runCatching {
|
||||
response.parseAs<MediaResponseBody>()
|
||||
}.getOrElse { // Keys are out of date
|
||||
val newKeys = noCacheClient.newCall(
|
||||
GET("https://raw.githubusercontent.com/KillerDogeEmpire/vidplay-keys/keys/keys.json", cache = cacheControl),
|
||||
).execute().parseAs<List<String>>()
|
||||
val newApiUrL = getApiUrl(embedLink, newKeys)
|
||||
client.newCall(
|
||||
GET(newApiUrL, apiHeaders),
|
||||
).execute().parseAs()
|
||||
}
|
||||
val decrypted = vrfDecrypt(data.result)
|
||||
val result = json.decodeFromString<Result>(decrypted)
|
||||
|
||||
return playlistUtils.extractFromHls(
|
||||
data.result.sources.first().file,
|
||||
playlistUrl = result.sources.first().file,
|
||||
referer = "https://$host/",
|
||||
videoNameGen = { q -> hosterName + (if (type.isBlank()) "" else " - $type") + " - $q" },
|
||||
subtitleList = subtitleList + data.result.tracks.toTracks(),
|
||||
subtitleList = subtitleList + result.tracks.toTracks(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun getApiUrl(embedLink: String, keyList: List<String>): String {
|
||||
private fun getApiUrl(embedLink: String): String {
|
||||
val host = embedLink.toHttpUrl().host
|
||||
val params = embedLink.toHttpUrl().let { url ->
|
||||
url.queryParameterNames.map {
|
||||
|
@ -76,13 +55,13 @@ class VidsrcExtractor(private val client: OkHttpClient, private val headers: Hea
|
|||
}
|
||||
}
|
||||
val vidId = embedLink.substringAfterLast("/").substringBefore("?")
|
||||
val encodedID = encodeID(vidId, keyList)
|
||||
val apiSlug = callFromFuToken(host, encodedID, embedLink)
|
||||
val apiSlug = encodeID(vidId, ENCRYPTION_KEY1)
|
||||
val h = encodeID(vidId, ENCRYPTION_KEY2)
|
||||
|
||||
return buildString {
|
||||
append("https://")
|
||||
append(host)
|
||||
append("/")
|
||||
append("/mediainfo/")
|
||||
append(apiSlug)
|
||||
if (params.isNotEmpty()) {
|
||||
append("?")
|
||||
|
@ -91,51 +70,23 @@ class VidsrcExtractor(private val client: OkHttpClient, private val headers: Hea
|
|||
"${it.first}=${it.second}"
|
||||
},
|
||||
)
|
||||
append("&h=$h")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun encodeID(videoID: String, keyList: List<String>): String {
|
||||
val rc4Key1 = SecretKeySpec(keyList[0].toByteArray(), "RC4")
|
||||
val rc4Key2 = SecretKeySpec(keyList[1].toByteArray(), "RC4")
|
||||
val cipher1 = Cipher.getInstance("RC4")
|
||||
val cipher2 = Cipher.getInstance("RC4")
|
||||
cipher1.init(Cipher.DECRYPT_MODE, rc4Key1, cipher1.parameters)
|
||||
cipher2.init(Cipher.DECRYPT_MODE, rc4Key2, cipher2.parameters)
|
||||
var encoded = videoID.toByteArray()
|
||||
|
||||
encoded = cipher1.doFinal(encoded)
|
||||
encoded = cipher2.doFinal(encoded)
|
||||
encoded = Base64.encode(encoded, Base64.DEFAULT)
|
||||
return encoded.toString(Charsets.UTF_8).replace("/", "_").trim()
|
||||
private fun encodeID(videoID: String, key: String): String {
|
||||
val rc4Key = SecretKeySpec(key.toByteArray(), "RC4")
|
||||
val cipher = Cipher.getInstance("RC4")
|
||||
cipher.init(Cipher.DECRYPT_MODE, rc4Key, cipher.parameters)
|
||||
return Base64.encode(cipher.doFinal(videoID.toByteArray()), Base64.DEFAULT)
|
||||
.toString(Charsets.UTF_8)
|
||||
.replace("+", "-")
|
||||
.replace("/", "_")
|
||||
.trim()
|
||||
}
|
||||
|
||||
private fun callFromFuToken(host: String, data: String, embedLink: String): String {
|
||||
val refererHeaders = headers.newBuilder().apply {
|
||||
add("Referer", embedLink)
|
||||
}.build()
|
||||
|
||||
val fuTokenScript = client.newCall(
|
||||
GET("https://$host/futoken", headers = refererHeaders),
|
||||
).execute().body.string()
|
||||
|
||||
val js = buildString {
|
||||
append("(function")
|
||||
append(
|
||||
fuTokenScript.substringAfter("window")
|
||||
.substringAfter("function")
|
||||
.replace("jQuery.ajax(", "")
|
||||
.substringBefore("+location.search"),
|
||||
)
|
||||
append("}(\"$data\"))")
|
||||
}
|
||||
|
||||
return QuickJs.create().use {
|
||||
it.evaluate(js)?.toString()!!
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<MediaResponseBody.Result.SubTrack>.toTracks(): List<Track> {
|
||||
private fun List<Result.SubTrack>.toTracks(): List<Track> {
|
||||
return filter {
|
||||
it.kind == "captions"
|
||||
}.mapNotNull {
|
||||
|
@ -147,17 +98,32 @@ class VidsrcExtractor(private val client: OkHttpClient, private val headers: Hea
|
|||
}.getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
private fun vrfDecrypt(input: String): String {
|
||||
var vrf = Base64.decode(input.toByteArray(), Base64.URL_SAFE)
|
||||
val rc4Key = SecretKeySpec(DECRYPTION_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")
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val ENCRYPTION_KEY1 = "8Qy3mlM2kod80XIK"
|
||||
private const val ENCRYPTION_KEY2 = "BgKVSrzpH2Enosgm"
|
||||
private const val DECRYPTION_KEY = "9jXDYBZUcTcTZveM"
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class MediaResponseBody(
|
||||
val status: Int,
|
||||
val result: Result,
|
||||
val result: String,
|
||||
) {
|
||||
@Serializable
|
||||
data class Result(
|
||||
val sources: ArrayList<Source>,
|
||||
val tracks: ArrayList<SubTrack> = ArrayList(),
|
||||
val sources: List<Source>,
|
||||
val tracks: List<SubTrack> = emptyList(),
|
||||
) {
|
||||
@Serializable
|
||||
data class Source(
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
|
@ -43,7 +54,7 @@ class VoeExtractor(private val client: OkHttpClient) {
|
|||
else -> return emptyList()
|
||||
}
|
||||
return playlistUtils.extractFromHls(playlistUrl,
|
||||
videoNameGen = { quality -> "${prefix}Voe: $quality" }
|
||||
videoNameGen = { quality -> "${prefix}Voe:$quality" }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +0,0 @@
|
|||
ext {
|
||||
extName = 'AnimeFlix'
|
||||
extClass = '.AnimeFlix'
|
||||
extVersionCode = 7
|
||||
}
|
||||
|
||||
apply from: "$rootDir/common.gradle"
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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'))
|
||||
}
|
|
@ -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)!!
|
||||
}
|
|
@ -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,
|
||||
)
|
|
@ -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"),
|
||||
),
|
||||
)
|
13
src/en/aniplay/build.gradle
Normal file
|
@ -0,0 +1,13 @@
|
|||
ext {
|
||||
extName = 'AniPlay'
|
||||
extClass = '.AniPlay'
|
||||
themePkg = 'anilist'
|
||||
overrideVersionCode = 1
|
||||
}
|
||||
|
||||
apply from: "$rootDir/common.gradle"
|
||||
|
||||
dependencies {
|
||||
implementation(project(":lib-multisrc:anilist"))
|
||||
implementation(project(":lib:playlist-utils"))
|
||||
}
|
BIN
src/en/aniplay/ic_launcher-playstore.png
Normal file
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
BIN
src/en/aniplay/res/mipmap-hdpi/ic_launcher.png
Normal file
Before Width: | Height: | Size: 2 KiB After Width: | Height: | Size: 2 KiB |
BIN
src/en/aniplay/res/mipmap-ldpi/ic_launcher.png
Normal file
Before Width: | Height: | Size: 843 B After Width: | Height: | Size: 843 B |
BIN
src/en/aniplay/res/mipmap-mdpi/ic_launcher.png
Normal file
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
BIN
src/en/aniplay/res/mipmap-xhdpi/ic_launcher.png
Normal file
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 2.9 KiB |
BIN
src/en/aniplay/res/mipmap-xxhdpi/ic_launcher.png
Normal file
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 5.2 KiB |
BIN
src/en/aniplay/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
Before Width: | Height: | Size: 7.1 KiB After Width: | Height: | Size: 7.1 KiB |
BIN
src/en/aniplay/res/web_hi_res_512.png
Normal file
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
@ -0,0 +1,375 @@
|
|||
package eu.kanade.tachiyomi.animeextension.en.aniplay
|
||||
|
||||
import android.app.Application
|
||||
import android.util.Base64
|
||||
import android.widget.Toast
|
||||
import androidx.preference.ListPreference
|
||||
import androidx.preference.PreferenceScreen
|
||||
import androidx.preference.SwitchPreferenceCompat
|
||||
import eu.kanade.tachiyomi.animesource.ConfigurableAnimeSource
|
||||
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.lib.playlistutils.PlaylistUtils
|
||||
import eu.kanade.tachiyomi.multisrc.anilist.AniListAnimeHttpSource
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.POST
|
||||
import eu.kanade.tachiyomi.util.parallelFlatMapBlocking
|
||||
import eu.kanade.tachiyomi.util.parseAs
|
||||
import kotlinx.serialization.encodeToString
|
||||
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 java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
class AniPlay : AniListAnimeHttpSource(), ConfigurableAnimeSource {
|
||||
override val name = "AniPlay"
|
||||
override val lang = "en"
|
||||
|
||||
override val baseUrl: String
|
||||
get() = "https://${preferences.getString(PREF_DOMAIN_KEY, PREF_DOMAIN_DEFAULT)}"
|
||||
|
||||
private val playlistUtils by lazy { PlaylistUtils(client, headers) }
|
||||
|
||||
private val preferences by lazy {
|
||||
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
|
||||
}
|
||||
|
||||
/* ================================= AniList configurations ================================= */
|
||||
|
||||
override fun mapAnimeDetailUrl(animeId: Int): String {
|
||||
return "$baseUrl/anime/info/$animeId"
|
||||
}
|
||||
|
||||
override fun mapAnimeId(animeDetailUrl: String): Int {
|
||||
val httpUrl = animeDetailUrl.toHttpUrl()
|
||||
|
||||
return httpUrl.pathSegments[2].toInt()
|
||||
}
|
||||
|
||||
override fun getPreferredTitleLanguage(): TitleLanguage {
|
||||
val preferredLanguage = preferences.getString(PREF_TITLE_LANGUAGE_KEY, PREF_TITLE_LANGUAGE_DEFAULT)
|
||||
|
||||
return when (preferredLanguage) {
|
||||
"romaji" -> TitleLanguage.ROMAJI
|
||||
"english" -> TitleLanguage.ENGLISH
|
||||
"native" -> TitleLanguage.NATIVE
|
||||
else -> TitleLanguage.ROMAJI
|
||||
}
|
||||
}
|
||||
|
||||
/* ====================================== Episode List ====================================== */
|
||||
|
||||
override fun episodeListRequest(anime: SAnime): Request {
|
||||
val httpUrl = anime.url.toHttpUrl()
|
||||
val animeId = httpUrl.pathSegments[2]
|
||||
|
||||
return GET("$baseUrl/api/anime/episode/$animeId")
|
||||
}
|
||||
|
||||
override fun episodeListParse(response: Response): List<SEpisode> {
|
||||
val isMarkFiller = preferences.getBoolean(PREF_MARK_FILLER_EPISODE_KEY, PREF_MARK_FILLER_EPISODE_DEFAULT)
|
||||
val episodeListUrl = response.request.url
|
||||
val animeId = episodeListUrl.pathSegments[3]
|
||||
val providers = response.parseAs<List<EpisodeListResponse>>()
|
||||
val episodes = mutableMapOf<Int, EpisodeListResponse.Episode>()
|
||||
val episodeExtras = mutableMapOf<Int, List<EpisodeExtra>>()
|
||||
|
||||
providers.forEach { provider ->
|
||||
provider.episodes.forEach { episode ->
|
||||
if (!episodes.containsKey(episode.number)) {
|
||||
episodes[episode.number] = episode
|
||||
}
|
||||
val existingEpisodeExtras = episodeExtras.getOrElse(episode.number) { emptyList() }
|
||||
val episodeExtra = EpisodeExtra(
|
||||
source = provider.providerId,
|
||||
episodeId = episode.id,
|
||||
hasDub = episode.hasDub,
|
||||
)
|
||||
episodeExtras[episode.number] = existingEpisodeExtras + listOf(episodeExtra)
|
||||
}
|
||||
}
|
||||
|
||||
return episodes.map { episodeMap ->
|
||||
val episode = episodeMap.value
|
||||
val episodeNumber = episode.number
|
||||
val episodeExtra = episodeExtras.getValue(episodeNumber)
|
||||
val episodeExtraString = json.encodeToString(episodeExtra)
|
||||
.let { Base64.encode(it.toByteArray(), Base64.DEFAULT) }
|
||||
.toString(Charsets.UTF_8)
|
||||
|
||||
val url = baseUrl.toHttpUrl().newBuilder()
|
||||
.addPathSegment("anime")
|
||||
.addPathSegment("watch")
|
||||
.addQueryParameter("id", animeId)
|
||||
.addQueryParameter("ep", episodeNumber.toString())
|
||||
.addQueryParameter("extras", episodeExtraString)
|
||||
.build()
|
||||
|
||||
val name = parseEpisodeName(episodeNumber, episode.title)
|
||||
val uploadDate = parseDate(episode.createdAt)
|
||||
val dub = when {
|
||||
episodeExtra.any { it.hasDub } -> ", Dub"
|
||||
else -> ""
|
||||
}
|
||||
val filler = when {
|
||||
episode.isFiller && isMarkFiller -> " • Filler Episode"
|
||||
else -> ""
|
||||
}
|
||||
val scanlator = "Sub$dub$filler"
|
||||
|
||||
SEpisode.create().apply {
|
||||
this.url = url.toString()
|
||||
this.name = name
|
||||
this.date_upload = uploadDate
|
||||
this.episode_number = episodeNumber.toFloat()
|
||||
this.scanlator = scanlator
|
||||
}
|
||||
}.reversed()
|
||||
}
|
||||
|
||||
/* ======================================= Video List ======================================= */
|
||||
|
||||
override suspend fun getVideoList(episode: SEpisode): List<Video> {
|
||||
val episodeUrl = episode.url.toHttpUrl()
|
||||
val animeId = episodeUrl.queryParameter("id") ?: return emptyList()
|
||||
val episodeNum = episodeUrl.queryParameter("ep") ?: return emptyList()
|
||||
val extras = episodeUrl.queryParameter("extras")
|
||||
?.let {
|
||||
Base64.decode(it, Base64.DEFAULT).toString(Charsets.UTF_8)
|
||||
}
|
||||
?.let { json.decodeFromString<List<EpisodeExtra>>(it) }
|
||||
?: emptyList()
|
||||
|
||||
val episodeDataList = extras.parallelFlatMapBlocking { extra ->
|
||||
val languages = mutableListOf("sub")
|
||||
if (extra.hasDub) {
|
||||
languages.add("dub")
|
||||
}
|
||||
val url = "$baseUrl/api/anime/source/$animeId"
|
||||
|
||||
languages.map { language ->
|
||||
val requestBody = json
|
||||
.encodeToString(
|
||||
VideoSourceRequest(
|
||||
source = extra.source,
|
||||
episodeId = extra.episodeId,
|
||||
episodeNum = episodeNum,
|
||||
subType = language,
|
||||
),
|
||||
)
|
||||
.toRequestBody("application/json".toMediaType())
|
||||
|
||||
val response = client
|
||||
.newCall(POST(url = url, body = requestBody))
|
||||
.execute()
|
||||
.parseAs<VideoSourceResponse>()
|
||||
|
||||
EpisodeData(
|
||||
source = extra.source,
|
||||
language = language,
|
||||
response = response,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val videos = episodeDataList.flatMap { episodeData ->
|
||||
val defaultSource = episodeData.response.sources?.first {
|
||||
it.quality in listOf("default", "auto")
|
||||
} ?: return@flatMap emptyList()
|
||||
|
||||
val subtitles = episodeData.response.subtitles
|
||||
?.filter { it.lang != "Thumbnails" }
|
||||
?.map { Track(it.url, it.lang) }
|
||||
?: emptyList()
|
||||
|
||||
playlistUtils.extractFromHls(
|
||||
playlistUrl = defaultSource.url,
|
||||
videoNameGen = { quality ->
|
||||
val serverName = getServerName(episodeData.source)
|
||||
val typeName = when {
|
||||
subtitles.isNotEmpty() -> "SoftSub"
|
||||
else -> getTypeName(episodeData.language)
|
||||
}
|
||||
|
||||
"$serverName - $quality - $typeName"
|
||||
},
|
||||
subtitleList = subtitles,
|
||||
)
|
||||
}
|
||||
|
||||
return videos.sort()
|
||||
}
|
||||
|
||||
override fun List<Video>.sort(): List<Video> {
|
||||
val quality = preferences.getString(PREF_QUALITY_KEY, PREF_QUALITY_DEFAULT)!!
|
||||
val lang = preferences.getString(PREF_TYPE_KEY, PREF_TYPE_DEFAULT)!!.let(::getTypeName)
|
||||
val server = preferences.getString(PREF_SERVER_KEY, PREF_SERVER_DEFAULT)!!.let(::getServerName)
|
||||
|
||||
return sortedWith(
|
||||
compareByDescending<Video> { it.quality.contains(lang) }
|
||||
.thenByDescending { it.quality.contains(quality) }
|
||||
.thenByDescending { it.quality.contains(server, true) },
|
||||
)
|
||||
}
|
||||
|
||||
/* ====================================== Preferences ====================================== */
|
||||
|
||||
override fun setupPreferenceScreen(screen: PreferenceScreen) {
|
||||
ListPreference(screen.context).apply {
|
||||
key = PREF_DOMAIN_KEY
|
||||
title = "Preferred domain"
|
||||
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
|
||||
Toast.makeText(screen.context, "Restart Aniyomi to apply changes", Toast.LENGTH_LONG).show()
|
||||
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_ENTRY_VALUES
|
||||
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)
|
||||
|
||||
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_TYPE_KEY
|
||||
title = "Preferred type"
|
||||
entries = PREF_TYPE_ENTRIES
|
||||
entryValues = PREF_TYPE_ENTRY_VALUES
|
||||
setDefaultValue(PREF_TYPE_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_LANGUAGE_KEY
|
||||
title = "Preferred title language"
|
||||
entries = PREF_TITLE_LANGUAGE_ENTRIES
|
||||
entryValues = PREF_TITLE_LANGUAGE_ENTRY_VALUES
|
||||
setDefaultValue(PREF_TITLE_LANGUAGE_DEFAULT)
|
||||
summary = "%s"
|
||||
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
val selected = newValue as String
|
||||
val index = findIndexOfValue(selected)
|
||||
val entry = entryValues[index] as String
|
||||
Toast.makeText(screen.context, "Refresh your anime library to apply changes", Toast.LENGTH_LONG).show()
|
||||
preferences.edit().putString(key, entry).commit()
|
||||
}
|
||||
}.also(screen::addPreference)
|
||||
|
||||
SwitchPreferenceCompat(screen.context).apply {
|
||||
key = PREF_MARK_FILLER_EPISODE_KEY
|
||||
title = "Mark filler episodes"
|
||||
setDefaultValue(PREF_MARK_FILLER_EPISODE_DEFAULT)
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
Toast.makeText(screen.context, "Refresh your anime library to apply changes", Toast.LENGTH_LONG).show()
|
||||
preferences.edit().putBoolean(key, newValue as Boolean).commit()
|
||||
}
|
||||
}.also(screen::addPreference)
|
||||
}
|
||||
|
||||
/* =================================== AniPlay Utilities =================================== */
|
||||
|
||||
private fun parseEpisodeName(number: Int, name: String): String {
|
||||
return when {
|
||||
listOf("EP ", "EPISODE ").any(name::startsWith) -> "Episode $number"
|
||||
else -> "Episode $number: $name"
|
||||
}
|
||||
}
|
||||
|
||||
private fun getServerName(value: String): String {
|
||||
val index = PREF_SERVER_ENTRY_VALUES.indexOf(value)
|
||||
return PREF_SERVER_ENTRIES[index]
|
||||
}
|
||||
|
||||
private fun getTypeName(value: String): String {
|
||||
val index = PREF_TYPE_ENTRY_VALUES.indexOf(value)
|
||||
return PREF_TYPE_ENTRIES[index]
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun parseDate(dateStr: String?): Long {
|
||||
return dateStr?.let {
|
||||
runCatching { DATE_FORMATTER.parse(it)?.time }.getOrNull()
|
||||
} ?: 0L
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val PREF_DOMAIN_KEY = "domain"
|
||||
private val PREF_DOMAIN_ENTRIES = arrayOf("aniplaynow.live (default)", "aniplay.lol (backup)")
|
||||
private val PREF_DOMAIN_ENTRY_VALUES = arrayOf("aniplaynow.live", "aniplay.lol")
|
||||
private const val PREF_DOMAIN_DEFAULT = "aniplaynow.live"
|
||||
|
||||
private const val PREF_SERVER_KEY = "server"
|
||||
private val PREF_SERVER_ENTRIES = arrayOf("Kuro (Gogoanime)", "Yuki (HiAnime)", "Yuno (Yugenanime)")
|
||||
private val PREF_SERVER_ENTRY_VALUES = arrayOf("kuro", "yuki", "yuno")
|
||||
private const val PREF_SERVER_DEFAULT = "kuro"
|
||||
|
||||
private const val PREF_QUALITY_KEY = "quality"
|
||||
private val PREF_QUALITY_ENTRIES = arrayOf("1080p", "720p", "480p", "360p")
|
||||
private val PREF_QUALITY_ENTRY_VALUES = arrayOf("1080", "720", "480", "360")
|
||||
private const val PREF_QUALITY_DEFAULT = "1080"
|
||||
|
||||
private const val PREF_TYPE_KEY = "type"
|
||||
private val PREF_TYPE_ENTRIES = arrayOf("Sub", "SoftSub", "Dub")
|
||||
private val PREF_TYPE_ENTRY_VALUES = arrayOf("sub", "softsub", "dub")
|
||||
private const val PREF_TYPE_DEFAULT = "sub"
|
||||
|
||||
private const val PREF_TITLE_LANGUAGE_KEY = "title_language"
|
||||
private val PREF_TITLE_LANGUAGE_ENTRIES = arrayOf("Romaji", "English", "Native")
|
||||
private val PREF_TITLE_LANGUAGE_ENTRY_VALUES = arrayOf("romaji", "english", "native")
|
||||
private const val PREF_TITLE_LANGUAGE_DEFAULT = "romaji"
|
||||
|
||||
private const val PREF_MARK_FILLER_EPISODE_KEY = "mark_filler_episode"
|
||||
private const val PREF_MARK_FILLER_EPISODE_DEFAULT = true
|
||||
|
||||
private val DATE_FORMATTER = SimpleDateFormat("yyyy-MM-dd", Locale.ENGLISH)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,69 @@
|
|||
package eu.kanade.tachiyomi.animeextension.en.aniplay
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class EpisodeListResponse(
|
||||
val episodes: List<Episode>,
|
||||
val providerId: String,
|
||||
val default: Boolean?,
|
||||
) {
|
||||
@Serializable
|
||||
data class Episode(
|
||||
val id: String,
|
||||
val number: Int,
|
||||
val title: String,
|
||||
val hasDub: Boolean,
|
||||
val isFiller: Boolean,
|
||||
val img: String?,
|
||||
val description: String?,
|
||||
val createdAt: String?,
|
||||
)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class VideoSourceRequest(
|
||||
val source: String,
|
||||
|
||||
@SerialName("episodeid")
|
||||
val episodeId: String,
|
||||
|
||||
@SerialName("episodenum")
|
||||
val episodeNum: String,
|
||||
|
||||
@SerialName("subtype")
|
||||
val subType: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class VideoSourceResponse(
|
||||
val sources: List<Source>?,
|
||||
val subtitles: List<Subtitle>?,
|
||||
) {
|
||||
@Serializable
|
||||
data class Source(
|
||||
val url: String,
|
||||
val quality: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Subtitle(
|
||||
val url: String,
|
||||
val lang: String,
|
||||
)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class EpisodeExtra(
|
||||
val source: String,
|
||||
val episodeId: String,
|
||||
val hasDub: Boolean,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class EpisodeData(
|
||||
val source: String,
|
||||
val language: String,
|
||||
val response: VideoSourceResponse,
|
||||
)
|
|
@ -1,7 +1,7 @@
|
|||
ext {
|
||||
extName = 'Aniwave'
|
||||
extClass = '.Aniwave'
|
||||
extVersionCode = 71
|
||||
extVersionCode = 74
|
||||
}
|
||||
|
||||
apply from: "$rootDir/common.gradle"
|
||||
|
|
|
@ -2,6 +2,8 @@ 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
|
||||
|
@ -39,7 +41,12 @@ class Aniwave : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
|
|||
override val id: Long = 98855593379717478
|
||||
|
||||
override val baseUrl by lazy {
|
||||
preferences.getString(PREF_DOMAIN_KEY, PREF_DOMAIN_DEFAULT)!!
|
||||
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"
|
||||
|
@ -90,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(getEncryptionKey(), 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
|
||||
|
@ -117,30 +124,39 @@ 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 {
|
||||
if (it.isNotBlank()) {
|
||||
description = when {
|
||||
description.isNullOrBlank() -> altName + it
|
||||
else -> description + "\n\n$altName" + it
|
||||
val altName = "Other name(s): "
|
||||
newDocument.select("h1.title").attr("data-jp").let {
|
||||
if (it.isNotBlank()) {
|
||||
description = when {
|
||||
description.isNullOrBlank() -> altName + it
|
||||
else -> description + "\n\n$altName" + it
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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(getEncryptionKey(), 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")
|
||||
|
@ -196,7 +212,7 @@ class Aniwave : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
|
|||
|
||||
override fun videoListRequest(episode: SEpisode): Request {
|
||||
val ids = episode.url.substringBefore("&")
|
||||
val vrf = utils.vrfEncrypt(getEncryptionKey(), ids)
|
||||
val vrf = utils.vrfEncrypt(ENCRYPTION_KEY, ids)
|
||||
val url = "/ajax/server/list/$ids?vrf=$vrf"
|
||||
val epurl = episode.url.substringAfter("epurl=")
|
||||
|
||||
|
@ -218,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 ->
|
||||
|
@ -249,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(getEncryptionKey(), server.serverId)
|
||||
val vrf = utils.vrfEncrypt(ENCRYPTION_KEY, server.serverId)
|
||||
|
||||
val listHeaders = headers.newBuilder().apply {
|
||||
add("Accept", "application/json, text/javascript, */*; q=0.01")
|
||||
|
@ -264,18 +280,13 @@ class Aniwave : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
|
|||
|
||||
return runCatching {
|
||||
val parsed = response.parseAs<ServerResponse>()
|
||||
val embedLink = utils.vrfDecrypt(getDecryptionKey(), parsed.result.url)
|
||||
val embedLink = utils.vrfDecrypt(DECRYPTION_KEY, parsed.result.url)
|
||||
when (server.serverName) {
|
||||
"Vidstream", "Megaf" -> {
|
||||
val hosterName = when (server.serverName) {
|
||||
"Vidstream" -> "Vidstream"
|
||||
else -> "Megaf"
|
||||
}
|
||||
vidsrcExtractor.videosFromUrl(embedLink, hosterName, server.type)
|
||||
}
|
||||
"filemoon" -> filemoonExtractor.videosFromUrl(embedLink, "Filemoon - ${server.type} - ")
|
||||
"vidstream" -> vidsrcExtractor.videosFromUrl(embedLink, "Vidstream", server.type)
|
||||
"megaf" -> vidsrcExtractor.videosFromUrl(embedLink, "MegaF", 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() }
|
||||
|
@ -313,20 +324,33 @@ class Aniwave : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
|
|||
}
|
||||
}
|
||||
|
||||
private fun getDecryptionKey(): String {
|
||||
var prefKey = preferences.getString(PREF_VERIFY_KEY_DECRYPT_KEY, null)
|
||||
if (prefKey.isNullOrBlank()) {
|
||||
prefKey = PREF_VERIFY_KEY_DECRYPT_VALUE
|
||||
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 prefKey
|
||||
return document
|
||||
}
|
||||
|
||||
private fun getEncryptionKey(): String {
|
||||
var prefKey = preferences.getString(PREF_VERIFY_KEY_ENCRYPT_KEY, null)
|
||||
if (prefKey.isNullOrBlank()) {
|
||||
prefKey = PREF_VERIFY_KEY_ENCRYPT_VALUE
|
||||
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
|
||||
}
|
||||
}
|
||||
return prefKey
|
||||
|
||||
// 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 {
|
||||
|
@ -340,6 +364,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"
|
||||
|
||||
|
@ -356,16 +382,16 @@ class Aniwave : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
|
|||
private val HOSTERS = arrayOf(
|
||||
"Vidstream",
|
||||
"Megaf",
|
||||
"Filemoon",
|
||||
"MoonF",
|
||||
"StreamTape",
|
||||
"Mp4Upload",
|
||||
"MP4u",
|
||||
)
|
||||
private val HOSTERS_NAMES = arrayOf(
|
||||
"Vidstream",
|
||||
"Megaf",
|
||||
"filemoon",
|
||||
"vidstream",
|
||||
"megaf",
|
||||
"moonf",
|
||||
"streamtape",
|
||||
"mp4upload",
|
||||
"mp4u",
|
||||
)
|
||||
private val PREF_HOSTER_DEFAULT = HOSTERS_NAMES.toSet()
|
||||
|
||||
|
@ -373,22 +399,25 @@ class Aniwave : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
|
|||
private val TYPES = arrayOf("Sub", "Softsub", "Dub")
|
||||
private val PREF_TYPES_TOGGLE_DEFAULT = TYPES.toSet()
|
||||
|
||||
// https://rowdy-avocado.github.io/multi-keys/
|
||||
private const val PREF_VERIFY_KEY_DECRYPT_KEY = "verify_key_decrypt"
|
||||
private const val PREF_VERIFY_KEY_DECRYPT_VALUE = "ctpAbOz5u7S6OMkx"
|
||||
|
||||
private const val PREF_VERIFY_KEY_ENCRYPT_KEY = "verify_key_encrypt"
|
||||
private const val PREF_VERIFY_KEY_ENCRYPT_VALUE = "p01EDKu734HJP1Tm"
|
||||
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"
|
||||
|
||||
|
@ -486,26 +515,27 @@ class Aniwave : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
|
|||
}.also(screen::addPreference)
|
||||
|
||||
EditTextPreference(screen.context).apply {
|
||||
key = PREF_VERIFY_KEY_DECRYPT_KEY
|
||||
title = "Custom decryption key"
|
||||
setDefaultValue("")
|
||||
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val newKey = newValue as String
|
||||
preferences.edit().putString(key, newKey).commit()
|
||||
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!"
|
||||
}
|
||||
}.also(screen::addPreference)
|
||||
|
||||
EditTextPreference(screen.context).apply {
|
||||
key = PREF_VERIFY_KEY_ENCRYPT_KEY
|
||||
title = "Custom encryption key"
|
||||
setDefaultValue("")
|
||||
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val newKey = newValue as String
|
||||
preferences.edit().putString(key, newKey).commit()
|
||||
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)
|
||||
}
|
||||
|
|
22
src/es/cineplus123/AndroidManifest.xml
Normal file
|
@ -0,0 +1,22 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application>
|
||||
<activity
|
||||
android:name=".es.cineplus123.Cineplus123UrlActivity"
|
||||
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="cineplus123.org"
|
||||
android:pathPattern="/anime/..*"
|
||||
android:scheme="https" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
14
src/es/cineplus123/build.gradle
Normal file
|
@ -0,0 +1,14 @@
|
|||
ext {
|
||||
extName = 'Cineplus123'
|
||||
extClass = '.Cineplus123'
|
||||
themePkg = 'dooplay'
|
||||
baseUrl = 'https://cineplus123.org'
|
||||
overrideVersionCode = 0
|
||||
}
|
||||
|
||||
apply from: "$rootDir/common.gradle"
|
||||
|
||||
dependencies {
|
||||
implementation(project(":lib:streamwish-extractor"))
|
||||
implementation(project(":lib:uqload-extractor"))
|
||||
}
|
BIN
src/es/cineplus123/res/mipmap-hdpi/ic_launcher.png
Normal file
Before Width: | Height: | Size: 6.7 KiB After Width: | Height: | Size: 6.7 KiB |
BIN
src/es/cineplus123/res/mipmap-hdpi/ic_launcher_adaptive_back.png
Normal file
Before Width: | Height: | Size: 6 KiB After Width: | Height: | Size: 6 KiB |
BIN
src/es/cineplus123/res/mipmap-hdpi/ic_launcher_adaptive_fore.png
Normal file
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
BIN
src/es/cineplus123/res/mipmap-mdpi/ic_launcher.png
Normal file
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.6 KiB |
BIN
src/es/cineplus123/res/mipmap-mdpi/ic_launcher_adaptive_back.png
Normal file
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 3.3 KiB |
BIN
src/es/cineplus123/res/mipmap-mdpi/ic_launcher_adaptive_fore.png
Normal file
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 5.3 KiB |
BIN
src/es/cineplus123/res/mipmap-xhdpi/ic_launcher.png
Normal file
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 9.3 KiB |
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
BIN
src/es/cineplus123/res/mipmap-xxhdpi/ic_launcher.png
Normal file
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
BIN
src/es/cineplus123/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 52 KiB |
|
@ -0,0 +1,196 @@
|
|||
package eu.kanade.tachiyomi.animeextension.es.cineplus123
|
||||
|
||||
import androidx.preference.ListPreference
|
||||
import androidx.preference.PreferenceScreen
|
||||
import eu.kanade.tachiyomi.animesource.model.AnimeFilterList
|
||||
import eu.kanade.tachiyomi.animesource.model.Video
|
||||
import eu.kanade.tachiyomi.lib.streamwishextractor.StreamWishExtractor
|
||||
import eu.kanade.tachiyomi.lib.uqloadextractor.UqloadExtractor
|
||||
import eu.kanade.tachiyomi.multisrc.dooplay.DooPlay
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.POST
|
||||
import eu.kanade.tachiyomi.util.asJsoup
|
||||
import eu.kanade.tachiyomi.util.parallelFlatMapBlocking
|
||||
import okhttp3.FormBody
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.jsoup.nodes.Element
|
||||
|
||||
class Cineplus123 : DooPlay(
|
||||
"es",
|
||||
"Cineplus123",
|
||||
"https://cineplus123.org",
|
||||
) {
|
||||
// ============================== Popular ===============================
|
||||
override fun popularAnimeRequest(page: Int) = GET("$baseUrl/tendencias/$page")
|
||||
|
||||
override fun popularAnimeSelector() = latestUpdatesSelector()
|
||||
|
||||
override fun popularAnimeNextPageSelector() = latestUpdatesNextPageSelector()
|
||||
|
||||
override fun latestUpdatesRequest(page: Int) = GET("$baseUrl/ano/2024/page/$page", headers)
|
||||
|
||||
override fun videoListSelector() = "li.dooplay_player_option" // ul#playeroptionsul
|
||||
|
||||
override val episodeMovieText = "Película"
|
||||
|
||||
override val episodeSeasonPrefix = "Temporada"
|
||||
override val prefQualityTitle = "Calidad preferida"
|
||||
|
||||
private val uqloadExtractor by lazy { UqloadExtractor(client) }
|
||||
private val streamWishExtractor by lazy { StreamWishExtractor(client, headers) }
|
||||
|
||||
// ============================ Video Links =============================
|
||||
override fun videoListParse(response: Response): List<Video> {
|
||||
val document = response.asJsoup()
|
||||
val players = document.select("ul#playeroptionsul li")
|
||||
return players.parallelFlatMapBlocking { player ->
|
||||
val name = player.selectFirst("span.title")!!.text()
|
||||
val url = getPlayerUrl(player)
|
||||
?: return@parallelFlatMapBlocking emptyList<Video>()
|
||||
extractVideos(url, name)
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractVideos(url: String, lang: String): List<Video> {
|
||||
return when {
|
||||
"uqload" in url -> uqloadExtractor.videosFromUrl(url, "$lang -")
|
||||
"strwish" in url -> streamWishExtractor.videosFromUrl(url, lang)
|
||||
else -> null
|
||||
} ?: emptyList()
|
||||
}
|
||||
|
||||
private fun getPlayerUrl(player: Element): String? {
|
||||
val body = FormBody.Builder()
|
||||
.add("action", "doo_player_ajax")
|
||||
.add("post", player.attr("data-post"))
|
||||
.add("nume", player.attr("data-nume"))
|
||||
.add("type", player.attr("data-type"))
|
||||
.build()
|
||||
|
||||
return client.newCall(POST("$baseUrl/wp-admin/admin-ajax.php", headers, body))
|
||||
.execute().body.string()
|
||||
.substringAfter("\"embed_url\":\"")
|
||||
.substringBefore("\",")
|
||||
.replace("\\", "")
|
||||
.takeIf(String::isNotBlank)
|
||||
}
|
||||
|
||||
// ============================== Filters ===============================
|
||||
override val fetchGenres = false
|
||||
|
||||
override fun getFilterList() = Cineplus123Filters.FILTER_LIST
|
||||
|
||||
override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request {
|
||||
val params = Cineplus123Filters.getSearchParameters(filters)
|
||||
val path = when {
|
||||
params.genre.isNotBlank() -> {
|
||||
if (params.genre in listOf("tendencias", "ratings", "series-de-tv", "peliculas")) {
|
||||
"/${params.genre}"
|
||||
} else {
|
||||
"/genero/${params.genre}"
|
||||
}
|
||||
}
|
||||
params.language.isNotBlank() -> "/genero/${params.language}"
|
||||
params.year.isNotBlank() -> "/ano/${params.year}"
|
||||
params.movie.isNotBlank() -> {
|
||||
if (params.movie == "Peliculas") {
|
||||
"/peliculas"
|
||||
} else {
|
||||
"/genero/${params.movie}"
|
||||
}
|
||||
}
|
||||
else -> buildString {
|
||||
append(
|
||||
when {
|
||||
query.isNotBlank() -> "/?s=$query"
|
||||
else -> "/"
|
||||
},
|
||||
)
|
||||
|
||||
append(
|
||||
when (params.type) {
|
||||
"serie" -> "serie-de-tv"
|
||||
"pelicula" -> "peliculas"
|
||||
else -> "tendencias"
|
||||
},
|
||||
|
||||
)
|
||||
|
||||
if (params.isInverted) append("&orden=asc")
|
||||
}
|
||||
}
|
||||
|
||||
return if (path.startsWith("/?s=")) {
|
||||
GET("$baseUrl/page/$page$path")
|
||||
} else {
|
||||
GET("$baseUrl$path/page/$page")
|
||||
}
|
||||
}
|
||||
|
||||
override fun setupPreferenceScreen(screen: PreferenceScreen) {
|
||||
super.setupPreferenceScreen(screen) // Quality preference
|
||||
|
||||
val langPref = ListPreference(screen.context).apply {
|
||||
key = PREF_LANG_KEY
|
||||
title = PREF_LANG_TITLE
|
||||
entries = PREF_LANG_ENTRIES
|
||||
entryValues = PREF_LANG_VALUES
|
||||
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()
|
||||
}
|
||||
}
|
||||
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)
|
||||
screen.addPreference(langPref)
|
||||
}
|
||||
|
||||
// ============================= Utilities ==============================
|
||||
override fun String.toDate() = 0L
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
override val prefQualityValues = arrayOf("480p", "720p", "1080p")
|
||||
override val prefQualityEntries = prefQualityValues
|
||||
|
||||
companion object {
|
||||
private const val PREF_LANG_KEY = "preferred_lang"
|
||||
private const val PREF_LANG_TITLE = "Preferred language"
|
||||
private const val PREF_LANG_DEFAULT = "LATINO"
|
||||
private const val PREF_SERVER_KEY = "preferred_server"
|
||||
private const val PREF_SERVER_DEFAULT = "Uqload"
|
||||
private val PREF_LANG_ENTRIES = arrayOf("SUBTITULADO", "LATINO", "CASTELLANO")
|
||||
private val PREF_LANG_VALUES = arrayOf("SUBTITULADO", "LATINO", "CASTELLANO")
|
||||
private val SERVER_LIST = arrayOf("StreamWish", "Uqload")
|
||||
}
|
||||
}
|
|
@ -0,0 +1,159 @@
|
|||
package eu.kanade.tachiyomi.animeextension.es.cineplus123
|
||||
|
||||
import eu.kanade.tachiyomi.animesource.model.AnimeFilter
|
||||
import eu.kanade.tachiyomi.animesource.model.AnimeFilterList
|
||||
|
||||
object Cineplus123Filters {
|
||||
|
||||
open class UriPartFilter(
|
||||
displayName: String,
|
||||
private val vals: Array<Pair<String, String>>,
|
||||
) : AnimeFilter.Select<String>(
|
||||
displayName,
|
||||
vals.map { it.first }.toTypedArray(),
|
||||
) {
|
||||
|
||||
fun toUriPart() = vals[state].second
|
||||
}
|
||||
|
||||
private inline fun <reified R> AnimeFilterList.getFirst(): R {
|
||||
return first { it is R } as R
|
||||
}
|
||||
|
||||
private inline fun <reified R> AnimeFilterList.asUriPart(): String {
|
||||
return getFirst<R>().let {
|
||||
(it as UriPartFilter).toUriPart()
|
||||
}
|
||||
}
|
||||
|
||||
class InvertedResultsFilter : AnimeFilter.CheckBox("Invertir resultados", false)
|
||||
class TypeFilter : UriPartFilter("Tipo", AnimesOnlineNinjaData.TYPES)
|
||||
|
||||
class GenreFilter : UriPartFilter("Generos", AnimesOnlineNinjaData.GENRES)
|
||||
class LanguageFilter : UriPartFilter("Idiomas", AnimesOnlineNinjaData.LANGUAGES)
|
||||
class YearFilter : UriPartFilter("Año", AnimesOnlineNinjaData.YEARS)
|
||||
class MovieFilter : UriPartFilter("Peliculas", AnimesOnlineNinjaData.MOVIES)
|
||||
|
||||
class OtherOptionsGroup : AnimeFilter.Group<UriPartFilter>(
|
||||
"Otros filtros",
|
||||
listOf(
|
||||
GenreFilter(),
|
||||
LanguageFilter(),
|
||||
YearFilter(),
|
||||
MovieFilter(),
|
||||
),
|
||||
)
|
||||
|
||||
private inline fun <reified R> AnimeFilter.Group<UriPartFilter>.getItemUri(): String {
|
||||
return state.first { it is R }.toUriPart()
|
||||
}
|
||||
|
||||
val FILTER_LIST get() = AnimeFilterList(
|
||||
InvertedResultsFilter(),
|
||||
TypeFilter(),
|
||||
AnimeFilter.Separator(),
|
||||
AnimeFilter.Header("Estos filtros no afectan a la busqueda por texto"),
|
||||
OtherOptionsGroup(),
|
||||
)
|
||||
|
||||
data class FilterSearchParams(
|
||||
val isInverted: Boolean = false,
|
||||
val type: String = "",
|
||||
val genre: String = "",
|
||||
val language: String = "",
|
||||
val year: String = "",
|
||||
val movie: String = "",
|
||||
)
|
||||
|
||||
internal fun getSearchParameters(filters: AnimeFilterList): FilterSearchParams {
|
||||
if (filters.isEmpty()) return FilterSearchParams()
|
||||
|
||||
val others = filters.getFirst<OtherOptionsGroup>()
|
||||
|
||||
return FilterSearchParams(
|
||||
filters.getFirst<InvertedResultsFilter>().state,
|
||||
filters.asUriPart<TypeFilter>(),
|
||||
others.getItemUri<GenreFilter>(),
|
||||
others.getItemUri<LanguageFilter>(),
|
||||
others.getItemUri<YearFilter>(),
|
||||
others.getItemUri<MovieFilter>(),
|
||||
)
|
||||
}
|
||||
|
||||
private object AnimesOnlineNinjaData {
|
||||
val EVERY = Pair("Seleccionar", "")
|
||||
|
||||
val TYPES = arrayOf(
|
||||
Pair("Todos", "todos"),
|
||||
Pair("Series", "serie"),
|
||||
Pair("Peliculas", "pelicula"),
|
||||
)
|
||||
|
||||
val GENRES = arrayOf(
|
||||
EVERY,
|
||||
Pair("accion", "accion"),
|
||||
Pair("action-adventure", "action-adventure"),
|
||||
Pair("animacion", "animacion"),
|
||||
Pair("aventura", "aventura"),
|
||||
Pair("bajalogratis", "bajalogratis"),
|
||||
Pair("belica", "belica"),
|
||||
Pair("ciencia-ficcion", "ciencia-ficcion"),
|
||||
Pair("comedia", "comedia"),
|
||||
Pair("crimen", "crimen"),
|
||||
Pair("disney", "disney"),
|
||||
Pair("documental", "documental"),
|
||||
Pair("don-torrent", "don-torrent"),
|
||||
Pair("drama", "drama"),
|
||||
Pair("familia", "familia"),
|
||||
Pair("fantasia", "fantasia"),
|
||||
Pair("gran-torrent", "gran-torrent"),
|
||||
Pair("hbo", "hbo"),
|
||||
Pair("historia", "historia"),
|
||||
Pair("kids", "kids"),
|
||||
Pair("misterio", "misterio"),
|
||||
Pair("musica", "musica"),
|
||||
Pair("romance", "romance"),
|
||||
Pair("sci-fi-fantasy", "sci-fi-fantasy"),
|
||||
Pair("series-de-amazon-prime-video", "series-de-amazon-prime-video"),
|
||||
Pair("soap", "soap"),
|
||||
Pair("suspense", "suspense"),
|
||||
Pair("talk", "talk"),
|
||||
Pair("terror", "terror"),
|
||||
Pair("war-politics", "war-politics"),
|
||||
Pair("western", "western"),
|
||||
)
|
||||
|
||||
val LANGUAGES = arrayOf(
|
||||
EVERY,
|
||||
Pair("latino", "latino"),
|
||||
Pair("castellano", "castellano"),
|
||||
Pair("subtitulado", "subtitulado"),
|
||||
)
|
||||
|
||||
val YEARS = arrayOf(EVERY) + (2024 downTo 1979).map {
|
||||
Pair(it.toString(), it.toString())
|
||||
}.toTypedArray()
|
||||
|
||||
val MOVIES = arrayOf(
|
||||
EVERY,
|
||||
Pair("pelicula", "pelicula"),
|
||||
Pair("series", "series de tv"),
|
||||
Pair("pelicula-de-tv", "pelicula-de-tv"),
|
||||
Pair("peliculas-cristianas", "peliculas-cristianas"),
|
||||
Pair("peliculas-de-halloween", "peliculas-de-halloween"),
|
||||
Pair("peliculas-de-navidad", "peliculas-de-navidad"),
|
||||
Pair("peliculas-para-el-dia-de-la-madre", "peliculas-para-el-dia-de-la-madre"),
|
||||
Pair("pelis-play", "pelis-play"),
|
||||
Pair("pelishouse", "pelishouse"),
|
||||
Pair("pelismart-tv", "pelismart-tv"),
|
||||
Pair("pelisnow", "pelisnow"),
|
||||
Pair("pelix-tv", "pelix-tv"),
|
||||
Pair("poseidonhd", "poseidonhd"),
|
||||
Pair("proximamente", "proximamente"),
|
||||
Pair("reality", "reality"),
|
||||
Pair("repelis-go", "repelis-go"),
|
||||
Pair("repelishd-tv", "repelishd-tv"),
|
||||
Pair("repelisplus", "repelisplus"),
|
||||
)
|
||||
}
|
||||
}
|
|
@ -1,7 +1,7 @@
|
|||
ext {
|
||||
extName = 'Hackstore'
|
||||
extClass = '.Hackstore'
|
||||
extVersionCode = 10
|
||||
extVersionCode = 11
|
||||
}
|
||||
|
||||
apply from: "$rootDir/common.gradle"
|
||||
|
|
|
@ -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" })
|
||||
|
|
|
@ -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"
|
||||
}
|
|
@ -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,32 +110,37 @@ 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 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 url = it.attr("onclick")
|
||||
.substringAfter("go_to_player('")
|
||||
.substringAfter("go_to_playerVast('")
|
||||
.substringBefore("?cover_url=")
|
||||
.substringBefore("')")
|
||||
.substringBefore("',")
|
||||
.substringBefore("?poster")
|
||||
.substringBefore("?c_poster=")
|
||||
.substringBefore("?thumb=")
|
||||
.substringBefore("#poster=")
|
||||
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 = 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('")
|
||||
.substringBefore("?cover_url=")
|
||||
.substringBefore("')")
|
||||
.substringBefore("',")
|
||||
.substringBefore("?poster")
|
||||
.substringBefore("?c_poster=")
|
||||
.substringBefore("?thumb=")
|
||||
.substringBefore("#poster=")
|
||||
|
||||
val realUrl = if (!regIsUrl.containsMatchIn(url)) {
|
||||
String(Base64.decode(url, Base64.DEFAULT))
|
||||
} else if (url.contains("?data=")) {
|
||||
val apiPageSoup = client.newCall(GET(url)).execute().asJsoup()
|
||||
apiPageSoup.selectFirst("iframe")?.attr("src") ?: ""
|
||||
} else {
|
||||
url
|
||||
}
|
||||
val realUrl = if (!regIsUrl.containsMatchIn(url)) {
|
||||
String(Base64.decode(url, Base64.DEFAULT))
|
||||
} else if (url.contains("?data=")) {
|
||||
val apiPageSoup = client.newCall(GET(url)).execute().asJsoup()
|
||||
apiPageSoup.selectFirst("iframe")?.attr("src") ?: ""
|
||||
} else {
|
||||
url
|
||||
}
|
||||
|
||||
serverVideoResolver(realUrl)
|
||||
}.also(videoList::addAll)
|
||||
serverVideoResolver(realUrl)
|
||||
}.getOrNull() ?: emptyList()
|
||||
}.also(videoList::addAll)
|
||||
}
|
||||
}
|
||||
|
||||
// verifier for old series
|
||||
|
@ -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()
|
||||
|
|
|
@ -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"),
|
||||
)
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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))
|
||||
}
|
||||
}
|
|
@ -72,6 +72,7 @@ data class Episode(
|
|||
@SerialName("lang") val languages: EpisodeLanguages,
|
||||
)
|
||||
|
||||
|
||||
@Serializable
|
||||
data class EpisodeLanguages(
|
||||
@SerialName("vf") val vf: EpisodeLanguage,
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
ext {
|
||||
extName = 'HentaiZM'
|
||||
extClass = '.HentaiZM'
|
||||
extVersionCode = 3
|
||||
extVersionCode = 4
|
||||
isNsfw = true
|
||||
}
|
||||
|
||||
|
|
|
@ -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"
|
||||
|
||||
|
|