feat(multisrc): New theme: AnimeStream (#1653)
@ -0,0 +1,6 @@
|
||||
dependencies {
|
||||
implementation(project(':lib-okru-extractor'))
|
||||
implementation(project(':lib-gdriveplayer-extractor'))
|
||||
implementation(project(':lib-streamsb-extractor'))
|
||||
implementation "dev.datlag.jsunpacker:jsunpacker:1.0.1"
|
||||
}
|
After Width: | Height: | Size: 3.9 KiB |
After Width: | Height: | Size: 2.0 KiB |
After Width: | Height: | Size: 6.2 KiB |
After Width: | Height: | Size: 13 KiB |
After Width: | Height: | Size: 21 KiB |
110
multisrc/overrides/animestream/animexin/src/AnimeXin.kt
Normal file
@ -0,0 +1,110 @@
|
||||
package eu.kanade.tachiyomi.animeextension.all.animexin
|
||||
|
||||
import androidx.preference.ListPreference
|
||||
import androidx.preference.PreferenceScreen
|
||||
import eu.kanade.tachiyomi.animeextension.all.animexin.extractors.DailymotionExtractor
|
||||
import eu.kanade.tachiyomi.animeextension.all.animexin.extractors.DoodExtractor
|
||||
import eu.kanade.tachiyomi.animeextension.all.animexin.extractors.VidstreamingExtractor
|
||||
import eu.kanade.tachiyomi.animeextension.all.animexin.extractors.YouTubeExtractor
|
||||
import eu.kanade.tachiyomi.animesource.model.Video
|
||||
import eu.kanade.tachiyomi.lib.gdriveplayerextractor.GdrivePlayerExtractor
|
||||
import eu.kanade.tachiyomi.lib.okruextractor.OkruExtractor
|
||||
import eu.kanade.tachiyomi.lib.streamsbextractor.StreamSBExtractor
|
||||
import eu.kanade.tachiyomi.multisrc.animestream.AnimeStream
|
||||
|
||||
class AnimeXin : AnimeStream(
|
||||
"all",
|
||||
"AnimeXin",
|
||||
"https://animexin.vip",
|
||||
) {
|
||||
override val id = 4620219025406449669
|
||||
|
||||
// ============================ Video Links =============================
|
||||
override fun getVideoList(url: String, name: String): List<Video> {
|
||||
val streamSbDomains = listOf(
|
||||
"sbhight", "sbrity", "sbembed.com", "sbembed1.com", "sbplay.org",
|
||||
"sbvideo.net", "streamsb.net", "sbplay.one", "cloudemb.com",
|
||||
"playersb.com", "tubesb.com", "sbplay1.com", "embedsb.com",
|
||||
"watchsb.com", "sbplay2.com", "japopav.tv", "viewsb.com",
|
||||
"sbfast", "sbfull.com", "javplaya.com", "ssbstream.net",
|
||||
"p1ayerjavseen.com", "sbthe.com", "vidmovie.xyz", "sbspeed.com",
|
||||
"streamsss.net", "sblanh.com", "tvmshow.com", "sbanh.com",
|
||||
"streamovies.xyz",
|
||||
)
|
||||
val prefix = "$name - "
|
||||
return when {
|
||||
url.contains("ok.ru") -> {
|
||||
OkruExtractor(client).videosFromUrl(url, prefix = prefix)
|
||||
}
|
||||
streamSbDomains.any { it in url } -> {
|
||||
StreamSBExtractor(client).videosFromUrl(url, headers, prefix = prefix)
|
||||
}
|
||||
url.contains("dailymotion") -> {
|
||||
DailymotionExtractor(client).videosFromUrl(url, prefix = prefix)
|
||||
}
|
||||
url.contains("https://dood") -> {
|
||||
DoodExtractor(client).videosFromUrl(url, quality = name)
|
||||
}
|
||||
url.contains("gdriveplayer") -> {
|
||||
val gdriveHeaders = headersBuilder()
|
||||
.add("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8")
|
||||
.add("Host", "gdriveplayer.to")
|
||||
.add("Referer", "$baseUrl/")
|
||||
.build()
|
||||
GdrivePlayerExtractor(client).videosFromUrl(url, name = name, headers = gdriveHeaders)
|
||||
}
|
||||
url.contains("youtube.com") -> {
|
||||
YouTubeExtractor(client).videosFromUrl(url, prefix = prefix)
|
||||
}
|
||||
url.contains("vidstreaming") -> {
|
||||
VidstreamingExtractor(client).videosFromUrl(url, prefix = prefix)
|
||||
}
|
||||
else -> emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== Settings ==============================
|
||||
override fun setupPreferenceScreen(screen: PreferenceScreen) {
|
||||
super.setupPreferenceScreen(screen) // Quality preferences
|
||||
val videoLangPref = ListPreference(screen.context).apply {
|
||||
key = PREF_LANG_KEY
|
||||
title = PREF_LANG_TITLE
|
||||
entries = PREF_LANG_VALUES
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
screen.addPreference(videoLangPref)
|
||||
}
|
||||
|
||||
// ============================= Utilities ==============================
|
||||
override fun List<Video>.sort(): List<Video> {
|
||||
val quality = preferences.getString(prefQualityKey, prefQualityDefault)!!
|
||||
val language = preferences.getString(PREF_LANG_KEY, PREF_LANG_DEFAULT)!!
|
||||
|
||||
return sortedWith(
|
||||
compareBy(
|
||||
{ it.quality.contains(quality) },
|
||||
{ it.quality.contains(language, true) },
|
||||
),
|
||||
).reversed()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val PREF_LANG_KEY = "preferred_language"
|
||||
private const val PREF_LANG_TITLE = "Preferred Video Language"
|
||||
private const val PREF_LANG_DEFAULT = "All Sub"
|
||||
private val PREF_LANG_VALUES = arrayOf(
|
||||
"All Sub", "Arabic", "English", "German", "Indonesia", "Italian",
|
||||
"Polish", "Portuguese", "Spanish", "Thai", "Turkish",
|
||||
)
|
||||
}
|
||||
}
|
@ -0,0 +1,91 @@
|
||||
package eu.kanade.tachiyomi.animeextension.all.animexin.extractors
|
||||
|
||||
import eu.kanade.tachiyomi.animesource.model.Track
|
||||
import eu.kanade.tachiyomi.animesource.model.Video
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
@Serializable
|
||||
data class DailyQuality(
|
||||
val qualities: Auto,
|
||||
val subtitles: Subtitle? = null,
|
||||
) {
|
||||
@Serializable
|
||||
data class Auto(
|
||||
val auto: List<Item>,
|
||||
) {
|
||||
@Serializable
|
||||
data class Item(
|
||||
val type: String,
|
||||
val url: String,
|
||||
)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class Subtitle(
|
||||
val data: Map<String, SubtitleObject>,
|
||||
) {
|
||||
@Serializable
|
||||
data class SubtitleObject(
|
||||
val label: String,
|
||||
val urls: List<String>,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class DailymotionExtractor(private val client: OkHttpClient) {
|
||||
|
||||
private val json: Json by injectLazy()
|
||||
|
||||
fun videosFromUrl(url: String, prefix: String): List<Video> {
|
||||
val videoList = mutableListOf<Video>()
|
||||
val htmlString = client.newCall(GET(url)).execute().body.string()
|
||||
|
||||
val internalData = htmlString.substringAfter("\"dmInternalData\":").substringBefore("</script>")
|
||||
val ts = internalData.substringAfter("\"ts\":").substringBefore(",")
|
||||
val v1st = internalData.substringAfter("\"v1st\":\"").substringBefore("\",")
|
||||
|
||||
val jsonUrl = "https://www.dailymotion.com/player/metadata/video/${url.toHttpUrl().encodedPath}?locale=en-US&dmV1st=$v1st&dmTs=$ts&is_native_app=0"
|
||||
val parsed = json.decodeFromString<DailyQuality>(
|
||||
client.newCall(GET(jsonUrl))
|
||||
.execute().body.string(),
|
||||
)
|
||||
|
||||
val subtitleList = mutableListOf<Track>()
|
||||
if (parsed.subtitles != null) {
|
||||
try {
|
||||
subtitleList.addAll(
|
||||
parsed.subtitles.data.map { k ->
|
||||
Track(
|
||||
k.value.urls.first(),
|
||||
k.value.label,
|
||||
)
|
||||
},
|
||||
)
|
||||
} catch (a: Exception) { }
|
||||
}
|
||||
|
||||
val masterUrl = parsed.qualities.auto.first().url
|
||||
|
||||
val masterPlaylist = client.newCall(GET(masterUrl)).execute().body.string()
|
||||
|
||||
val separator = "#EXT-X-STREAM-INF"
|
||||
masterPlaylist.substringAfter(separator).split(separator).map {
|
||||
val quality = it.substringAfter("RESOLUTION=").substringAfter("x").substringBefore(",NAME") + "p"
|
||||
var videoUrl = it.substringAfter("\n").substringBefore("\n")
|
||||
|
||||
try {
|
||||
videoList.add(Video(videoUrl, prefix + quality, videoUrl, subtitleTracks = subtitleList))
|
||||
} catch (a: Exception) {
|
||||
videoList.add(Video(videoUrl, prefix + quality, videoUrl))
|
||||
}
|
||||
}
|
||||
|
||||
return videoList
|
||||
}
|
||||
}
|
@ -0,0 +1,80 @@
|
||||
package eu.kanade.tachiyomi.animeextension.all.animexin.extractors
|
||||
|
||||
import eu.kanade.tachiyomi.animesource.model.Track
|
||||
import eu.kanade.tachiyomi.animesource.model.Video
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import okhttp3.Headers
|
||||
import okhttp3.OkHttpClient
|
||||
|
||||
class DoodExtractor(private val client: OkHttpClient) {
|
||||
|
||||
fun videoFromUrl(
|
||||
url: String,
|
||||
quality: String? = null,
|
||||
redirect: Boolean = true,
|
||||
): Video? {
|
||||
val newQuality = quality ?: "Doodstream" + if (redirect) " mirror" else ""
|
||||
|
||||
return try {
|
||||
val response = client.newCall(GET(url)).execute()
|
||||
val newUrl = if (redirect) response.request.url.toString() else url
|
||||
|
||||
val doodTld = newUrl.substringAfter("https://dood.").substringBefore("/")
|
||||
val content = response.body.string()
|
||||
|
||||
val subtitleList = mutableListOf<Track>()
|
||||
val subtitleRegex = """src:'//(srt[^']*?)',\s*label:'([^']*?)'""".toRegex()
|
||||
try {
|
||||
subtitleList.addAll(
|
||||
subtitleRegex.findAll(content).map {
|
||||
Track(
|
||||
"https://" + it.groupValues[1],
|
||||
it.groupValues[2],
|
||||
)
|
||||
},
|
||||
)
|
||||
} catch (a: Exception) { }
|
||||
|
||||
if (!content.contains("'/pass_md5/")) return null
|
||||
val md5 = content.substringAfter("'/pass_md5/").substringBefore("',")
|
||||
val token = md5.substringAfterLast("/")
|
||||
val randomString = getRandomString()
|
||||
val expiry = System.currentTimeMillis()
|
||||
val videoUrlStart = client.newCall(
|
||||
GET(
|
||||
"https://dood.$doodTld/pass_md5/$md5",
|
||||
Headers.headersOf("referer", newUrl),
|
||||
),
|
||||
).execute().body.string()
|
||||
val videoUrl = "$videoUrlStart$randomString?token=$token&expiry=$expiry"
|
||||
try {
|
||||
Video(newUrl, newQuality, videoUrl, headers = doodHeaders(doodTld), subtitleTracks = subtitleList)
|
||||
} catch (a: Exception) {
|
||||
Video(newUrl, newQuality, videoUrl, headers = doodHeaders(doodTld))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun videosFromUrl(
|
||||
url: String,
|
||||
quality: String? = null,
|
||||
redirect: Boolean = true,
|
||||
): List<Video> {
|
||||
val video = videoFromUrl(url, quality, redirect)
|
||||
return video?.let { listOf(it) } ?: emptyList<Video>()
|
||||
}
|
||||
|
||||
private fun getRandomString(length: Int = 10): String {
|
||||
val allowedChars = ('A'..'Z') + ('a'..'z') + ('0'..'9')
|
||||
return (1..length)
|
||||
.map { allowedChars.random() }
|
||||
.joinToString("")
|
||||
}
|
||||
|
||||
private fun doodHeaders(tld: String) = Headers.Builder().apply {
|
||||
add("User-Agent", "Aniyomi")
|
||||
add("Referer", "https://dood.$tld/")
|
||||
}.build()
|
||||
}
|
@ -0,0 +1,127 @@
|
||||
package eu.kanade.tachiyomi.animeextension.all.animexin.extractors
|
||||
|
||||
import android.util.Base64
|
||||
import eu.kanade.tachiyomi.animesource.model.Video
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.util.asJsoup
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import okhttp3.Headers
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.lang.Exception
|
||||
import java.util.Locale
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.spec.IvParameterSpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
@ExperimentalSerializationApi
|
||||
class VidstreamingExtractor(private val client: OkHttpClient) {
|
||||
|
||||
private val json: Json by injectLazy()
|
||||
|
||||
fun videosFromUrl(serverUrl: String, prefix: String): List<Video> {
|
||||
try {
|
||||
val document = client.newCall(GET(serverUrl)).execute().asJsoup()
|
||||
val iv = document.select("div.wrapper")
|
||||
.attr("class").substringAfter("container-")
|
||||
.filter { it.isDigit() }.toByteArray()
|
||||
val secretKey = document.select("body[class]")
|
||||
.attr("class").substringAfter("container-")
|
||||
.filter { it.isDigit() }.toByteArray()
|
||||
val decryptionKey = document.select("div.videocontent")
|
||||
.attr("class").substringAfter("videocontent-")
|
||||
.filter { it.isDigit() }.toByteArray()
|
||||
val encryptAjaxParams = cryptoHandler(
|
||||
document.select("script[data-value]")
|
||||
.attr("data-value"),
|
||||
iv,
|
||||
secretKey,
|
||||
false,
|
||||
).substringAfter("&")
|
||||
|
||||
val httpUrl = serverUrl.toHttpUrl()
|
||||
val host = "https://" + httpUrl.host + "/"
|
||||
val id = httpUrl.queryParameter("id") ?: throw Exception("error getting id")
|
||||
val encryptedId = cryptoHandler(id, iv, secretKey)
|
||||
val token = httpUrl.queryParameter("token")
|
||||
val qualitySuffix = if (token != null) " (Vid-mp4 - Gogostream)" else " (Vid-mp4 - Vidstreaming)"
|
||||
|
||||
val jsonResponse = client.newCall(
|
||||
GET(
|
||||
"${host}encrypt-ajax.php?id=$encryptedId&$encryptAjaxParams&alias=$id",
|
||||
Headers.headersOf(
|
||||
"X-Requested-With",
|
||||
"XMLHttpRequest",
|
||||
),
|
||||
),
|
||||
).execute().body.string()
|
||||
val data = json.decodeFromString<JsonObject>(jsonResponse)["data"]!!.jsonPrimitive.content
|
||||
val decryptedData = cryptoHandler(data, iv, decryptionKey, false)
|
||||
val videoList = mutableListOf<Video>()
|
||||
val autoList = mutableListOf<Video>()
|
||||
val array = json.decodeFromString<JsonObject>(decryptedData)["source"]!!.jsonArray
|
||||
if (array.size == 1 && array[0].jsonObject["type"]!!.jsonPrimitive.content == "hls") {
|
||||
val fileURL = array[0].jsonObject["file"].toString().trim('"')
|
||||
val masterPlaylist = client.newCall(GET(fileURL)).execute().body.string()
|
||||
masterPlaylist.substringAfter("#EXT-X-STREAM-INF:")
|
||||
.split("#EXT-X-STREAM-INF:").forEach {
|
||||
val quality = it.substringAfter("RESOLUTION=").substringAfter("x").substringBefore(",").substringBefore("\n") + "p"
|
||||
var videoUrl = it.substringAfter("\n").substringBefore("\n")
|
||||
if (!videoUrl.startsWith("http")) {
|
||||
videoUrl = fileURL.substringBeforeLast("/") + "/$videoUrl"
|
||||
}
|
||||
videoList.add(Video(videoUrl, prefix + quality + qualitySuffix, videoUrl))
|
||||
}
|
||||
} else {
|
||||
array.forEach {
|
||||
val label = it.jsonObject["label"].toString().lowercase(Locale.ROOT)
|
||||
.trim('"').replace(" ", "")
|
||||
val fileURL = it.jsonObject["file"].toString().trim('"')
|
||||
val videoHeaders = Headers.headersOf("Referer", serverUrl)
|
||||
if (label == "auto") {
|
||||
autoList.add(
|
||||
Video(
|
||||
fileURL,
|
||||
label + qualitySuffix,
|
||||
fileURL,
|
||||
headers = videoHeaders,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
videoList.add(Video(fileURL, label + qualitySuffix, fileURL, headers = videoHeaders))
|
||||
}
|
||||
}
|
||||
}
|
||||
return videoList.sortedByDescending {
|
||||
it.quality.substringBefore(qualitySuffix).substringBefore("p").toIntOrNull() ?: -1
|
||||
} + autoList
|
||||
} catch (e: Exception) {
|
||||
return emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun cryptoHandler(
|
||||
string: String,
|
||||
iv: ByteArray,
|
||||
secretKeyString: ByteArray,
|
||||
encrypt: Boolean = true,
|
||||
): String {
|
||||
val ivParameterSpec = IvParameterSpec(iv)
|
||||
val secretKey = SecretKeySpec(secretKeyString, "AES")
|
||||
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
|
||||
return if (!encrypt) {
|
||||
cipher.init(Cipher.DECRYPT_MODE, secretKey, ivParameterSpec)
|
||||
String(cipher.doFinal(Base64.decode(string, Base64.DEFAULT)))
|
||||
} else {
|
||||
cipher.init(Cipher.ENCRYPT_MODE, secretKey, ivParameterSpec)
|
||||
Base64.encodeToString(cipher.doFinal(string.toByteArray()), Base64.NO_WRAP)
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,182 @@
|
||||
package eu.kanade.tachiyomi.animeextension.all.animexin.extractors
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import eu.kanade.tachiyomi.animesource.model.Track
|
||||
import eu.kanade.tachiyomi.animesource.model.Video
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.POST
|
||||
import eu.kanade.tachiyomi.util.asJsoup
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.long
|
||||
import okhttp3.Headers
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import kotlin.math.abs
|
||||
|
||||
class YouTubeExtractor(private val client: OkHttpClient) {
|
||||
|
||||
private val json: Json by injectLazy()
|
||||
|
||||
fun videosFromUrl(url: String, prefix: String): List<Video> {
|
||||
// Ported from https://github.com/dermasmid/scrapetube/blob/master/scrapetube/scrapetube.py
|
||||
// TODO: Make code prettier
|
||||
// GET KEY
|
||||
|
||||
var ytcfgString = ""
|
||||
val videoId = url.substringAfter("/embed/")
|
||||
|
||||
val document = client.newCall(
|
||||
GET(url.replace("/embed/", "/watch?v=")),
|
||||
).execute().asJsoup()
|
||||
|
||||
for (element in document.select("script")) {
|
||||
val scriptData = element.data()
|
||||
if (scriptData.startsWith("(function() {window.ytplayer={};")) {
|
||||
ytcfgString = scriptData
|
||||
}
|
||||
}
|
||||
|
||||
val apiKey = getKey(ytcfgString, "innertubeApiKey")
|
||||
|
||||
val playerUrl = "https://www.youtube.com/youtubei/v1/player?key=$apiKey&prettyPrint=false"
|
||||
|
||||
val body = """
|
||||
{
|
||||
"context":{
|
||||
"client":{
|
||||
"clientName":"ANDROID",
|
||||
"clientVersion":"17.31.35",
|
||||
"androidSdkVersion":30,
|
||||
"userAgent":"com.google.android.youtube/17.31.35 (Linux; U; Android 11) gzip",
|
||||
"hl":"en",
|
||||
"timeZone":"UTC",
|
||||
"utcOffsetMinutes":0
|
||||
}
|
||||
},
|
||||
"videoId":"$videoId",
|
||||
"params":"8AEB",
|
||||
"playbackContext":{
|
||||
"contentPlaybackContext":{
|
||||
"html5Preference":"HTML5_PREF_WANTS"
|
||||
}
|
||||
},
|
||||
"contentCheckOk":true,
|
||||
"racyCheckOk":true
|
||||
}
|
||||
""".trimIndent().toRequestBody("application/json".toMediaType())
|
||||
|
||||
val headers = Headers.headersOf(
|
||||
"X-YouTube-Client-Name", "3",
|
||||
"X-YouTube-Client-Version", "17.31.35",
|
||||
"Origin", "https://www.youtube.com",
|
||||
"User-Agent", "com.google.android.youtube/17.31.35 (Linux; U; Android 11) gzip",
|
||||
"content-type", "application/json",
|
||||
)
|
||||
|
||||
val postResponse = client.newCall(
|
||||
POST(playerUrl, headers = headers, body = body),
|
||||
).execute()
|
||||
|
||||
val responseObject = json.decodeFromString<JsonObject>(postResponse.body.string())
|
||||
val videoList = mutableListOf<Video>()
|
||||
|
||||
val formats = responseObject["streamingData"]!!
|
||||
.jsonObject["adaptiveFormats"]!!
|
||||
.jsonArray
|
||||
|
||||
val audioTracks = mutableListOf<Track>()
|
||||
val subtitleTracks = mutableListOf<Track>()
|
||||
|
||||
// Get Audio
|
||||
for (format in formats) {
|
||||
if (format.jsonObject["mimeType"]!!.jsonPrimitive.content.startsWith("audio/webm")) {
|
||||
try {
|
||||
audioTracks.add(
|
||||
Track(
|
||||
format.jsonObject["url"]!!.jsonPrimitive.content,
|
||||
format.jsonObject["audioQuality"]!!.jsonPrimitive.content +
|
||||
" (${formatBits(format.jsonObject["averageBitrate"]!!.jsonPrimitive.long)}ps)",
|
||||
),
|
||||
)
|
||||
} catch (a: Exception) { }
|
||||
}
|
||||
}
|
||||
|
||||
// Get Subtitles
|
||||
if (responseObject.containsKey("captions")) {
|
||||
val captionTracks = responseObject["captions"]!!
|
||||
.jsonObject["playerCaptionsTracklistRenderer"]!!
|
||||
.jsonObject["captionTracks"]!!
|
||||
.jsonArray
|
||||
|
||||
for (caption in captionTracks) {
|
||||
val captionJson = caption.jsonObject
|
||||
try {
|
||||
subtitleTracks.add(
|
||||
Track(
|
||||
// TODO: Would replacing srv3 with vtt work for every video?
|
||||
captionJson["baseUrl"]!!.jsonPrimitive.content.replace("srv3", "vtt"),
|
||||
captionJson["name"]!!.jsonObject["runs"]!!.jsonArray[0].jsonObject["text"]!!.jsonPrimitive.content,
|
||||
),
|
||||
)
|
||||
} catch (a: Exception) { }
|
||||
}
|
||||
}
|
||||
|
||||
// List formats
|
||||
for (format in formats) {
|
||||
val mimeType = format.jsonObject["mimeType"]!!.jsonPrimitive.content
|
||||
if (mimeType.startsWith("video/mp4")) {
|
||||
videoList.add(
|
||||
try {
|
||||
Video(
|
||||
format.jsonObject["url"]!!.jsonPrimitive.content,
|
||||
prefix + format.jsonObject["qualityLabel"]!!.jsonPrimitive.content +
|
||||
" (${mimeType.substringAfter("codecs=\"").substringBefore("\"")})",
|
||||
format.jsonObject["url"]!!.jsonPrimitive.content,
|
||||
audioTracks = audioTracks,
|
||||
subtitleTracks = subtitleTracks,
|
||||
)
|
||||
} catch (a: Exception) {
|
||||
Video(
|
||||
format.jsonObject["url"]!!.jsonPrimitive.content,
|
||||
prefix + format.jsonObject["qualityLabel"]!!.jsonPrimitive.content +
|
||||
" (${mimeType.substringAfter("codecs=\"").substringBefore("\"")})",
|
||||
format.jsonObject["url"]!!.jsonPrimitive.content,
|
||||
)
|
||||
},
|
||||
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return videoList
|
||||
}
|
||||
|
||||
fun getKey(string: String, key: String): String {
|
||||
var pattern = Regex("\"$key\":\"(.*?)\"")
|
||||
return pattern.find(string)?.groupValues?.get(1) ?: ""
|
||||
}
|
||||
|
||||
@SuppressLint("DefaultLocale")
|
||||
fun formatBits(size: Long): String {
|
||||
var bits = abs(size)
|
||||
if (bits < 1000) {
|
||||
return "${bits}b"
|
||||
}
|
||||
val iterator = "kMGTPE".iterator()
|
||||
var currentChar = iterator.next()
|
||||
while (bits >= 999950 && iterator.hasNext()) {
|
||||
bits /= 1000
|
||||
currentChar = iterator.next()
|
||||
}
|
||||
return "%.0f%cb".format(bits / 1000.0, currentChar)
|
||||
}
|
||||
}
|
23
multisrc/overrides/animestream/default/AndroidManifest.xml
Normal file
@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<application>
|
||||
<activity
|
||||
android:name="eu.kanade.tachiyomi.multisrc.animestream.AnimeStreamUrlActivity"
|
||||
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="${SOURCEHOST}"
|
||||
android:pathPattern="/..*"
|
||||
android:scheme="${SOURCESCHEME}" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
3
multisrc/overrides/animestream/lmanime/additional.gradle
Normal file
@ -0,0 +1,3 @@
|
||||
dependencies {
|
||||
implementation(project(":lib-okru-extractor"))
|
||||
}
|
After Width: | Height: | Size: 3.5 KiB |
After Width: | Height: | Size: 1.9 KiB |
After Width: | Height: | Size: 5.0 KiB |
After Width: | Height: | Size: 9.2 KiB |
After Width: | Height: | Size: 13 KiB |
113
multisrc/overrides/animestream/lmanime/src/LMAnime.kt
Normal file
@ -0,0 +1,113 @@
|
||||
package eu.kanade.tachiyomi.animeextension.all.lmanime
|
||||
|
||||
import androidx.preference.ListPreference
|
||||
import androidx.preference.MultiSelectListPreference
|
||||
import androidx.preference.PreferenceScreen
|
||||
import eu.kanade.tachiyomi.animeextension.all.lmanime.extractors.DailymotionExtractor
|
||||
import eu.kanade.tachiyomi.animesource.model.Video
|
||||
import eu.kanade.tachiyomi.lib.okruextractor.OkruExtractor
|
||||
import eu.kanade.tachiyomi.multisrc.animestream.AnimeStream
|
||||
import eu.kanade.tachiyomi.util.asJsoup
|
||||
import okhttp3.Response
|
||||
|
||||
class LMAnime : AnimeStream(
|
||||
"all",
|
||||
"LMAnime",
|
||||
"https://lmanime.com",
|
||||
) {
|
||||
// ============================ Video Links =============================
|
||||
override val prefQualityValues = arrayOf("144p", "288p", "480p", "720p", "1080p")
|
||||
override val prefQualityEntries = prefQualityValues
|
||||
|
||||
override fun videoListParse(response: Response): List<Video> {
|
||||
val items = response.asJsoup().select(videoListSelector())
|
||||
val allowed = preferences.getStringSet(PREF_ALLOWED_LANGS_KEY, PREF_ALLOWED_LANGS_DEFAULT)!!
|
||||
return items
|
||||
.filter { element ->
|
||||
val text = element.text()
|
||||
allowed.any { it in text }
|
||||
}.parallelMap {
|
||||
val language = it.text().substringBefore(" ")
|
||||
val url = getHosterUrl(it)
|
||||
getVideoList(url, language)
|
||||
}.flatten()
|
||||
}
|
||||
|
||||
override fun getVideoList(url: String, name: String): List<Video> {
|
||||
val prefix = "$name -"
|
||||
return when {
|
||||
"ok.ru" in url ->
|
||||
OkruExtractor(client).videosFromUrl(url, prefix)
|
||||
"dailymotion.com" in url ->
|
||||
DailymotionExtractor(client).videosFromUrl(url, "Dailymotion ($name)")
|
||||
else -> emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== Settings ==============================
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun setupPreferenceScreen(screen: PreferenceScreen) {
|
||||
super.setupPreferenceScreen(screen) // Quality preferences
|
||||
val langPref = ListPreference(screen.context).apply {
|
||||
key = PREF_LANG_KEY
|
||||
title = PREF_LANG_TITLE
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
val allowedPref = MultiSelectListPreference(screen.context).apply {
|
||||
key = PREF_ALLOWED_LANGS_KEY
|
||||
title = PREF_ALLOWED_LANGS_TITLE
|
||||
entries = PREF_ALLOWED_LANGS_ENTRIES
|
||||
entryValues = PREF_ALLOWED_LANGS_ENTRIES
|
||||
setDefaultValue(PREF_ALLOWED_LANGS_DEFAULT)
|
||||
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
preferences.edit().putStringSet(key, newValue as Set<String>).commit()
|
||||
}
|
||||
}
|
||||
|
||||
screen.addPreference(langPref)
|
||||
screen.addPreference(allowedPref)
|
||||
}
|
||||
|
||||
// ============================= Utilities ==============================
|
||||
override fun List<Video>.sort(): List<Video> {
|
||||
val quality = preferences.getString(prefQualityKey, prefQualityDefault)!!
|
||||
val lang = preferences.getString(PREF_LANG_KEY, PREF_LANG_DEFAULT)!!
|
||||
return sortedWith(
|
||||
compareBy(
|
||||
{ it.quality.contains(quality) },
|
||||
{ it.quality.contains(lang, true) },
|
||||
),
|
||||
).reversed()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val PREF_LANG_KEY = "pref_language"
|
||||
private const val PREF_LANG_TITLE = "Preferred language"
|
||||
private const val PREF_LANG_DEFAULT = "English"
|
||||
private val PREF_LANG_ENTRIES = arrayOf(
|
||||
"English",
|
||||
"Español",
|
||||
"Indonesian",
|
||||
"Portugués",
|
||||
"Türkçe",
|
||||
"العَرَبِيَّة",
|
||||
"ไทย",
|
||||
)
|
||||
|
||||
private const val PREF_ALLOWED_LANGS_KEY = "pref_allowed_languages"
|
||||
private const val PREF_ALLOWED_LANGS_TITLE = "Allowed languages to fetch videos"
|
||||
private val PREF_ALLOWED_LANGS_ENTRIES = PREF_LANG_ENTRIES
|
||||
private val PREF_ALLOWED_LANGS_DEFAULT = PREF_ALLOWED_LANGS_ENTRIES.toSet()
|
||||
}
|
||||
}
|
@ -0,0 +1,50 @@
|
||||
package eu.kanade.tachiyomi.animeextension.all.lmanime.extractors
|
||||
|
||||
import eu.kanade.tachiyomi.animesource.model.Video
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.OkHttpClient
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
@Serializable
|
||||
data class DailyQuality(
|
||||
val qualities: Auto,
|
||||
) {
|
||||
@Serializable
|
||||
data class Auto(
|
||||
val auto: List<Video>,
|
||||
) {
|
||||
@Serializable
|
||||
data class Video(
|
||||
val type: String,
|
||||
val url: String,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class DailymotionExtractor(private val client: OkHttpClient) {
|
||||
private val json: Json by injectLazy()
|
||||
|
||||
fun videosFromUrl(url: String, prefix: String): List<Video> {
|
||||
val id = url.substringBefore("?").substringAfterLast("/")
|
||||
val jsonUrl = "https://www.dailymotion.com/player/metadata/video/$id"
|
||||
val jsonRequest = client.newCall(GET(jsonUrl)).execute().body.string()
|
||||
val parsed = json.decodeFromString<DailyQuality>(jsonRequest)
|
||||
|
||||
val masterUrl = parsed.qualities.auto.first().url
|
||||
val masterPlaylist = client.newCall(GET(masterUrl)).execute().body.string()
|
||||
|
||||
val separator = "#EXT-X-STREAM-INF"
|
||||
return masterPlaylist.substringAfter(separator).split(separator).map {
|
||||
val resolution = it.substringAfter("RESOLUTION=")
|
||||
.substringAfter("x")
|
||||
.substringBefore(",NAME") + "p"
|
||||
val quality = "$prefix $resolution"
|
||||
val videoUrl = it.substringAfter("\n").substringBefore("\n")
|
||||
|
||||
Video(videoUrl, quality, videoUrl)
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,3 @@
|
||||
dependencies {
|
||||
implementation(project(":lib-unpacker"))
|
||||
}
|
After Width: | Height: | Size: 4.5 KiB |
After Width: | Height: | Size: 2.4 KiB |
After Width: | Height: | Size: 6.3 KiB |
After Width: | Height: | Size: 12 KiB |
After Width: | Height: | Size: 18 KiB |
26
multisrc/overrides/animestream/rinecloud/src/RineCloud.kt
Normal file
@ -0,0 +1,26 @@
|
||||
package eu.kanade.tachiyomi.animeextension.pt.rinecloud
|
||||
|
||||
import eu.kanade.tachiyomi.animeextension.pt.rinecloud.extractors.RineCloudExtractor
|
||||
import eu.kanade.tachiyomi.animesource.model.Video
|
||||
import eu.kanade.tachiyomi.multisrc.animestream.AnimeStream
|
||||
|
||||
class RineCloud : AnimeStream(
|
||||
"pt-BR",
|
||||
"RineCloud",
|
||||
"https://rine.cloud",
|
||||
) {
|
||||
override fun headersBuilder() = super.headersBuilder().add("Referer", baseUrl)
|
||||
|
||||
// ============================ Video Links =============================
|
||||
override val prefQualityValues = arrayOf("1080p", "720p", "480p", "360p", "240p")
|
||||
override val prefQualityEntries = prefQualityValues
|
||||
|
||||
override fun getVideoList(url: String, name: String): List<Video> {
|
||||
return when {
|
||||
"rine.cloud" in url -> {
|
||||
RineCloudExtractor(client).videosFromUrl(url, headers)
|
||||
}
|
||||
else -> emptyList()
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,43 @@
|
||||
package eu.kanade.tachiyomi.animeextension.pt.rinecloud.extractors
|
||||
|
||||
import android.util.Base64
|
||||
import eu.kanade.tachiyomi.animesource.model.Video
|
||||
import eu.kanade.tachiyomi.lib.unpacker.Unpacker
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.util.asJsoup
|
||||
import okhttp3.Headers
|
||||
import okhttp3.OkHttpClient
|
||||
|
||||
class RineCloudExtractor(private val client: OkHttpClient) {
|
||||
fun videosFromUrl(url: String, headers: Headers): List<Video> {
|
||||
val playerDoc = client.newCall(GET(url, headers)).execute().asJsoup()
|
||||
val scriptData = playerDoc.selectFirst("script:containsData(JuicyCodes.Run)")
|
||||
?.data()
|
||||
?: return emptyList()
|
||||
|
||||
val decodedData = scriptData.substringAfter("(").substringBefore(")")
|
||||
.split("+\"")
|
||||
.joinToString("") { it.replace("\"", "") }
|
||||
.let { Base64.decode(it, Base64.DEFAULT) }
|
||||
.let(::String)
|
||||
|
||||
val unpackedJs = Unpacker.unpack(decodedData).ifEmpty { return emptyList() }
|
||||
|
||||
val masterPlaylistUrl = unpackedJs.substringAfter("sources:[")
|
||||
.substringAfter("file\":\"")
|
||||
.substringBefore('"')
|
||||
|
||||
val playlistData = client.newCall(GET(masterPlaylistUrl, headers)).execute()
|
||||
.body.string()
|
||||
|
||||
val separator = "#EXT-X-STREAM-INF:"
|
||||
return playlistData.substringAfter(separator).split(separator).map {
|
||||
val quality = it.substringAfter("RESOLUTION=")
|
||||
.substringAfter("x")
|
||||
.substringBefore("\n")
|
||||
.substringBefore(",") + "p"
|
||||
val videoUrl = it.substringAfter("\n").substringBefore("\n")
|
||||
Video(videoUrl, "RineCloud - $quality", videoUrl, headers = headers)
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,398 @@
|
||||
package eu.kanade.tachiyomi.multisrc.animestream
|
||||
|
||||
import android.app.Application
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
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.Video
|
||||
import eu.kanade.tachiyomi.animesource.online.ParsedAnimeHttpSource
|
||||
import eu.kanade.tachiyomi.multisrc.animestream.AnimeStreamFilters.GenresFilter
|
||||
import eu.kanade.tachiyomi.multisrc.animestream.AnimeStreamFilters.OrderFilter
|
||||
import eu.kanade.tachiyomi.multisrc.animestream.AnimeStreamFilters.SeasonFilter
|
||||
import eu.kanade.tachiyomi.multisrc.animestream.AnimeStreamFilters.StatusFilter
|
||||
import eu.kanade.tachiyomi.multisrc.animestream.AnimeStreamFilters.StudioFilter
|
||||
import eu.kanade.tachiyomi.multisrc.animestream.AnimeStreamFilters.SubFilter
|
||||
import eu.kanade.tachiyomi.multisrc.animestream.AnimeStreamFilters.TypeFilter
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.asObservableSuccess
|
||||
import eu.kanade.tachiyomi.util.asJsoup
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.jsoup.Jsoup
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
import rx.Observable
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
abstract class AnimeStream(
|
||||
override val lang: String,
|
||||
override val name: String,
|
||||
override val baseUrl: String,
|
||||
) : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
|
||||
|
||||
override val supportsLatest = true
|
||||
|
||||
override val client = network.cloudflareClient
|
||||
|
||||
protected open val preferences by lazy {
|
||||
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val PREFIX_SEARCH = "path:"
|
||||
}
|
||||
|
||||
protected open val prefQualityDefault = "720p"
|
||||
protected open val prefQualityKey = "preferred_quality"
|
||||
protected open val prefQualityTitle = when (lang) {
|
||||
"pt-BR" -> "Qualidade preferida"
|
||||
else -> "Preferred quality"
|
||||
}
|
||||
protected open val prefQualityValues = arrayOf("1080p", "720p", "480p", "360p")
|
||||
protected open val prefQualityEntries = prefQualityValues
|
||||
|
||||
protected open val videoSortPrefKey = prefQualityKey
|
||||
protected open val videoSortPrefDefault = prefQualityDefault
|
||||
|
||||
protected open val dateFormatter by lazy {
|
||||
val locale = when (lang) {
|
||||
"pt-BR" -> Locale("pt", "BR")
|
||||
else -> Locale.ENGLISH
|
||||
}
|
||||
SimpleDateFormat("MMMM d, yyyy", locale)
|
||||
}
|
||||
|
||||
protected open val animeListUrl = "$baseUrl/anime"
|
||||
|
||||
// ============================== Popular ===============================
|
||||
override fun fetchPopularAnime(page: Int): Observable<AnimesPage> {
|
||||
fetchFilterList()
|
||||
return super.fetchPopularAnime(page)
|
||||
}
|
||||
|
||||
override fun popularAnimeFromElement(element: Element): SAnime {
|
||||
return SAnime.create().apply {
|
||||
val ahref = element.selectFirst("h4 > a.series")!!
|
||||
setUrlWithoutDomain(ahref.attr("href"))
|
||||
title = ahref.text()
|
||||
thumbnail_url = element.selectFirst("img")!!.getImageUrl()
|
||||
}
|
||||
}
|
||||
|
||||
override fun popularAnimeNextPageSelector() = null
|
||||
|
||||
override fun popularAnimeRequest(page: Int) = GET(baseUrl)
|
||||
|
||||
/* Possible classes: wpop-weekly, wpop-monthly, wpop-alltime */
|
||||
override fun popularAnimeSelector() = "div.serieslist.wpop-alltime li"
|
||||
|
||||
// ============================== Episodes ==============================
|
||||
override fun episodeListParse(response: Response): List<SEpisode> {
|
||||
val doc = response.asJsoup()
|
||||
return doc.select(episodeListSelector()).map(::episodeFromElement)
|
||||
}
|
||||
|
||||
protected open val episodePrefix = when (lang) {
|
||||
"pt-BR" -> "Episódio"
|
||||
else -> "Episode"
|
||||
}
|
||||
|
||||
override fun episodeFromElement(element: Element): SEpisode {
|
||||
return SEpisode.create().apply {
|
||||
setUrlWithoutDomain(element.attr("href"))
|
||||
element.selectFirst("div.epl-num")!!.text().let {
|
||||
name = "$episodePrefix $it"
|
||||
episode_number = it.substringBefore(" ").toFloatOrNull() ?: 0F
|
||||
}
|
||||
element.selectFirst("div.epl-sub")?.text()?.let { scanlator = it }
|
||||
date_upload = element.selectFirst("div.epl-date")?.text().toDate()
|
||||
}
|
||||
}
|
||||
|
||||
override fun episodeListSelector() = "div.eplister > ul > li > a"
|
||||
|
||||
// =========================== Anime Details ============================
|
||||
override fun animeDetailsParse(document: Document): SAnime {
|
||||
return SAnime.create().apply {
|
||||
setUrlWithoutDomain(document.location())
|
||||
title = document.selectFirst("h1.entry-title")!!.text()
|
||||
thumbnail_url = document.selectFirst("div.thumb > img")!!.getImageUrl()
|
||||
|
||||
val infos = document.selectFirst("div.info-content")!!
|
||||
genre = infos.select("div.genxed > a").eachText().joinToString()
|
||||
|
||||
status = parseStatus(infos.getInfo("Status"))
|
||||
artist = infos.getInfo("tudio")
|
||||
author = infos.getInfo("Fansub")
|
||||
|
||||
description = buildString {
|
||||
document.selectFirst("div.entry-content")?.text()?.let {
|
||||
append("$it\n\n")
|
||||
}
|
||||
|
||||
infos.select("div.spe > span").eachText().forEach {
|
||||
append("$it\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================ Video Links =============================
|
||||
override fun videoListSelector() = "select.mirror > option[data-index]"
|
||||
|
||||
override fun videoListParse(response: Response): List<Video> {
|
||||
val items = response.asJsoup().select(videoListSelector())
|
||||
return items.parallelMap { element ->
|
||||
runCatching {
|
||||
val name = element.text()
|
||||
val url = getHosterUrl(element)
|
||||
getVideoList(url, name)
|
||||
}.onFailure { it.printStackTrace() }.getOrElse { emptyList() }
|
||||
}.flatten()
|
||||
}
|
||||
|
||||
protected open fun getHosterUrl(element: Element): String {
|
||||
return Base64.decode(element.attr("value"), Base64.DEFAULT)
|
||||
.let(::String) // bytearray -> string
|
||||
.let(Jsoup::parse) // string -> document
|
||||
.selectFirst("iframe[src~=.]")!!
|
||||
.attr("src")
|
||||
.let { // sometimes the url dont specify its protocol
|
||||
when {
|
||||
it.startsWith("http") -> it
|
||||
else -> "https:$it"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected open fun getVideoList(url: String, name: String): List<Video> {
|
||||
Log.i(name, "getVideoList -> URL => $url || Name => $name")
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
override fun videoFromElement(element: Element) = throw Exception("Not Used")
|
||||
|
||||
override fun videoUrlParse(document: Document) = throw Exception("Not Used")
|
||||
|
||||
// =============================== Search ===============================
|
||||
override fun searchAnimeFromElement(element: Element): SAnime {
|
||||
return SAnime.create().apply {
|
||||
setUrlWithoutDomain(element.attr("href"))
|
||||
title = element.selectFirst("div.tt")!!.ownText()
|
||||
thumbnail_url = element.selectFirst("img")!!.getImageUrl()
|
||||
}
|
||||
}
|
||||
|
||||
override fun searchAnimeNextPageSelector() = "div.pagination a.next, div.hpage > a.r"
|
||||
|
||||
override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request {
|
||||
val params = AnimeStreamFilters.getSearchParameters(filters)
|
||||
return if (query.isNotEmpty()) {
|
||||
GET("$baseUrl/page/$page/?s=$query")
|
||||
} else {
|
||||
val multiString = buildString {
|
||||
if (params.genres.isNotEmpty()) append(params.genres + "&")
|
||||
if (params.seasons.isNotEmpty()) append(params.seasons + "&")
|
||||
if (params.studios.isNotEmpty()) append(params.studios + "&")
|
||||
}
|
||||
|
||||
GET("$baseUrl/anime/?page=$page&$multiString&status=${params.status}&type=${params.type}&sub=${params.sub}&order=${params.order}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun searchAnimeSelector() = "div.listupd article a.tip"
|
||||
|
||||
override fun fetchSearchAnime(page: Int, query: String, filters: AnimeFilterList): Observable<AnimesPage> {
|
||||
return if (query.startsWith(PREFIX_SEARCH)) { // URL intent handler
|
||||
val path = query.removePrefix(PREFIX_SEARCH)
|
||||
client.newCall(GET("$baseUrl/$path"))
|
||||
.asObservableSuccess()
|
||||
.map(::searchAnimeByPathParse)
|
||||
} else {
|
||||
super.fetchSearchAnime(page, query, filters)
|
||||
}
|
||||
}
|
||||
|
||||
protected open fun searchAnimeByPathParse(response: Response): AnimesPage {
|
||||
val details = animeDetailsParse(response.asJsoup())
|
||||
return AnimesPage(listOf(details), false)
|
||||
}
|
||||
|
||||
// =============================== Latest ===============================
|
||||
override fun fetchLatestUpdates(page: Int): Observable<AnimesPage> {
|
||||
fetchFilterList()
|
||||
return super.fetchLatestUpdates(page)
|
||||
}
|
||||
|
||||
override fun latestUpdatesRequest(page: Int) = GET("$animeListUrl/?page=$page&order=update")
|
||||
|
||||
override fun latestUpdatesSelector() = searchAnimeSelector()
|
||||
|
||||
override fun latestUpdatesNextPageSelector() = searchAnimeNextPageSelector()
|
||||
|
||||
override fun latestUpdatesFromElement(element: Element) = searchAnimeFromElement(element)
|
||||
|
||||
// ============================== Settings ==============================
|
||||
override fun setupPreferenceScreen(screen: PreferenceScreen) {
|
||||
val videoQualityPref = ListPreference(screen.context).apply {
|
||||
key = prefQualityKey
|
||||
title = prefQualityTitle
|
||||
entries = prefQualityEntries
|
||||
entryValues = prefQualityValues
|
||||
setDefaultValue(prefQualityDefault)
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
screen.addPreference(videoQualityPref)
|
||||
}
|
||||
|
||||
// ============================== Filters ===============================
|
||||
|
||||
/**
|
||||
* Disable it if you don't want the filters to be automatically fetched.
|
||||
*/
|
||||
protected open val fetchFilters = true
|
||||
|
||||
private fun fetchFilterList() {
|
||||
if (fetchFilters && !AnimeStreamFilters.filterInitialized()) {
|
||||
AnimeStreamFilters.filterElements = runBlocking {
|
||||
withContext(Dispatchers.IO) {
|
||||
client.newCall(GET(animeListUrl)).execute()
|
||||
.asJsoup()
|
||||
.select("span.sec1 > div.filter > ul")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected open val filtersHeader = when (lang) {
|
||||
"pt-BR" -> "NOTA: Filtros serão ignorados se usar a pesquisa por nome!"
|
||||
else -> "NOTE: Filters are going to be ignored if using search text!"
|
||||
}
|
||||
|
||||
protected open val filtersMissingWarning: String = when (lang) {
|
||||
"pt-BR" -> "Aperte 'Redefinir' para tentar mostrar os filtros"
|
||||
else -> "Press 'Reset' to attempt to show the filters"
|
||||
}
|
||||
|
||||
protected open val genresFilterText = when (lang) {
|
||||
"pt-BR" -> "Gêneros"
|
||||
else -> "Genres"
|
||||
}
|
||||
|
||||
protected open val seasonsFilterText = when (lang) {
|
||||
"pt-BR" -> "Temporadas"
|
||||
else -> "Seasons"
|
||||
}
|
||||
|
||||
protected open val studioFilterText = when (lang) {
|
||||
"pt-BR" -> "Estúdios"
|
||||
else -> "Studios"
|
||||
}
|
||||
|
||||
protected open val statusFilterText = "Status"
|
||||
|
||||
protected open val typeFilterText = when (lang) {
|
||||
"pt-BR" -> "Tipo"
|
||||
else -> "Type"
|
||||
}
|
||||
|
||||
protected open val subFilterText = when (lang) {
|
||||
"pt-BR" -> "Legenda"
|
||||
else -> "Subtitle"
|
||||
}
|
||||
|
||||
protected open val orderFilterText = when (lang) {
|
||||
"pt-BR" -> "Ordem"
|
||||
else -> "Order"
|
||||
}
|
||||
|
||||
override fun getFilterList(): AnimeFilterList {
|
||||
return if (fetchFilters && AnimeStreamFilters.filterInitialized()) {
|
||||
AnimeFilterList(
|
||||
GenresFilter(genresFilterText),
|
||||
SeasonFilter(seasonsFilterText),
|
||||
StudioFilter(studioFilterText),
|
||||
AnimeFilter.Separator(),
|
||||
StatusFilter(statusFilterText),
|
||||
TypeFilter(typeFilterText),
|
||||
SubFilter(subFilterText),
|
||||
OrderFilter(orderFilterText),
|
||||
)
|
||||
} else if (fetchFilters) {
|
||||
AnimeFilterList(AnimeFilter.Header(filtersMissingWarning))
|
||||
} else {
|
||||
AnimeFilterList()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================= Utilities ==============================
|
||||
override fun List<Video>.sort(): List<Video> {
|
||||
val quality = preferences.getString(videoSortPrefKey, videoSortPrefDefault)!!
|
||||
return sortedWith(
|
||||
compareBy { it.quality.contains(quality, true) },
|
||||
).reversed()
|
||||
}
|
||||
|
||||
protected open fun parseStatus(statusString: String?): Int {
|
||||
return when (statusString?.trim()?.lowercase()) {
|
||||
"completed", "completo" -> SAnime.COMPLETED
|
||||
"ongoing", "lançamento" -> SAnime.ONGOING
|
||||
else -> SAnime.UNKNOWN
|
||||
}
|
||||
}
|
||||
|
||||
protected open fun Element.getInfo(text: String): String? {
|
||||
return selectFirst("span:contains($text)")
|
||||
?.run {
|
||||
selectFirst("a")?.text() ?: ownText()
|
||||
}
|
||||
}
|
||||
|
||||
protected open fun String?.toDate(): Long {
|
||||
return this?.let {
|
||||
runCatching {
|
||||
dateFormatter.parse(trim())?.time
|
||||
}.getOrNull()
|
||||
} ?: 0L
|
||||
}
|
||||
|
||||
protected inline fun <A, B> Iterable<A>.parallelMap(crossinline f: suspend (A) -> B): List<B> =
|
||||
runBlocking {
|
||||
map { async(Dispatchers.Default) { f(it) } }.awaitAll()
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to get the image url via various possible attributes.
|
||||
* Taken from Tachiyomi's Madara multisrc.
|
||||
*/
|
||||
protected open fun Element.getImageUrl(): String? {
|
||||
return when {
|
||||
hasAttr("data-src") -> attr("abs:data-src")
|
||||
hasAttr("data-lazy-src") -> attr("abs:data-lazy-src")
|
||||
hasAttr("srcset") -> attr("abs:srcset").substringBefore(" ")
|
||||
else -> attr("abs:src")
|
||||
}.substringBefore("?resize")
|
||||
}
|
||||
}
|
@ -0,0 +1,103 @@
|
||||
package eu.kanade.tachiyomi.multisrc.animestream
|
||||
|
||||
import eu.kanade.tachiyomi.animesource.model.AnimeFilter
|
||||
import eu.kanade.tachiyomi.animesource.model.AnimeFilterList
|
||||
import org.jsoup.select.Elements
|
||||
|
||||
object AnimeStreamFilters {
|
||||
open class QueryPartFilter(
|
||||
displayName: String,
|
||||
val vals: Array<Pair<String, String>>,
|
||||
) : AnimeFilter.Select<String>(
|
||||
displayName,
|
||||
vals.map { it.first }.toTypedArray(),
|
||||
) {
|
||||
fun toQueryPart() = vals[state].second
|
||||
}
|
||||
|
||||
open class CheckBoxFilterList(name: String, val pairs: Array<Pair<String, String>>) :
|
||||
AnimeFilter.Group<AnimeFilter.CheckBox>(name, pairs.map { CheckBoxVal(it.first, false) })
|
||||
|
||||
private class CheckBoxVal(name: String, state: Boolean = false) : AnimeFilter.CheckBox(name, state)
|
||||
|
||||
private inline fun <reified R> AnimeFilterList.asQueryPart(): String {
|
||||
return (getFirst<R>() as QueryPartFilter).toQueryPart()
|
||||
}
|
||||
|
||||
private inline fun <reified R> AnimeFilterList.getFirst(): R {
|
||||
return first { it is R } as R
|
||||
}
|
||||
|
||||
private inline fun <reified R> AnimeFilterList.parseCheckbox(
|
||||
options: Array<Pair<String, String>>,
|
||||
name: String,
|
||||
): String {
|
||||
return (getFirst<R>() as CheckBoxFilterList).state
|
||||
.mapNotNull { checkbox ->
|
||||
when {
|
||||
checkbox.state -> {
|
||||
options.find { it.first == checkbox.name }!!.second
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}.joinToString("&$name[]=").let {
|
||||
when {
|
||||
it.isBlank() -> ""
|
||||
else -> "$name[]=$it"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class GenresFilter(name: String) : CheckBoxFilterList(name, GENRES_LIST)
|
||||
class SeasonFilter(name: String) : CheckBoxFilterList(name, SEASON_LIST)
|
||||
class StudioFilter(name: String) : CheckBoxFilterList(name, STUDIO_LIST)
|
||||
|
||||
class StatusFilter(name: String) : QueryPartFilter(name, STATUS_LIST)
|
||||
class TypeFilter(name: String) : QueryPartFilter(name, TYPE_LIST)
|
||||
class SubFilter(name: String) : QueryPartFilter(name, SUB_LIST)
|
||||
class OrderFilter(name: String) : QueryPartFilter(name, ORDER_LIST)
|
||||
|
||||
data class FilterSearchParams(
|
||||
val genres: String = "",
|
||||
val seasons: String = "",
|
||||
val studios: String = "",
|
||||
val status: String = "",
|
||||
val type: String = "",
|
||||
val sub: String = "",
|
||||
val order: String = "",
|
||||
)
|
||||
|
||||
internal fun getSearchParameters(filters: AnimeFilterList): FilterSearchParams {
|
||||
if (filters.isEmpty()) return FilterSearchParams()
|
||||
|
||||
return FilterSearchParams(
|
||||
filters.parseCheckbox<GenresFilter>(GENRES_LIST, "genre"),
|
||||
filters.parseCheckbox<SeasonFilter>(SEASON_LIST, "season"),
|
||||
filters.parseCheckbox<StudioFilter>(STUDIO_LIST, "studio"),
|
||||
filters.asQueryPart<StatusFilter>(),
|
||||
filters.asQueryPart<TypeFilter>(),
|
||||
filters.asQueryPart<SubFilter>(),
|
||||
filters.asQueryPart<OrderFilter>(),
|
||||
)
|
||||
}
|
||||
|
||||
internal lateinit var filterElements: Elements
|
||||
|
||||
internal fun filterInitialized() = ::filterElements.isInitialized
|
||||
|
||||
private fun getPairListByIndex(index: Int) = filterElements.get(index)
|
||||
.select("li")
|
||||
.map { element ->
|
||||
val key = element.selectFirst("label")!!.text()
|
||||
val value = element.selectFirst("input")!!.attr("value")
|
||||
Pair(key, value)
|
||||
}.toTypedArray()
|
||||
|
||||
private val GENRES_LIST by lazy { getPairListByIndex(0) }
|
||||
private val SEASON_LIST by lazy { getPairListByIndex(1) }
|
||||
private val STUDIO_LIST by lazy { getPairListByIndex(2) }
|
||||
private val STATUS_LIST by lazy { getPairListByIndex(3) }
|
||||
private val TYPE_LIST by lazy { getPairListByIndex(4) }
|
||||
private val SUB_LIST by lazy { getPairListByIndex(5) }
|
||||
private val ORDER_LIST by lazy { getPairListByIndex(6) }
|
||||
}
|
@ -0,0 +1,23 @@
|
||||
package eu.kanade.tachiyomi.multisrc.animestream
|
||||
|
||||
import generator.ThemeSourceData.SingleLang
|
||||
import generator.ThemeSourceGenerator
|
||||
|
||||
class AnimeStreamGenerator : ThemeSourceGenerator {
|
||||
override val themePkg = "animestream"
|
||||
|
||||
override val themeClass = "AnimeStream"
|
||||
|
||||
override val baseVersionCode = 1
|
||||
|
||||
override val sources = listOf(
|
||||
SingleLang("AnimeXin", "https://animexin.vip", "all", isNsfw = false, overrideVersionCode = 4),
|
||||
SingleLang("LMAnime", "https://lmanime.com", "all", isNsfw = false, overrideVersionCode = 2),
|
||||
SingleLang("RineCloud", "https://rine.cloud", "pt-BR", isNsfw = false),
|
||||
)
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun main(args: Array<String>) = AnimeStreamGenerator().createAll()
|
||||
}
|
||||
}
|
@ -0,0 +1,37 @@
|
||||
package eu.kanade.tachiyomi.multisrc.animestream
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
class AnimeStreamUrlActivity : Activity() {
|
||||
|
||||
private val tag by lazy { javaClass.simpleName }
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val pathSegments = intent?.data?.pathSegments
|
||||
if (pathSegments != null && pathSegments.isNotEmpty()) {
|
||||
val path = pathSegments.joinToString("/")
|
||||
val mainIntent = Intent().apply {
|
||||
action = "eu.kanade.tachiyomi.ANIMESEARCH"
|
||||
putExtra("query", "${AnimeStream.PREFIX_SEARCH}$path")
|
||||
putExtra("filter", packageName)
|
||||
}
|
||||
|
||||
try {
|
||||
startActivity(mainIntent)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
Log.e(tag, e.toString())
|
||||
}
|
||||
} else {
|
||||
Log.e(tag, "could not parse uri from intent $intent")
|
||||
}
|
||||
|
||||
finish()
|
||||
exitProcess(0)
|
||||
}
|
||||
}
|