diff --git a/src/fr/jeanyves/AndroidManifest.xml b/src/fr/jeanyves/AndroidManifest.xml
new file mode 100644
index 000000000..acb4de356
--- /dev/null
+++ b/src/fr/jeanyves/AndroidManifest.xml
@@ -0,0 +1,2 @@
+
+
\ No newline at end of file
diff --git a/src/fr/jeanyves/build.gradle b/src/fr/jeanyves/build.gradle
new file mode 100644
index 000000000..8770ece9d
--- /dev/null
+++ b/src/fr/jeanyves/build.gradle
@@ -0,0 +1,12 @@
+apply plugin: 'com.android.application'
+apply plugin: 'kotlin-android'
+
+ext {
+ extName = 'JeanYves'
+ pkgNameSuffix = 'fr.jeanyves'
+ extClass = '.JeanYves'
+ extVersionCode = 1
+ libVersion = '13'
+}
+
+apply from: "$rootDir/common.gradle"
diff --git a/src/fr/jeanyves/res/mipmap-hdpi/ic_launcher.png b/src/fr/jeanyves/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 000000000..d4599ed00
Binary files /dev/null and b/src/fr/jeanyves/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/src/fr/jeanyves/res/mipmap-mdpi/ic_launcher.png b/src/fr/jeanyves/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 000000000..68e1c40a3
Binary files /dev/null and b/src/fr/jeanyves/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/src/fr/jeanyves/res/mipmap-xhdpi/ic_launcher.png b/src/fr/jeanyves/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 000000000..49a68331a
Binary files /dev/null and b/src/fr/jeanyves/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/src/fr/jeanyves/res/mipmap-xxhdpi/ic_launcher.png b/src/fr/jeanyves/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 000000000..5d5b5e213
Binary files /dev/null and b/src/fr/jeanyves/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/src/fr/jeanyves/res/mipmap-xxxhdpi/ic_launcher.png b/src/fr/jeanyves/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 000000000..144632d40
Binary files /dev/null and b/src/fr/jeanyves/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/src/fr/jeanyves/res/web_hi_res_512.png b/src/fr/jeanyves/res/web_hi_res_512.png
new file mode 100644
index 000000000..5c314e3cf
Binary files /dev/null and b/src/fr/jeanyves/res/web_hi_res_512.png differ
diff --git a/src/fr/jeanyves/src/eu/kanade/tachiyomi/animeextension/fr/jeanyves/JeanYves.kt b/src/fr/jeanyves/src/eu/kanade/tachiyomi/animeextension/fr/jeanyves/JeanYves.kt
new file mode 100644
index 000000000..8be02de3c
--- /dev/null
+++ b/src/fr/jeanyves/src/eu/kanade/tachiyomi/animeextension/fr/jeanyves/JeanYves.kt
@@ -0,0 +1,299 @@
+package eu.kanade.tachiyomi.animeextension.fr.jeanyves
+
+import android.app.Application
+import android.content.SharedPreferences
+import androidx.preference.PreferenceScreen
+import androidx.preference.SwitchPreferenceCompat
+import eu.kanade.tachiyomi.animesource.ConfigurableAnimeSource
+import eu.kanade.tachiyomi.animesource.model.AnimeFilterList
+import eu.kanade.tachiyomi.animesource.model.AnimesPage
+import eu.kanade.tachiyomi.animesource.model.SAnime
+import eu.kanade.tachiyomi.animesource.model.SEpisode
+import eu.kanade.tachiyomi.animesource.model.Video
+import eu.kanade.tachiyomi.animesource.online.ParsedAnimeHttpSource
+import eu.kanade.tachiyomi.network.GET
+import eu.kanade.tachiyomi.network.asObservableSuccess
+import eu.kanade.tachiyomi.util.asJsoup
+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 rx.Observable
+import uy.kohesive.injekt.Injekt
+import uy.kohesive.injekt.api.get
+import java.net.URLDecoder
+
+class JeanYves : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
+
+ override val name = "Jean-Yves"
+
+ override val baseUrl = "https://jeanyves.pro/data/"
+
+ private val videoFormats = arrayOf(".mkv", ".mp4", ".avi")
+
+ override val lang = "fr"
+
+ override val supportsLatest = false
+
+ override val client: OkHttpClient = network.cloudflareClient
+
+ private val chunkedSize = 200
+
+ private val preferences: SharedPreferences by lazy {
+ Injekt.get().getSharedPreferences("source_$id", 0x0000)
+ }
+
+ // ============================== Popular ===============================
+
+ override fun popularAnimeRequest(page: Int): Request = GET("$baseUrl#$page")
+
+ override fun popularAnimeParse(response: Response): AnimesPage {
+ val document = response.asJsoup()
+ val animeList = mutableListOf()
+ val page = response.request.url.encodedFragment!!.toInt()
+ val items = document.select(popularAnimeSelector())
+
+ items.chunked(chunkedSize)[page - 1].forEach {
+ val anime = SAnime.create()
+ anime.title = it.selectFirst("div.header")!!.text()
+ anime.setUrlWithoutDomain(
+ baseUrl.toHttpUrl().newBuilder()
+ .addPathSegments(it.attr("href"))
+ .build().encodedPath,
+ )
+ anime.thumbnail_url = baseUrl.toHttpUrl().newBuilder()
+ .addPathSegments(it.selectFirst("img")!!.attr("src"))
+ .build().toString()
+ animeList.add(anime)
+ }
+
+ return AnimesPage(animeList, page * chunkedSize <= items.size)
+ }
+
+ override fun popularAnimeSelector(): String = "div.cards > a.card"
+
+ override fun popularAnimeNextPageSelector(): String? = null
+
+ override fun popularAnimeFromElement(element: Element): SAnime = throw Exception("Not used")
+
+ // =============================== Latest ===============================
+
+ override fun latestUpdatesRequest(page: Int): Request = throw Exception("Not used")
+
+ override fun latestUpdatesSelector(): String = throw Exception("Not used")
+
+ override fun latestUpdatesNextPageSelector(): String = throw Exception("Not used")
+
+ override fun latestUpdatesFromElement(element: Element): SAnime = throw Exception("Not used")
+
+ // =============================== Search ===============================
+
+ override fun fetchSearchAnime(
+ page: Int,
+ query: String,
+ filters: AnimeFilterList,
+ ): Observable {
+ return Observable.defer {
+ try {
+ client.newCall(searchAnimeRequest(page, query, filters)).asObservableSuccess()
+ } catch (e: NoClassDefFoundError) {
+ // RxJava doesn't handle Errors, which tends to happen during global searches
+ // if an old extension using non-existent classes is still around
+ throw RuntimeException(e)
+ }
+ }
+ .map { response ->
+ searchAnimeParse(response, query)
+ }
+ }
+
+ override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request {
+ return GET("$baseUrl#$page")
+ }
+
+ private fun searchAnimeParse(response: Response, query: String): AnimesPage {
+ val document = response.asJsoup()
+ val animeList = mutableListOf()
+ val page = response.request.url.encodedFragment!!.toInt()
+ val items = document.select(popularAnimeSelector()).filter { t ->
+ t.selectFirst("div.header")!!.text().contains(query, true)
+ }
+
+ items.chunked(chunkedSize)[page - 1].forEach {
+ val anime = SAnime.create()
+ val name = it.selectFirst("div.header")!!.text()
+
+ anime.title = name
+ anime.setUrlWithoutDomain(
+ baseUrl.toHttpUrl().newBuilder()
+ .addPathSegments(it.attr("href"))
+ .build().encodedPath,
+ )
+ anime.thumbnail_url = baseUrl.toHttpUrl().newBuilder()
+ .addPathSegments(it.selectFirst("img")!!.attr("src"))
+ .build().toString()
+ animeList.add(anime)
+ }
+
+ return AnimesPage(animeList, page * chunkedSize <= items.size)
+ }
+
+ override fun searchAnimeSelector(): String = throw Exception("Not used")
+
+ override fun searchAnimeNextPageSelector(): String = throw Exception("Not used")
+
+ override fun searchAnimeFromElement(element: Element): SAnime = throw Exception("Not used")
+
+ // =========================== Anime Details ============================
+
+ override fun fetchAnimeDetails(anime: SAnime): Observable {
+ return Observable.just(anime)
+ }
+
+ override fun animeDetailsParse(document: Document): SAnime = throw Exception("Not used")
+
+ // ============================== Episodes ==============================
+
+ override fun fetchEpisodeList(anime: SAnime): Observable> {
+ val episodeList = mutableListOf()
+ var counter = 1
+
+ fun traverseDirectory(url: String) {
+ val doc = client.newCall(GET(url)).execute().asJsoup()
+
+ doc.select(episodeListSelector()).forEach { link ->
+ val href = link.selectFirst("a[href]")!!.attr("href")
+
+ if (href.isNotBlank() && href != "../") {
+ val fullUrl = joinUrl(url, href)
+ if (
+ href.startsWith("?dir=") &&
+ !(
+ preferences.getBoolean("ignore_bonus", true) &&
+ fullUrl.endsWith("/bonus", true)
+ )
+ ) {
+ traverseDirectory(fullUrl)
+ }
+ if (videoFormats.any { t -> fullUrl.toHttpUrl().encodedPath.endsWith(t) }) {
+ val episode = SEpisode.create()
+ val paths = fullUrl.toHttpUrl().pathSegments
+
+ val extraInfo = if (paths.size > 4) {
+ "/" + paths.subList(3, paths.size - 1).joinToString("/") { it.trimInfo() }
+ } else {
+ ""
+ }
+ val size = link.selectFirst("span.file-size")?.let { it.text().trim() }
+ val episodeNumRegex = Regex(""" - \d+x(\d+) - """)
+
+ episode.name = URLDecoder.decode(
+ videoFormats.fold(paths.last()) { acc, suffix -> acc.removeSuffix(suffix).trimInfo() },
+ "UTF-8",
+ )
+ episode.url = fullUrl
+ episode.scanlator = URLDecoder.decode("${extraInfo.ifBlank { "/" }}${if (size == null) "" else " • $size"}", "UTF-8")
+ episode.episode_number = episodeNumRegex.find(paths.last())?.let {
+ it.groupValues[1].toFloatOrNull()
+ } ?: counter.toFloat()
+ counter++
+
+ episodeList.add(episode)
+ }
+ }
+ }
+ }
+
+ traverseDirectory("https://${baseUrl.toHttpUrl().host}${anime.url}".toHttpUrl().toString())
+
+ val episodes = if (preferences.getBoolean("attempt_sort", true)) {
+ val seasonRegex = Regex(""" - (\d+|None)+x(?:\d+|None) - """)
+ val episodeRegex = Regex(""" - (?:\d+|None)x(\d+|None) - """)
+ episodeList.sortedWith(
+ compareBy(
+ { epName ->
+ seasonRegex.find(epName.name)?.let {
+ it.groupValues[1].toFloatOrNull()
+ } ?: 0F
+ },
+ { epName ->
+ episodeRegex.find(epName.name)?.let {
+ it.groupValues[1].toFloatOrNull()
+ } ?: 0F
+ },
+ ),
+ ).reversed()
+ } else {
+ episodeList.reversed()
+ }
+
+ return Observable.just(episodes)
+ }
+
+ override fun episodeListParse(response: Response): List = throw Exception("Not used")
+
+ override fun episodeListSelector(): String = "ul#directory-listing > li"
+
+ override fun episodeFromElement(element: Element): SEpisode = throw Exception("Not used")
+
+ // ============================ Video Links =============================
+
+ override fun fetchVideoList(episode: SEpisode): Observable> {
+ return Observable.just(listOf(Video(episode.url, "Video", episode.url)))
+ }
+
+ override fun videoFromElement(element: Element): Video = throw Exception("Not Used")
+
+ override fun videoListSelector(): String = throw Exception("Not Used")
+
+ override fun videoUrlParse(document: Document): String = throw Exception("Not Used")
+
+ // ============================= Utilities ==============================
+
+ private fun joinUrl(url: String, path2: String): String {
+ return url.toHttpUrl().newBuilder()
+ .query("")
+ .toString()
+ .removeSuffix("?")
+ .removeSuffix("/") +
+ "/" +
+ path2.removePrefix("/")
+ }
+
+ private fun String.trimInfo(): String {
+ var newString = this.replaceFirst("""^\[\w+\] """.toRegex(), "")
+ val regex = """( ?\[[\s\w-]+\]| ?\([\s\w-]+\))(\.mkv|\.mp4|\.avi)?${'$'}""".toRegex()
+
+ while (regex.containsMatchIn(newString)) {
+ newString = regex.replace(newString) { matchResult ->
+ matchResult.groups[2]?.value ?: ""
+ }
+ }
+
+ return newString
+ }
+
+ override fun setupPreferenceScreen(screen: PreferenceScreen) {
+ val ignoreExtras = SwitchPreferenceCompat(screen.context).apply {
+ key = "ignore_bonus"
+ title = "Ignore \"Bonus\" folder"
+ setDefaultValue(true)
+ setOnPreferenceChangeListener { _, newValue ->
+ preferences.edit().putBoolean(key, newValue as Boolean).commit()
+ }
+ }
+ val attemptEpsiodeSort = SwitchPreferenceCompat(screen.context).apply {
+ key = "attempt_sort"
+ title = "Attempt episode sorting"
+ summary = "Attempt sorting based on season and episode number"
+ setDefaultValue(true)
+ setOnPreferenceChangeListener { _, newValue ->
+ preferences.edit().putBoolean(key, newValue as Boolean).commit()
+ }
+ }
+ screen.addPreference(ignoreExtras)
+ screen.addPreference(attemptEpsiodeSort)
+ }
+}