feat(src/fr): New source: OtakuFR (#2010)

This commit is contained in:
Secozzi
2023-08-04 08:49:44 +00:00
committed by GitHub
parent 485b97251c
commit cfc7ff07bd
12 changed files with 487 additions and 0 deletions

View File

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest />

View File

@ -0,0 +1,24 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
}
ext {
extName = 'OtakuFR'
pkgNameSuffix = 'fr.otakufr'
extClass = '.OtakuFR'
extVersionCode = 1
libVersion = '13'
}
dependencies {
implementation(project(':lib-sibnet-extractor'))
implementation(project(':lib-voe-extractor'))
implementation(project(':lib-sendvid-extractor'))
implementation(project(':lib-dood-extractor'))
implementation(project(':lib-okru-extractor'))
implementation(project(":lib-playlist-utils"))
implementation "dev.datlag.jsunpacker:jsunpacker:1.0.1"
}
apply from: "$rootDir/common.gradle"

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 434 KiB

View File

@ -0,0 +1,383 @@
package eu.kanade.tachiyomi.animeextension.fr.otakufr
import android.app.Application
import android.content.SharedPreferences
import androidx.preference.ListPreference
import androidx.preference.PreferenceScreen
import eu.kanade.tachiyomi.animeextension.fr.otakufr.extractors.StreamWishExtractor
import eu.kanade.tachiyomi.animeextension.fr.otakufr.extractors.UpstreamExtractor
import eu.kanade.tachiyomi.animeextension.fr.otakufr.extractors.VidbmExtractor
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.lib.doodextractor.DoodExtractor
import eu.kanade.tachiyomi.lib.okruextractor.OkruExtractor
import eu.kanade.tachiyomi.lib.sendvidextractor.SendvidExtractor
import eu.kanade.tachiyomi.lib.sibnetextractor.SibnetExtractor
import eu.kanade.tachiyomi.lib.voeextractor.VoeExtractor
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.util.asJsoup
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.runBlocking
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.lang.Exception
import java.text.SimpleDateFormat
import java.util.Locale
class OtakuFR : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
override val name = "OtakuFR"
override val baseUrl = "https://otakufr.co"
override val lang = "fr"
override val supportsLatest = false
override val client: OkHttpClient = network.cloudflareClient
private val preferences: SharedPreferences by lazy {
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
}
// ============================== Popular ===============================
override fun popularAnimeRequest(page: Int): Request = GET("$baseUrl/en-cours".addPage(page), headers)
override fun popularAnimeSelector(): String = "div.list > article.card"
override fun popularAnimeFromElement(element: Element): SAnime {
val a = element.selectFirst("a.episode-name")!!
return SAnime.create().apply {
thumbnail_url = element.selectFirst("img")!!.attr("abs:src")
setUrlWithoutDomain(a.attr("href"))
title = a.text()
}
}
override fun popularAnimeNextPageSelector(): String = "ul.pagination > li.active ~ li"
// =============================== Latest ===============================
override fun latestUpdatesRequest(page: Int): Request = throw Exception("Not used")
override fun latestUpdatesSelector(): String = throw Exception("Not used")
override fun latestUpdatesFromElement(element: Element): SAnime = throw Exception("Not used")
override fun latestUpdatesNextPageSelector(): String = throw Exception("Not used")
// =============================== Search ===============================
override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request {
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/toute-la-liste-affiches/?q=$query".addPage(page), headers)
genreFilter.state != 0 -> GET("$baseUrl${genreFilter.toUriPart()}".addPage(page), headers)
subPageFilter.state != 0 -> GET("$baseUrl${subPageFilter.toUriPart()}".addPage(page), headers)
else -> throw Exception("Either search something or select a filter")
}
}
override fun searchAnimeSelector(): String = popularAnimeSelector()
override fun searchAnimeFromElement(element: Element): SAnime = popularAnimeFromElement(element)
override fun searchAnimeNextPageSelector(): String = popularAnimeNextPageSelector()
// =========================== Anime Details ============================
override fun animeDetailsParse(document: Document): SAnime {
val infoDiv = document.selectFirst("article.card div.episode")!!
return SAnime.create().apply {
status = infoDiv.selectFirst("li:contains(Statut)")?.let { parseStatus(it.ownText()) } ?: SAnime.UNKNOWN
genre = infoDiv.select("li:contains(Genre:) ul li").joinToString(", ") { it.text() }
author = infoDiv.selectFirst("li:contains(Studio d\\'animation)")?.ownText()
description = buildString {
append(infoDiv.select("> p:not(:has(strong)):not(:empty)").joinToString("\n\n") { it.text() })
append("\n")
infoDiv.selectFirst("li:contains(Autre Nom)")?.let { append("\n${it.text()}") }
infoDiv.selectFirst("li:contains(Auteur)")?.let { append("\n${it.text()}") }
infoDiv.selectFirst("li:contains(Réalisateur)")?.let { append("\n${it.text()}") }
infoDiv.selectFirst("li:contains(Type)")?.let { append("\n${it.text()}") }
infoDiv.selectFirst("li:contains(Sortie initiale)")?.let { append("\n${it.text()}") }
infoDiv.selectFirst("li:contains(Durée)")?.let { append("\n${it.text()}") }
}
}
}
// ============================== Episodes ==============================
override fun episodeListSelector() = "div.list-episodes > a"
override fun episodeFromElement(element: Element): SEpisode {
val epText = element.ownText()
return SEpisode.create().apply {
setUrlWithoutDomain(element.attr("abs:href"))
name = epText
episode_number = Regex(" ([\\d.]+) (?:Vostfr|VF)").find(epText)
?.groupValues
?.get(1)
?.toFloatOrNull()
?: 1F
date_upload = element.selectFirst("span")
?.text()
?.let(::parseDate)
?: 0L
}
}
// ============================ Video Links =============================
private val streamwishExtractor by lazy { StreamWishExtractor(client, headers) }
private val vidbmExtractor by lazy { VidbmExtractor(client, headers) }
private val sendvidExtractor by lazy { SendvidExtractor(client, headers) }
private val upstreamExtractor by lazy { UpstreamExtractor(client, headers) }
private val okruExtractor by lazy { OkruExtractor(client) }
private val doodExtractor by lazy { DoodExtractor(client) }
private val voeExtractor by lazy { VoeExtractor(client) }
private val sibnetExtractor by lazy { SibnetExtractor(client) }
override fun videoListParse(response: Response): List<Video> {
val document = response.asJsoup()
val serversList = document.select("div.tab-content iframe[src]").mapNotNull {
val url = it.attr("abs:src")
if (url.contains("parisanime.com")) {
val docHeaders = headers.newBuilder().apply {
add("Accept", "*/*")
add("Host", url.toHttpUrl().host)
add("Referer", url)
add("X-Requested-With", "XMLHttpRequest")
}.build()
val newDoc = client.newCall(
GET(url, headers = docHeaders),
).execute().asJsoup()
newDoc.selectFirst("div[data-url]")?.attr("data-url")
} else {
url
}
}
return serversList.parallelMap {
runCatching { getHosterVideos(it) }.getOrElse { emptyList() }
}.flatten().sort().ifEmpty { throw Exception("Failed to extract videos") }
}
private fun getHosterVideos(host: String): List<Video> {
return when {
host.startsWith("https://doo") -> doodExtractor.videosFromUrl(host, quality = "Doodstream")
host.contains("streamwish") -> streamwishExtractor.videosFromUrl(host)
host.contains("sibnet.ru") -> sibnetExtractor.videosFromUrl(host)
host.contains("vadbam") -> vidbmExtractor.videosFromUrl(host)
host.contains("sendvid.com") -> sendvidExtractor.videosFromUrl(host)
host.contains("ok.ru") -> okruExtractor.videosFromUrl(host)
host.contains("upstream") -> upstreamExtractor.videosFromUrl(host)
host.startsWith("https://voe") -> voeExtractor.videoFromUrl(host, quality = "Voe")?.let(::listOf) ?: emptyList()
else -> emptyList()
}
}
override fun videoListSelector() = throw Exception("not used")
override fun videoFromElement(element: Element) = throw Exception("not used")
override fun videoUrlParse(document: Document) = throw Exception("not used")
// ============================= Utilities ==============================
private fun String.addPage(page: Int): String {
return if (page == 1) {
this
} else {
this.toHttpUrl().newBuilder().apply {
addPathSegment("page")
addPathSegment(page.toString())
}.build().toString()
}
}
private fun parseDate(dateStr: String): Long {
return runCatching { DATE_FORMATTER.parse(dateStr)?.time }
.getOrNull() ?: 0L
}
override fun List<Video>.sort(): List<Video> {
val quality = preferences.getString(PREF_QUALITY_KEY, PREF_QUALITY_DEFAULT)!!
val server = preferences.getString(PREF_SERVER_KEY, PREF_SERVER_DEFAULT)!!
return this.sortedWith(
compareBy(
{ it.quality.contains(quality) },
{ Regex("""(\d+)p""").find(it.quality)?.groupValues?.get(1)?.toIntOrNull() ?: 0 },
{ it.quality.contains(server, true) },
),
).reversed()
}
private fun parseStatus(statusString: String): Int {
return when (statusString) {
"En cours" -> SAnime.ONGOING
"Terminé" -> SAnime.COMPLETED
else -> SAnime.UNKNOWN
}
}
// From Dopebox
private fun <A, B> Iterable<A>.parallelMap(f: suspend (A) -> B): List<B> =
runBlocking {
map { async(Dispatchers.Default) { f(it) } }.awaitAll()
}
companion object {
private val DATE_FORMATTER by lazy {
SimpleDateFormat("d MMMM yyyy", Locale.FRENCH)
}
private val HOSTERS = arrayOf(
"Streamwish",
"Doodstream",
"Sendvid",
"Vidbm",
"Okru",
"Voe",
"Sibnet",
"Upstream",
)
private const val PREF_QUALITY_KEY = "preferred_quality"
private const val PREF_QUALITY_DEFAULT = "1080"
private const val PREF_SERVER_KEY = "preferred_server"
private const val PREF_SERVER_DEFAULT = "Streamwish"
}
// ============================== Settings ==============================
override fun setupPreferenceScreen(screen: PreferenceScreen) {
ListPreference(screen.context).apply {
key = PREF_QUALITY_KEY
title = "Preferred quality"
entries = arrayOf("1080p", "720p", "480p", "360p")
entryValues = arrayOf("1080", "720", "480", "360")
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 = HOSTERS
entryValues = HOSTERS
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)
}
// ============================== Filters ===============================
override fun getFilterList(): AnimeFilterList = AnimeFilterList(
AnimeFilter.Header("Text search ignores filters"),
GenreFilter(),
SubPageFilter(),
)
// copy($("div.dropdown-menu > a.dropdown-item").map((i,el) => `Pair("${$(el).text().trim()}", "${$(el).attr('href').trim().slice(18)}")`).get().join(',\n'))
// on /
private class GenreFilter : UriPartFilter(
"Genres",
arrayOf(
Pair("<select>", ""),
Pair("Action", "/genre/action/"),
Pair("Aventure", "/genre/aventure/"),
Pair("Comedie", "/genre/comedie/"),
Pair("Crime", "/genre/crime/"),
Pair("Démons", "/genre/demons/"),
Pair("Drame", "/genre/drame/"),
Pair("Ecchi", "/genre/ecchi/"),
Pair("Espace", "/genre/espace/"),
Pair("Fantastique", "/genre/fantastique/"),
Pair("Gore", "/genre/gore/"),
Pair("Harem", "/genre/harem/"),
Pair("Historique", "/genre/historique/"),
Pair("Horreur", "/genre/horreur/"),
Pair("Isekai", "/genre/isekai/"),
Pair("Jeux", "/genre/jeu/"),
Pair("L'école", "/genre/lecole/"),
Pair("Magical girls", "/genre/magical-girls/"),
Pair("Magie", "/genre/magie/"),
Pair("Martial Arts", "/genre/martial-arts/"),
Pair("Mecha", "/genre/mecha/"),
Pair("Militaire", "/genre/militaire/"),
Pair("Musique", "/genre/musique/"),
Pair("Mysterieux", "/genre/mysterieux/"),
Pair("Parodie", "/genre/Parodie/"),
Pair("Police", "/genre/police/"),
Pair("Psychologique", "/genre/psychologique/"),
Pair("Romance", "/genre/romance/"),
Pair("Samurai", "/genre/samurai/"),
Pair("Sci-Fi", "/genre/sci-fi/"),
Pair("Seinen", "/genre/seinen/"),
Pair("Shoujo", "/genre/shoujo/"),
Pair("Shoujo Ai", "/genre/shoujo-ai/"),
Pair("Shounen", "/genre/shounen/"),
Pair("Shounen Ai", "/genre/shounen-ai/"),
Pair("Sport", "/genre/sport/"),
Pair("Super Power", "/genre/super-power/"),
Pair("Surnaturel", "/genre/surnaturel/"),
Pair("Suspense", "/genre/suspense/"),
Pair("Thriller", "/genre/thriller/"),
Pair("Tranche de vie", "/genre/tranche-de-vie/"),
Pair("Vampire", "/genre/vampire/"),
),
)
private class SubPageFilter : UriPartFilter(
"Sup-page",
arrayOf(
Pair("<select>", ""),
Pair("Terminé", "/termine/"),
Pair("Film", "/film/"),
),
)
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
}
}

View File

@ -0,0 +1,24 @@
package eu.kanade.tachiyomi.animeextension.fr.otakufr.extractors
import dev.datlag.jsunpacker.JsUnpacker
import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.lib.playlistutils.PlaylistUtils
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.util.asJsoup
import okhttp3.Headers
import okhttp3.OkHttpClient
class StreamWishExtractor(private val client: OkHttpClient, private val headers: Headers) {
fun videosFromUrl(url: String): List<Video> {
val doc = client.newCall(GET(url, headers = headers)).execute().asJsoup()
val jsEval = doc.selectFirst("script:containsData(m3u8)")?.data() ?: return emptyList()
val masterUrl = JsUnpacker.unpackAndCombine(jsEval)
?.substringAfter("source")
?.substringAfter("file:\"")
?.substringBefore("\"")
?: return emptyList()
return PlaylistUtils(client, headers).extractFromHls(masterUrl, videoNameGen = { quality -> "Streamwish - $quality" })
}
}

View File

@ -0,0 +1,23 @@
package eu.kanade.tachiyomi.animeextension.fr.otakufr.extractors
import dev.datlag.jsunpacker.JsUnpacker
import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.lib.playlistutils.PlaylistUtils
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.util.asJsoup
import okhttp3.Headers
import okhttp3.OkHttpClient
class UpstreamExtractor(private val client: OkHttpClient, private val headers: Headers) {
fun videosFromUrl(url: String): List<Video> {
val jsE = client.newCall(
GET(url, headers),
).execute().asJsoup().selectFirst("script:containsData(m3u8)")?.data() ?: return emptyList()
val masterUrl = JsUnpacker.unpackAndCombine(jsE)
?.substringAfter("{file:\"")
?.substringBefore("\"}")
?: return emptyList()
return PlaylistUtils(client, headers).extractFromHls(masterUrl, videoNameGen = { quality -> "Upstream - $quality" })
}
}

View File

@ -0,0 +1,31 @@
package eu.kanade.tachiyomi.animeextension.fr.otakufr.extractors
import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.lib.playlistutils.PlaylistUtils
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.util.asJsoup
import okhttp3.Headers
import okhttp3.OkHttpClient
class VidbmExtractor(private val client: OkHttpClient, private val headers: Headers) {
fun videosFromUrl(url: String): List<Video> {
val doc = client.newCall(GET(url, headers = headers)).execute().asJsoup()
val js = doc.selectFirst("script:containsData(m3u8),script:containsData(mp4)")?.data() ?: return emptyList()
val masterUrl = js.substringAfter("source")
.substringAfter("file:\"")
.substringBefore("\"")
val quality = js.substringAfter("source")
.substringAfter("file")
.substringBefore("]")
.substringAfter("label:\"")
.substringBefore("\"")
return if (masterUrl.contains("m3u8")) {
PlaylistUtils(client, headers).extractFromHls(masterUrl, videoNameGen = { quality -> "Vidbm - $quality" })
} else {
listOf(Video(masterUrl, "Vidbm - $quality", masterUrl))
}
}
}