diff --git a/src/all/javguru/AndroidManifest.xml b/src/all/javguru/AndroidManifest.xml
new file mode 100644
index 000000000..ce6b23f5e
--- /dev/null
+++ b/src/all/javguru/AndroidManifest.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/all/javguru/build.gradle b/src/all/javguru/build.gradle
new file mode 100644
index 000000000..fe11107fc
--- /dev/null
+++ b/src/all/javguru/build.gradle
@@ -0,0 +1,23 @@
+plugins {
+ alias(libs.plugins.android.application)
+ alias(libs.plugins.kotlin.android)
+}
+
+ext {
+ extName = 'Jav Guru'
+ pkgNameSuffix = 'all.javguru'
+ extClass = '.JavGuru'
+ extVersionCode = 1
+ isNsfw = true
+}
+
+dependencies {
+ implementation(project(':lib-streamsb-extractor'))
+ implementation(project(':lib-streamtape-extractor'))
+ implementation(project(':lib-dood-extractor'))
+ implementation(project(':lib-mixdrop-extractor'))
+ implementation "dev.datlag.jsunpacker:jsunpacker:1.0.1"
+ implementation(project(':lib-playlist-utils'))
+}
+
+apply from: "$rootDir/common.gradle"
diff --git a/src/all/javguru/res/mipmap-hdpi/ic_launcher.png b/src/all/javguru/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 000000000..bbcb061b6
Binary files /dev/null and b/src/all/javguru/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/src/all/javguru/res/mipmap-mdpi/ic_launcher.png b/src/all/javguru/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 000000000..0d256b8e1
Binary files /dev/null and b/src/all/javguru/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/src/all/javguru/res/mipmap-xhdpi/ic_launcher.png b/src/all/javguru/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 000000000..482f71a66
Binary files /dev/null and b/src/all/javguru/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/src/all/javguru/res/mipmap-xxhdpi/ic_launcher.png b/src/all/javguru/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 000000000..d318247a7
Binary files /dev/null and b/src/all/javguru/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/src/all/javguru/res/mipmap-xxxhdpi/ic_launcher.png b/src/all/javguru/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 000000000..4e21545ca
Binary files /dev/null and b/src/all/javguru/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/src/all/javguru/res/web_hi_res_512.png b/src/all/javguru/res/web_hi_res_512.png
new file mode 100644
index 000000000..1e1e735b7
Binary files /dev/null and b/src/all/javguru/res/web_hi_res_512.png differ
diff --git a/src/all/javguru/src/eu/kanade/tachiyomi/animeextension/all/javguru/JavGuru.kt b/src/all/javguru/src/eu/kanade/tachiyomi/animeextension/all/javguru/JavGuru.kt
new file mode 100644
index 000000000..190730a2e
--- /dev/null
+++ b/src/all/javguru/src/eu/kanade/tachiyomi/animeextension/all/javguru/JavGuru.kt
@@ -0,0 +1,335 @@
+package eu.kanade.tachiyomi.animeextension.all.javguru
+
+import android.util.Base64
+import eu.kanade.tachiyomi.animeextension.all.javguru.extractors.MaxStreamExtractor
+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.lib.doodextractor.DoodExtractor
+import eu.kanade.tachiyomi.lib.mixdropextractor.MixDropExtractor
+import eu.kanade.tachiyomi.lib.streamsbextractor.StreamSBExtractor
+import eu.kanade.tachiyomi.lib.streamtapeextractor.StreamTapeExtractor
+import eu.kanade.tachiyomi.network.GET
+import eu.kanade.tachiyomi.network.asObservable
+import eu.kanade.tachiyomi.network.asObservableSuccess
+import eu.kanade.tachiyomi.network.interceptor.rateLimit
+import eu.kanade.tachiyomi.util.asJsoup
+import okhttp3.Call
+import okhttp3.HttpUrl.Companion.toHttpUrl
+import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
+import okhttp3.Request
+import okhttp3.Response
+import org.jsoup.select.Elements
+import rx.Observable
+import kotlin.math.min
+
+class JavGuru : AnimeHttpSource() {
+
+ override val name = "Jav Guru"
+
+ override val baseUrl = "https://jav.guru"
+
+ override val lang = "all"
+
+ override val supportsLatest = true
+
+ override val client = network.cloudflareClient.newBuilder()
+ .rateLimit(2)
+ .build()
+
+ override fun headersBuilder() = super.headersBuilder()
+ .add("Referer", "$baseUrl/")
+
+ private val streamSbExtractor: StreamSBExtractor by lazy {
+ StreamSBExtractor(client)
+ }
+
+ private val streamTapeExtractor: StreamTapeExtractor by lazy {
+ StreamTapeExtractor(client)
+ }
+
+ private val doodExtractor: DoodExtractor by lazy {
+ DoodExtractor(client)
+ }
+
+ private val mixDropExtractor: MixDropExtractor by lazy {
+ MixDropExtractor(client)
+ }
+
+ private val maxStreamExtractor: MaxStreamExtractor by lazy {
+ MaxStreamExtractor(client)
+ }
+
+ private lateinit var popularElements: Elements
+
+ override fun fetchPopularAnime(page: Int): Observable {
+ return if (page == 1) {
+ client.newCall(popularAnimeRequest(page))
+ .asObservableSuccess()
+ .map(::popularAnimeParse)
+ } else {
+ Observable.just(cachedPopularAnimeParse(page))
+ }
+ }
+
+ override fun popularAnimeRequest(page: Int) =
+ GET("$baseUrl/most-watched-rank/", headers)
+
+ override fun popularAnimeParse(response: Response): AnimesPage {
+ popularElements = response.asJsoup().select(".tabcontent li")
+
+ return cachedPopularAnimeParse(1)
+ }
+
+ private fun cachedPopularAnimeParse(page: Int): AnimesPage {
+ val end = min(page * 20, popularElements.size)
+ val entries = popularElements.subList((page - 1) * 20, end).map { element ->
+ SAnime.create().apply {
+ element.select("a").let { a ->
+ getIDFromUrl(a)?.let { url = it }
+ ?: setUrlWithoutDomain(a.attr("href"))
+
+ title = a.text()
+ thumbnail_url = a.select("img").attr("abs:src")
+ }
+ }
+ }
+ return AnimesPage(entries, end < popularElements.size)
+ }
+
+ override fun latestUpdatesRequest(page: Int): Request {
+ val url = baseUrl + if (page > 1) "/page/$page/" else ""
+
+ return GET(url, headers)
+ }
+
+ override fun latestUpdatesParse(response: Response): AnimesPage {
+ val document = response.asJsoup()
+
+ val entries = document.select("div.site-content div.inside-article:not(:contains(nothing))").map { element ->
+ SAnime.create().apply {
+ element.select("a").let { a ->
+ getIDFromUrl(a)?.let { url = it }
+ ?: setUrlWithoutDomain(a.attr("href"))
+ }
+ thumbnail_url = element.select("img").attr("abs:src")
+ title = element.select("h2 > a").text()
+ }
+ }
+
+ val page = document.location()
+ .substringBeforeLast("/").toHttpUrlOrNull()
+ ?.pathSegments?.last()?.toIntOrNull() ?: 1
+
+ val lastPage = document.select("div.wp-pagenavi a")
+ .last()?.attr("href")?.substringBeforeLast("/")
+ ?.toHttpUrlOrNull()?.pathSegments?.last()?.toIntOrNull() ?: 1
+
+ return AnimesPage(entries, page < lastPage)
+ }
+
+ override fun fetchSearchAnime(page: Int, query: String, filters: AnimeFilterList): Observable {
+ if (query.startsWith(PREFIX_ID)) {
+ val id = query.substringAfter(PREFIX_ID)
+ if (id.toIntOrNull() == null) {
+ return Observable.just(AnimesPage(emptyList(), false))
+ }
+ val url = "/$id/"
+ val tempAnime = SAnime.create().apply { this.url = url }
+ return fetchAnimeDetails(tempAnime).map {
+ val anime = it.apply { this.url = url }
+ AnimesPage(listOf(anime), false)
+ }
+ } else if (query.isNotEmpty()) {
+ return client.newCall(searchAnimeRequest(page, query, filters))
+ .asObservableSuccess()
+ .map(::searchAnimeParse)
+ } else {
+ filters.forEach { filter ->
+ when (filter) {
+ is TagFilter,
+ is CategoryFilter,
+ -> {
+ if (filter.state != 0) {
+ val url = "$baseUrl${filter.toUrlPart()}" + if (page > 1) "page/$page/" else ""
+ val request = GET(url, headers)
+ return client.newCall(request)
+ .asObservableIgnoreCode(404)
+ .map(::searchAnimeParse)
+ }
+ }
+ is ActressFilter,
+ is ActorFilter,
+ is StudioFilter,
+ is MakerFilter,
+ -> {
+ if ((filter.state as String).isNotEmpty()) {
+ val url = "$baseUrl${filter.toUrlPart()}" + if (page > 1) "page/$page/" else ""
+ val request = GET(url, headers)
+ return client.newCall(request)
+ .asObservableIgnoreCode(404)
+ .map(::searchAnimeParse)
+ }
+ }
+ else -> { }
+ }
+ }
+ }
+
+ throw Exception("Select at least one Filter")
+ }
+
+ override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request {
+ val url = baseUrl.toHttpUrl().newBuilder().apply {
+ if (page > 1) addPathSegments("page/$page/")
+ addQueryParameter("s", query)
+ }.build().toString()
+
+ return GET(url, headers)
+ }
+
+ override fun getFilterList() = getFilters()
+
+ override fun searchAnimeParse(response: Response) = latestUpdatesParse(response)
+
+ override fun animeDetailsParse(response: Response): SAnime {
+ val document = response.asJsoup()
+
+ return SAnime.create().apply {
+ title = document.select(".titl").text()
+ thumbnail_url = document.select(".large-screenshot img").attr("abs:src")
+ genre = document.select(".infoleft a[rel*=tag]").joinToString { it.text() }
+ author = document.selectFirst(".infoleft li:contains(studio) a")?.text()
+ artist = document.selectFirst(".infoleft li:contains(label) a")?.text()
+ status = SAnime.COMPLETED
+ description = buildString {
+ document.selectFirst(".infoleft li:contains(code)")?.text()?.let { append("$it\n") }
+ document.selectFirst(".infoleft li:contains(director)")?.text()?.let { append("$it\n") }
+ document.selectFirst(".infoleft li:contains(studio)")?.text()?.let { append("$it\n") }
+ document.selectFirst(".infoleft li:contains(label)")?.text()?.let { append("$it\n") }
+ document.selectFirst(".infoleft li:contains(actor)")?.text()?.let { append("$it\n") }
+ document.selectFirst(".infoleft li:contains(actress)")?.text()?.let { append("$it\n") }
+ }
+ }
+ }
+
+ override fun fetchEpisodeList(anime: SAnime): Observable> {
+ return Observable.just(
+ listOf(
+ SEpisode.create().apply {
+ url = anime.url
+ name = "Episode"
+ },
+ ),
+ )
+ }
+
+ override fun videoListParse(response: Response): List