Initial commit

This commit is contained in:
almightyhak 2024-06-20 11:54:12 +07:00
commit 98ed7e8839
2263 changed files with 108711 additions and 0 deletions

View file

@ -0,0 +1,11 @@
ext {
extName = 'AnimePahe'
extClass = '.AnimePahe'
extVersionCode = 27
}
apply from: "$rootDir/common.gradle"
dependencies {
implementation "dev.datlag.jsunpacker:jsunpacker:1.0.1"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 857 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View file

@ -0,0 +1,381 @@
package eu.kanade.tachiyomi.animeextension.en.animepahe
import android.app.Application
import android.content.SharedPreferences
import androidx.preference.ListPreference
import androidx.preference.PreferenceScreen
import androidx.preference.SwitchPreferenceCompat
import eu.kanade.tachiyomi.animeextension.en.animepahe.dto.EpisodeDto
import eu.kanade.tachiyomi.animeextension.en.animepahe.dto.LatestAnimeDto
import eu.kanade.tachiyomi.animeextension.en.animepahe.dto.ResponseDto
import eu.kanade.tachiyomi.animeextension.en.animepahe.dto.SearchResultDto
import eu.kanade.tachiyomi.animesource.ConfigurableAnimeSource
import eu.kanade.tachiyomi.animesource.model.AnimeFilterList
import eu.kanade.tachiyomi.animesource.model.AnimesPage
import eu.kanade.tachiyomi.animesource.model.SAnime
import eu.kanade.tachiyomi.animesource.model.SEpisode
import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.animesource.online.AnimeHttpSource
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.util.asJsoup
import eu.kanade.tachiyomi.util.parseAs
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import okhttp3.Headers
import okhttp3.Request
import okhttp3.Response
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.text.SimpleDateFormat
import java.util.Locale
import kotlin.math.ceil
import kotlin.math.floor
class AnimePahe : ConfigurableAnimeSource, AnimeHttpSource() {
private val preferences: SharedPreferences by lazy {
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
}
private val interceptor = DdosGuardInterceptor(network.client)
override val client = network.client.newBuilder()
.addInterceptor(interceptor)
.build()
override val name = "AnimePahe"
override val baseUrl by lazy {
preferences.getString(PREF_DOMAIN_KEY, PREF_DOMAIN_DEFAULT)!!
}
override val lang = "en"
override val supportsLatest = true
private val json = Json {
ignoreUnknownKeys = true
}
// =========================== Anime Details ============================
/**
* This override is necessary because AnimePahe does not provide permanent
* URLs to its animes, so we need to fetch the anime session every time.
*
* @see episodeListRequest
*/
override fun animeDetailsRequest(anime: SAnime): Request {
val animeId = anime.getId()
// We're using coroutines here to run it inside another thread and
// prevent android.os.NetworkOnMainThreadException when trying to open
// webview or share it.
val session = runBlocking {
withContext(Dispatchers.IO) {
fetchSession(anime.title, animeId)
}
}
return GET("$baseUrl/anime/$session?anime_id=$animeId")
}
override fun animeDetailsParse(response: Response): SAnime {
val document = response.asJsoup()
return SAnime.create().apply {
title = document.selectFirst("div.title-wrapper > h1 > span")!!.text()
author = document.selectFirst("div.col-sm-4.anime-info p:contains(Studio:)")
?.text()
?.replace("Studio: ", "")
status = parseStatus(document.selectFirst("div.col-sm-4.anime-info p:contains(Status:) a")!!.text())
thumbnail_url = document.selectFirst("div.anime-poster a")!!.attr("href")
genre = document.select("div.anime-genre ul li").joinToString { it.text() }
val synonyms = document.selectFirst("div.col-sm-4.anime-info p:contains(Synonyms:)")
?.text()
description = document.select("div.anime-summary").text() +
if (synonyms.isNullOrEmpty()) "" else "\n\n$synonyms"
}
}
// =============================== Latest ===============================
override fun latestUpdatesRequest(page: Int): Request = GET("$baseUrl/api?m=airing&page=$page")
override fun latestUpdatesParse(response: Response): AnimesPage {
val latestData = response.parseAs<ResponseDto<LatestAnimeDto>>()
val hasNextPage = latestData.currentPage < latestData.lastPage
val animeList = latestData.items.map { anime ->
SAnime.create().apply {
title = anime.title
thumbnail_url = anime.snapshot
val animeId = anime.id
setUrlWithoutDomain("/anime/?anime_id=$animeId")
artist = anime.fansub
}
}
return AnimesPage(animeList, hasNextPage)
}
// =============================== Search ===============================
override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request =
GET("$baseUrl/api?m=search&l=8&q=$query")
override fun searchAnimeParse(response: Response): AnimesPage {
val searchData = response.parseAs<ResponseDto<SearchResultDto>>()
val animeList = searchData.items.map { anime ->
SAnime.create().apply {
title = anime.title
thumbnail_url = anime.poster
val animeId = anime.id
setUrlWithoutDomain("/anime/?anime_id=$animeId")
}
}
return AnimesPage(animeList, false)
}
// ============================== Popular ===============================
// This source doesnt have a popular animes page,
// so we use latest animes page instead.
override suspend fun getPopularAnime(page: Int) = getLatestUpdates(page)
override fun popularAnimeParse(response: Response): AnimesPage = TODO()
override fun popularAnimeRequest(page: Int): Request = TODO()
// ============================== Episodes ==============================
/**
* This override is necessary because AnimePahe does not provide permanent
* URLs to its animes, so we need to fetch the anime session every time.
*
* @see animeDetailsRequest
*/
override fun episodeListRequest(anime: SAnime): Request {
val session = fetchSession(anime.title, anime.getId())
return GET("$baseUrl/api?m=release&id=$session&sort=episode_desc&page=1")
}
override fun episodeListParse(response: Response): List<SEpisode> {
val url = response.request.url.toString()
val session = url.substringAfter("&id=").substringBefore("&")
return recursivePages(response, session)
}
private fun parseEpisodePage(episodes: List<EpisodeDto>, animeSession: String): MutableList<SEpisode> {
return episodes.map { episode ->
SEpisode.create().apply {
date_upload = episode.createdAt.toDate()
val session = episode.session
setUrlWithoutDomain("/play/$animeSession/$session")
val epNum = episode.episodeNumber
episode_number = epNum
val epName = if (floor(epNum) == ceil(epNum)) {
epNum.toInt().toString()
} else {
epNum.toString()
}
name = "Episode $epName"
}
}.toMutableList()
}
private fun recursivePages(response: Response, animeSession: String): List<SEpisode> {
val episodesData = response.parseAs<ResponseDto<EpisodeDto>>()
val page = episodesData.currentPage
val hasNextPage = page < episodesData.lastPage
val returnList = parseEpisodePage(episodesData.items, animeSession)
if (hasNextPage) {
val nextPage = nextPageRequest(response.request.url.toString(), page + 1)
returnList += recursivePages(nextPage, animeSession)
}
return returnList
}
private fun nextPageRequest(url: String, page: Int): Response {
val request = GET(url.substringBeforeLast("&page=") + "&page=$page")
return client.newCall(request).execute()
}
// ============================ Video Links =============================
override fun videoListParse(response: Response): List<Video> {
val document = response.asJsoup()
val downloadLinks = document.select("div#pickDownload > a")
return document.select("div#resolutionMenu > button").mapIndexed { index, btn ->
val kwikLink = btn.attr("data-src")
val quality = btn.text()
val paheWinLink = downloadLinks[index].attr("href")
getVideo(paheWinLink, kwikLink, quality)
}
}
private fun getVideo(paheUrl: String, kwikUrl: String, quality: String): Video {
return if (preferences.getBoolean(PREF_LINK_TYPE_KEY, PREF_LINK_TYPE_DEFAULT)) {
val videoUrl = KwikExtractor(client).getHlsStreamUrl(kwikUrl, referer = baseUrl)
Video(
videoUrl,
quality,
videoUrl,
headers = Headers.headersOf("referer", "https://kwik.cx"),
)
} else {
val videoUrl = KwikExtractor(client).getStreamUrlFromKwik(paheUrl)
Video(videoUrl, quality, videoUrl)
}
}
override fun List<Video>.sort(): List<Video> {
val subPreference = preferences.getString(PREF_SUB_KEY, PREF_SUB_DEFAULT)!!
val quality = preferences.getString(PREF_QUALITY_KEY, PREF_QUALITY_DEFAULT)!!
val shouldBeAv1 = preferences.getBoolean(PREF_AV1_KEY, PREF_AV1_DEFAULT)
val shouldEndWithEng = subPreference == "eng"
return this.sortedWith(
compareBy(
{ it.quality.contains(quality) },
{ Regex("""\beng\b""").containsMatchIn(it.quality.lowercase()) == shouldEndWithEng },
{ it.quality.lowercase().contains("av1") == shouldBeAv1 },
),
).reversed()
}
// ============================== Settings ==============================
override fun setupPreferenceScreen(screen: PreferenceScreen) {
val videoQualityPref = ListPreference(screen.context).apply {
key = PREF_QUALITY_KEY
title = PREF_QUALITY_TITLE
entries = PREF_QUALITY_ENTRIES
entryValues = PREF_QUALITY_ENTRIES
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()
}
}
val domainPref = ListPreference(screen.context).apply {
key = PREF_DOMAIN_KEY
title = PREF_DOMAIN_TITLE
entries = PREF_DOMAIN_ENTRIES
entryValues = PREF_DOMAIN_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()
}
}
val subPref = ListPreference(screen.context).apply {
key = PREF_SUB_KEY
title = PREF_SUB_TITLE
entries = PREF_SUB_ENTRIES
entryValues = PREF_SUB_VALUES
setDefaultValue(PREF_SUB_DEFAULT)
summary = "%s"
setOnPreferenceChangeListener { _, newValue ->
val selected = newValue as String
val index = findIndexOfValue(selected)
val entry = entryValues[index] as String
preferences.edit().putString(key, entry).commit()
}
}
val linkPref = SwitchPreferenceCompat(screen.context).apply {
key = PREF_LINK_TYPE_KEY
title = PREF_LINK_TYPE_TITLE
summary = PREF_LINK_TYPE_SUMMARY
setDefaultValue(PREF_LINK_TYPE_DEFAULT)
setOnPreferenceChangeListener { _, newValue ->
val new = newValue as Boolean
preferences.edit().putBoolean(key, new).commit()
}
}
val av1Pref = SwitchPreferenceCompat(screen.context).apply {
key = PREF_AV1_KEY
title = PREF_AV1_TITLE
summary = PREF_AV1_SUMMARY
setDefaultValue(PREF_AV1_DEFAULT)
setOnPreferenceChangeListener { _, newValue ->
val new = newValue as Boolean
preferences.edit().putBoolean(key, new).commit()
}
}
screen.addPreference(videoQualityPref)
screen.addPreference(domainPref)
screen.addPreference(subPref)
screen.addPreference(linkPref)
screen.addPreference(av1Pref)
}
// ============================= Utilities ==============================
private fun fetchSession(title: String, animeId: String): String {
return client.newCall(GET("$baseUrl/api?m=search&q=$title"))
.execute()
.body.string()
.substringAfter("\"id\":$animeId")
.substringAfter("\"session\":\"")
.substringBefore("\"")
}
private fun parseStatus(statusString: String): Int {
return when (statusString) {
"Currently Airing" -> SAnime.ONGOING
"Finished Airing" -> SAnime.COMPLETED
else -> SAnime.UNKNOWN
}
}
private fun SAnime.getId() = url.substringAfterLast("?anime_id=").substringBefore("\"")
private fun String.toDate(): Long {
return runCatching {
DATE_FORMATTER.parse(this)?.time ?: 0L
}.getOrNull() ?: 0L
}
companion object {
private val DATE_FORMATTER by lazy {
SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.ENGLISH)
}
private const val PREF_QUALITY_KEY = "preffered_quality"
private const val PREF_QUALITY_TITLE = "Preferred quality"
private const val PREF_QUALITY_DEFAULT = "1080p"
private val PREF_QUALITY_ENTRIES = arrayOf("1080p", "720p", "360p")
private const val PREF_DOMAIN_KEY = "preffered_domain"
private const val PREF_DOMAIN_TITLE = "Preferred domain (requires app restart)"
private const val PREF_DOMAIN_DEFAULT = "https://animepahe.com"
private val PREF_DOMAIN_ENTRIES = arrayOf("animepahe.com", "animepahe.ru", "animepahe.org")
private val PREF_DOMAIN_VALUES by lazy {
PREF_DOMAIN_ENTRIES.map { "https://" + it }.toTypedArray()
}
private const val PREF_SUB_KEY = "preffered_sub"
private const val PREF_SUB_TITLE = "Prefer subs or dubs?"
private const val PREF_SUB_DEFAULT = "jpn"
private val PREF_SUB_ENTRIES = arrayOf("sub", "dub")
private val PREF_SUB_VALUES = arrayOf("jpn", "eng")
private const val PREF_LINK_TYPE_KEY = "preffered_link_type"
private const val PREF_LINK_TYPE_TITLE = "Use HLS links"
private const val PREF_LINK_TYPE_DEFAULT = false
private val PREF_LINK_TYPE_SUMMARY by lazy {
"""Enable this if you are having Cloudflare issues.
|Note that this will break the ability to seek inside of the video unless the episode is downloaded in advance.
""".trimMargin()
}
// Big slap to whoever misspelled `preferred`
private const val PREF_AV1_KEY = "preffered_av1"
private const val PREF_AV1_TITLE = "Use AV1 codec"
private const val PREF_AV1_DEFAULT = false
private val PREF_AV1_SUMMARY by lazy {
"""Enable to use AV1 if available
|Turn off to never select av1 as preferred codec
""".trimMargin()
}
}
}

View file

@ -0,0 +1,69 @@
package eu.kanade.tachiyomi.animeextension.en.animepahe
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 = (oldCookie + newCookie).joinToString("; ") {
"${it.name}=${it.value}"
}
return chain.proceed(originalRequest.newBuilder().addHeader("cookie", newCookieHeader).build())
}
fun getNewCookie(url: HttpUrl): Cookie? {
val cookies = cookieManager.getCookie(url.toString())
val oldCookie = if (cookies != null && cookies.isNotEmpty()) {
cookies.split(";").mapNotNull { Cookie.parse(url, it) }
} else {
emptyList()
}
val ddg2Cookie = oldCookie.firstOrNull { it.name == "__ddg2_" }
if (!ddg2Cookie?.value.isNullOrEmpty()) {
return ddg2Cookie
}
val wellKnown = client.newCall(GET("https://check.ddos-guard.net/check.js"))
.execute().body.string()
.substringAfter("'", "")
.substringBefore("'", "")
val checkUrl = "${url.scheme}://${url.host + wellKnown}"
return client.newCall(GET(checkUrl)).execute().header("set-cookie")?.let {
Cookie.parse(url, it)
}
}
companion object {
private val ERROR_CODES = listOf(403)
private val SERVER_CHECK = listOf("ddos-guard")
}
}

View file

@ -0,0 +1,159 @@
/** The following file is slightly modified and taken from: https://github.com/LagradOst/CloudStream-3/blob/4d6050219083d675ba9c7088b59a9492fcaa32c7/app/src/main/java/com/lagradost/cloudstream3/animeproviders/AnimePaheProvider.kt
* It is published under the following license:
*
MIT License
Copyright (c) 2021 Osten
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*
*/
package eu.kanade.tachiyomi.animeextension.en.animepahe
import dev.datlag.jsunpacker.JsUnpacker
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.util.asJsoup
import okhttp3.FormBody
import okhttp3.Headers
import okhttp3.OkHttpClient
import okhttp3.Response
import kotlin.math.pow
class KwikExtractor(private val client: OkHttpClient) {
private var cookies: String = ""
private val kwikParamsRegex = Regex("""\("(\w+)",\d+,"(\w+)",(\d+),(\d+),\d+\)""")
private val kwikDUrl = Regex("action=\"([^\"]+)\"")
private val kwikDToken = Regex("value=\"([^\"]+)\"")
private fun isNumber(s: String?): Boolean {
return s?.toIntOrNull() != null
}
fun getHlsStreamUrl(kwikUrl: String, referer: String): String {
val eContent = client.newCall(GET(kwikUrl, Headers.headersOf("referer", referer)))
.execute().asJsoup()
val script = eContent.selectFirst("script:containsData(eval\\(function)")!!.data().substringAfterLast("eval(function(")
val unpacked = JsUnpacker.unpackAndCombine("eval(function($script")!!
return unpacked.substringAfter("const source=\\'").substringBefore("\\';")
}
fun getStreamUrlFromKwik(paheUrl: String): String {
val noRedirects = client.newBuilder()
.followRedirects(false)
.followSslRedirects(false)
.build()
val kwikUrl = "https://" + noRedirects.newCall(GET("$paheUrl/i")).execute()
.header("location")!!.substringAfterLast("https://")
val fContent =
client.newCall(GET(kwikUrl, Headers.headersOf("referer", "https://kwik.cx/"))).execute()
cookies += fContent.header("set-cookie")!!
val fContentString = fContent.body.string()
val (fullString, key, v1, v2) = kwikParamsRegex.find(fContentString)!!.destructured
val decrypted = decrypt(fullString, key, v1.toInt(), v2.toInt())
val uri = kwikDUrl.find(decrypted)!!.destructured.component1()
val tok = kwikDToken.find(decrypted)!!.destructured.component1()
var content: Response? = null
var code = 419
var tries = 0
val noRedirectClient = OkHttpClient().newBuilder()
.followRedirects(false)
.followSslRedirects(false)
.cookieJar(client.cookieJar)
.build()
while (code != 302 && tries < 20) {
content = noRedirectClient.newCall(
POST(
uri,
Headers.headersOf(
"referer",
fContent.request.url.toString(),
"cookie",
fContent.header("set-cookie")!!.replace("path=/;", ""),
),
FormBody.Builder().add("_token", tok).build(),
),
).execute()
code = content.code
++tries
}
if (tries > 19) {
throw Exception("Failed to extract the stream uri from kwik.")
}
val location = content?.header("location").toString()
content?.close()
return location
}
private fun decrypt(fullString: String, key: String, v1: Int, v2: Int): String {
var r = ""
var i = 0
while (i < fullString.length) {
var s = ""
while (fullString[i] != key[v2]) {
s += fullString[i]
++i
}
var j = 0
while (j < key.length) {
s = s.replace(key[j].toString(), j.toString())
++j
}
r += (getString(s, v2).toInt() - v1).toChar()
++i
}
return r
}
private fun getString(content: String, s1: Int): String {
val s2 = 10
val characterMap = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ+/"
val slice2 = characterMap.slice(0 until s2)
var acc: Long = 0
for ((n, i) in content.reversed().withIndex()) {
acc += when (isNumber("$i")) {
true -> "$i".toLong()
false -> 0L
} * s1.toDouble().pow(n.toDouble()).toInt()
}
var k = ""
while (acc > 0) {
k = slice2[(acc % s2).toInt()] + k
acc = (acc - (acc % s2)) / s2
}
return when (k != "") {
true -> k
false -> "0"
}
}
}

View file

@ -0,0 +1,42 @@
package eu.kanade.tachiyomi.animeextension.en.animepahe.dto
import kotlinx.serialization.EncodeDefault
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class ResponseDto<T>(
@SerialName("current_page")
val currentPage: Int,
@SerialName("last_page")
val lastPage: Int,
@EncodeDefault
@SerialName("data")
val items: List<T> = emptyList(),
)
@Serializable
data class LatestAnimeDto(
@SerialName("anime_title")
val title: String,
val snapshot: String,
@SerialName("anime_id")
val id: Int,
val fansub: String,
)
@Serializable
data class SearchResultDto(
val title: String,
val poster: String,
val id: Int,
)
@Serializable
data class EpisodeDto(
@SerialName("created_at")
val createdAt: String,
val session: String,
@SerialName("episode")
val episodeNumber: Float,
)