diff --git a/src/en/nollyverse/AndroidManifest.xml b/src/en/nollyverse/AndroidManifest.xml new file mode 100644 index 0000000000..568741e54f --- /dev/null +++ b/src/en/nollyverse/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/src/en/nollyverse/build.gradle b/src/en/nollyverse/build.gradle new file mode 100644 index 0000000000..2bd94f69c6 --- /dev/null +++ b/src/en/nollyverse/build.gradle @@ -0,0 +1,11 @@ +apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' + +ext { + extName = 'NollyVerse' + pkgNameSuffix = 'en.nollyverse' + extClass = '.NollyVerse' + extVersionCode = 3 +} + +apply from: "$rootDir/common.gradle" diff --git a/src/en/nollyverse/res/mipmap-hdpi/ic_launcher.png b/src/en/nollyverse/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000..01b41f68ad Binary files /dev/null and b/src/en/nollyverse/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/en/nollyverse/res/mipmap-mdpi/ic_launcher.png b/src/en/nollyverse/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000..505832322c Binary files /dev/null and b/src/en/nollyverse/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/en/nollyverse/res/mipmap-xhdpi/ic_launcher.png b/src/en/nollyverse/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000000..62f929f72c Binary files /dev/null and b/src/en/nollyverse/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/en/nollyverse/res/mipmap-xxhdpi/ic_launcher.png b/src/en/nollyverse/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000..f5e1c241b0 Binary files /dev/null and b/src/en/nollyverse/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/en/nollyverse/res/mipmap-xxxhdpi/ic_launcher.png b/src/en/nollyverse/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000..beeb4e3c62 Binary files /dev/null and b/src/en/nollyverse/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/en/nollyverse/res/web_hi_res_512.png b/src/en/nollyverse/res/web_hi_res_512.png new file mode 100644 index 0000000000..cd31026f19 Binary files /dev/null and b/src/en/nollyverse/res/web_hi_res_512.png differ diff --git a/src/en/nollyverse/src/eu/kanade/tachiyomi/animeextension/en/nollyverse/NollyVerse.kt b/src/en/nollyverse/src/eu/kanade/tachiyomi/animeextension/en/nollyverse/NollyVerse.kt new file mode 100644 index 0000000000..7fea856d69 --- /dev/null +++ b/src/en/nollyverse/src/eu/kanade/tachiyomi/animeextension/en/nollyverse/NollyVerse.kt @@ -0,0 +1,552 @@ +package eu.kanade.tachiyomi.animeextension.en.nollyverse + +import android.app.Application +import android.content.SharedPreferences +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.network.GET +import eu.kanade.tachiyomi.network.POST +import eu.kanade.tachiyomi.util.asJsoup +import okhttp3.FormBody +import okhttp3.HttpUrl.Companion.toHttpUrl +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 + +class NollyVerse : ConfigurableAnimeSource, ParsedAnimeHttpSource() { + + override val name = "NollyVerse" + + override val baseUrl = "https://www.thenollyverse.com" + + override val lang = "en" + + override val supportsLatest = true + + private val preferences: SharedPreferences by lazy { + Injekt.get().getSharedPreferences("source_$id", 0x0000) + } + + // ============================== Popular =============================== + + override fun popularAnimeRequest(page: Int): Request = GET("$baseUrl/category/trending-movies/page/$page/") + + override fun popularAnimeParse(response: Response): AnimesPage { + val document = response.asJsoup() + + val animes = document.select(popularAnimeSelector()).map { element -> + popularAnimeFromElement(element) + } + + val hasNextPage = popularAnimeNextPageSelector()?.let { selector -> + if (document.select(selector).text() != ">") { + return AnimesPage(animes, false) + } + document.select(selector).first() + } != null + + return AnimesPage(animes, hasNextPage) + } + + override fun popularAnimeSelector(): String = "div.col-md-8 div.row div.col-md-6" + + override fun popularAnimeFromElement(element: Element): SAnime = SAnime.create().apply { + title = element.select("div.post-body h3 a").text() + thumbnail_url = element.select("a.post-img img").attr("data-src") + setUrlWithoutDomain(element.select("a.post-img").attr("href")) + } + + override fun popularAnimeNextPageSelector(): String = "div.loadmore ul.pagination.pagination-md li:nth-last-child(2)" + + // =============================== Latest =============================== + + override fun latestUpdatesRequest(page: Int): Request = GET("$baseUrl/category/new-series/page/$page/") + + override fun latestUpdatesParse(response: Response): AnimesPage { + val document = response.asJsoup() + + val animes = document.select(latestUpdatesSelector()).map { element -> + latestUpdatesFromElement(element) + } + + val hasNextPage = latestUpdatesNextPageSelector()?.let { selector -> + if (document.select(selector).text() != ">") { + return AnimesPage(animes, false) + } + document.select(selector).first() + } != null + + return AnimesPage(animes, hasNextPage) + } + + override fun latestUpdatesSelector(): String = "div.section div.container div.row div.post.post-row" + + override fun latestUpdatesFromElement(element: Element): SAnime = SAnime.create().apply { + title = element.select("div.post-body h3 a").text() + thumbnail_url = element.select("a.post-img img").attr("data-src").ifEmpty { + element.select("a.post-img img").attr("src") + } + setUrlWithoutDomain(element.select("a.post-img").attr("href")) + } + + override fun latestUpdatesNextPageSelector(): String = "div.loadmore ul.pagination.pagination-md li:nth-last-child(2)" + + // =============================== Search =============================== + + override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request { + return if (query.isNotBlank()) { + val body = FormBody.Builder() + .add("name", "$query") + .build() + POST("$baseUrl/livesearch.php", body = body) + } else { + var searchPath = "" + filters.filter { it.state != 0 }.forEach { filter -> + when (filter) { + is CategoryFilter -> searchPath = if (filter.toUriPart() == "/series/") filter.toUriPart() else "${filter.toUriPart()}page/$page" + is MovieGenreFilter -> searchPath = "/movies/genre/${filter.toUriPart()}/page/$page" + is SeriesGenreFilter -> searchPath = "/series/genre/${filter.toUriPart()}/page/$page" + else -> "" + } + } + + require(searchPath.isNotEmpty()) { "Search must not be empty" } + + GET(baseUrl + searchPath) + } + } + + override fun searchAnimeParse(response: Response): AnimesPage { + val document = response.asJsoup() + val path = response.request.url.encodedPath + + var hasNextPage: Boolean + var animes: List + + when { + path.startsWith("/livesearch") -> { + hasNextPage = false + animes = document.select(searchAnimeSelector()).map { element -> + searchAnimeFromElement(element) + } + } + + path.startsWith("/movies/genre/") -> { + animes = document.select(movieGenreSelector()).map { element -> + movieGenreFromElement(element) + } + hasNextPage = nextPageSelector()?.let { selector -> + if (document.select(selector).text() != ">") { + return AnimesPage(animes, false) + } + document.select(selector).first() + } != null + } + + path.startsWith("/series/genre/") || + path.startsWith("/category/popular-movies/") || + path.startsWith("/category/trending-movies/") -> { + animes = document.select(seriesGenreSelector()).map { element -> + seriesGenreFromElement(element) + } + hasNextPage = nextPageSelector()?.let { selector -> + if (document.select(selector).text() != ">") { + return AnimesPage(animes, false) + } + document.select(selector).first() + } != null + } + + path.startsWith("/category/korean-movies/") || path.startsWith("/category/korean-series/") -> { + animes = document.select(koreanSelector()).map { element -> + koreanFromElement(element) + } + hasNextPage = nextPageSelector()?.let { selector -> + if (document.select(selector).text() != ">") { + return AnimesPage(animes, false) + } + document.select(selector).first() + } != null + } + + path.startsWith("/category/latest-movies/") || + path.startsWith("/category/new-series/") || + path.startsWith("/category/latest-uploads/") -> { + animes = document.select(latestSelector()).map { element -> + latestFromElement(element) + } + hasNextPage = nextPageSelector()?.let { selector -> + if (document.select(selector).text() != ">") { + return AnimesPage(animes, false) + } + document.select(selector).first() + } != null + } + + path.startsWith("/series/") -> { + animes = document.select(seriesSelector()).map { element -> + seriesFromElement(element) + } + hasNextPage = false + } + + else -> { + animes = document.select(movieSelector()).map { element -> + movieFromElement(element) + } + hasNextPage = nextPageSelector()?.let { selector -> + if (document.select(selector).text() != ">") { + return AnimesPage(animes, false) + } + document.select(selector).first() + } != null + } + } + + return AnimesPage(animes, hasNextPage) + } + + private fun movieGenreSelector(): String = "div.container > div.row > div.col-md-4" + + private fun movieGenreFromElement(element: Element): SAnime = latestUpdatesFromElement(element) + + private fun seriesGenreSelector(): String = "div.row div.col-md-8 div.col-md-6" + + private fun seriesGenreFromElement(element: Element): SAnime = latestUpdatesFromElement(element) + + private fun koreanSelector(): String = "div.col-md-8 div.row div.col-md-6" + + private fun koreanFromElement(element: Element): SAnime = latestUpdatesFromElement(element) + + private fun latestSelector(): String = latestUpdatesSelector() + + private fun latestFromElement(element: Element): SAnime = latestUpdatesFromElement(element) + + private fun seriesSelector(): String = "div.section-row ul.list-style li" + + private fun seriesFromElement(element: Element): SAnime = SAnime.create().apply { + title = element.select("a").text() + thumbnail_url = toImgUrl(element.select("a").attr("href")) + setUrlWithoutDomain(element.select("a").attr("href")) + } + + private fun movieSelector(): String = "div.container div.row div.col-md-12 div.col-md-4" + + private fun movieFromElement(element: Element): SAnime = SAnime.create().apply { + title = element.select("h3 a").text() + thumbnail_url = element.select("a img").attr("src") + setUrlWithoutDomain(element.select("a.post-img").attr("href")) + } + + override fun searchAnimeSelector(): String = "a" + + override fun searchAnimeFromElement(element: Element): SAnime = SAnime.create().apply { + title = element.text() + thumbnail_url = toImgUrl(element.attr("href")) + setUrlWithoutDomain(element.attr("href")) + } + + private fun nextPageSelector(): String = "ul.pagination.pagination-md li:nth-last-child(2)" + + override fun searchAnimeNextPageSelector(): String = throw Exception("Not used") + + // =========================== Anime Details ============================ + + override fun animeDetailsParse(document: Document): SAnime = SAnime.create().apply { + title = document.select("div.page-header div.container div.row div.text-center h1").text() + description = document.select("blockquote.blockquote small").text() + genre = document.select("div.col-md-8 ul.list-style li").firstOrNull { + it.text().startsWith("Genre: ") + }?.text()?.substringAfter("Genre: ")?.replace(",", ", ") + } + + // ============================== Episodes ============================== + + override fun episodeListRequest(anime: SAnime): Request { + return if (anime.url.startsWith("/movie/")) { + GET(baseUrl + anime.url + "/download/", headers) + } else { + GET(baseUrl + anime.url + "/seasons/", headers) + } + } + + override fun episodeListParse(response: Response): List { + val path = response.request.url.encodedPath + + val document = response.asJsoup() + val episodeList = mutableListOf() + + if (path.startsWith("/movie/")) { + episodeList.add( + SEpisode.create().apply { + name = "Movie" + episode_number = 1F + setUrlWithoutDomain(path) + }, + ) + } else { + var counter = 1 + for (season in document.select("table.table.table-striped tbody tr").reversed()) { + val seasonUrl = season.select("td a[href]").attr("href") + val seasonSoup = client.newCall( + GET(seasonUrl, headers), + ).execute().asJsoup() + + val episodeTable = seasonSoup.select("table.table.table-striped") + val seasonNumber = episodeTable.select("thead th").eachText().find { + t -> + """Season (\d+)""".toRegex().matches(t) + }?.split(" ")!![1] + + for (ep in episodeTable.select("tbody tr")) { + episodeList.add( + SEpisode.create().apply { + name = "Episode S${seasonNumber}E${ep.selectFirst("td")!!.text().split(" ")!![1]}" + episode_number = counter.toFloat() + setUrlWithoutDomain(seasonUrl + "#$counter") + }, + ) + counter++ + } + + // Stop abuse + Thread.sleep(500) + } + } + + return episodeList.reversed() + } + + override fun episodeFromElement(element: Element): SEpisode { + val seasonNum = element.ownerDocument()!!.select("div.Title span").text() + + return SEpisode.create().apply { + name = "Season $seasonNum" + "x" + element.select("td span.Num").text() + " : " + element.select("td.MvTbTtl > a").text() + episode_number = element.select("td > span.Num").text().toFloat() + setUrlWithoutDomain(element.select("td.MvTbPly > a.ClA").attr("abs:href")) + } + } + + override fun episodeListSelector() = throw Exception("not used") + + // ============================ Video Links ============================= + + override fun videoListRequest(episode: SEpisode): Request { + return if (episode.name == "Movie") { + GET(baseUrl + episode.url + "#movie", headers) + } else { + val episodeIndex = """Episode S(\d+)E(?\d+)""".toRegex().matchEntire( + episode.name, + )!!.groups["num"]!!.value + GET(baseUrl + episode.url.replaceAfterLast("#", "") + episodeIndex, headers) + } + } + + override fun videoListParse(response: Response): List