diff --git a/src/en/multporn/AndroidManifest.xml b/src/en/multporn/AndroidManifest.xml
new file mode 100644
index 000000000..30deb7f79
--- /dev/null
+++ b/src/en/multporn/AndroidManifest.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/src/en/multporn/build.gradle b/src/en/multporn/build.gradle
new file mode 100644
index 000000000..193111b9b
--- /dev/null
+++ b/src/en/multporn/build.gradle
@@ -0,0 +1,13 @@
+apply plugin: 'com.android.application'
+apply plugin: 'kotlin-android'
+
+ext {
+ extName = 'Multporn'
+ pkgNameSuffix = 'en.multporn'
+ extClass = '.Multporn'
+ extVersionCode = 1
+ libVersion = '1.2'
+ containsNsfw = true
+}
+
+apply from: "$rootDir/common.gradle"
\ No newline at end of file
diff --git a/src/en/multporn/res/mipmap-hdpi/ic_launcher.png b/src/en/multporn/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 000000000..8d9c2e0a2
Binary files /dev/null and b/src/en/multporn/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/src/en/multporn/res/mipmap-mdpi/ic_launcher.png b/src/en/multporn/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 000000000..b06e8d083
Binary files /dev/null and b/src/en/multporn/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/src/en/multporn/res/mipmap-xhdpi/ic_launcher.png b/src/en/multporn/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 000000000..8bc6400ed
Binary files /dev/null and b/src/en/multporn/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/src/en/multporn/res/mipmap-xxhdpi/ic_launcher.png b/src/en/multporn/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 000000000..de286ccc3
Binary files /dev/null and b/src/en/multporn/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/src/en/multporn/res/mipmap-xxxhdpi/ic_launcher.png b/src/en/multporn/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 000000000..01a24d3e9
Binary files /dev/null and b/src/en/multporn/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/src/en/multporn/res/web_hi_res_512.png b/src/en/multporn/res/web_hi_res_512.png
new file mode 100644
index 000000000..3e7c022d8
Binary files /dev/null and b/src/en/multporn/res/web_hi_res_512.png differ
diff --git a/src/en/multporn/src/eu/kanade/tachiyomi/extension/en/multporn/Multporn.kt b/src/en/multporn/src/eu/kanade/tachiyomi/extension/en/multporn/Multporn.kt
new file mode 100644
index 000000000..69b01d59a
--- /dev/null
+++ b/src/en/multporn/src/eu/kanade/tachiyomi/extension/en/multporn/Multporn.kt
@@ -0,0 +1,426 @@
+package eu.kanade.tachiyomi.extension.en.multporn
+
+import com.github.salomonbrys.kotson.fromJson
+import com.github.salomonbrys.kotson.get
+import com.google.gson.Gson
+import com.google.gson.JsonArray
+import eu.kanade.tachiyomi.annotations.Nsfw
+import eu.kanade.tachiyomi.network.GET
+import eu.kanade.tachiyomi.network.POST
+import eu.kanade.tachiyomi.network.asObservable
+import eu.kanade.tachiyomi.network.asObservableSuccess
+import eu.kanade.tachiyomi.source.model.Filter
+import eu.kanade.tachiyomi.source.model.FilterList
+import eu.kanade.tachiyomi.source.model.MangasPage
+import eu.kanade.tachiyomi.source.model.Page
+import eu.kanade.tachiyomi.source.model.SChapter
+import eu.kanade.tachiyomi.source.model.SManga
+import eu.kanade.tachiyomi.source.online.ParsedHttpSource
+import eu.kanade.tachiyomi.util.asJsoup
+import okhttp3.FormBody
+import okhttp3.Headers
+import okhttp3.HttpUrl
+import okhttp3.MediaType
+import okhttp3.Request
+import okhttp3.Response
+import okhttp3.ResponseBody
+import org.jsoup.nodes.Document
+import org.jsoup.nodes.Element
+import rx.Observable
+import rx.schedulers.Schedulers
+import java.util.Locale
+
+@Nsfw
+class Multporn : ParsedHttpSource() {
+
+ override val name = "Multporn"
+ override val lang: String = "en"
+ override val baseUrl = "https://multporn.net"
+ override val supportsLatest = true
+
+ private val gson = Gson()
+
+ override fun headersBuilder(): Headers.Builder = Headers.Builder()
+ .add("User-Agent", HEADER_USER_AGENT)
+ .add("Content-Type", HEADER_CONTENT_TYPE)
+
+ // Popular
+
+ private fun buildPopularMangaRequest(page: Int, filters: FilterList = FilterList()): Request {
+ val body = FormBody.Builder()
+ .addEncoded("page", page.toString())
+ .addEncoded("view_name", "top")
+ .addEncoded("view_display_id", "page")
+
+ (if (filters.isEmpty()) getFilterList(POPULAR_DEFAULT_SORT_BY_FILTER_STATE) else filters).forEach {
+ when (it) {
+ is SortBySelectFilter -> body.addEncoded("sort_by", it.selected.uri)
+ is SortOrderSelectFilter -> body.addEncoded("sort_order", it.selected.uri)
+ is PopularTypeSelectFilter -> body.addEncoded("type", it.selected.uri)
+ else -> { }
+ }
+ }
+
+ return POST("$baseUrl/views/ajax", headers, body.build())
+ }
+
+ override fun popularMangaRequest(page: Int) = buildPopularMangaRequest(page - 1)
+
+ override fun popularMangaParse(response: Response): MangasPage {
+ val html = gson.fromJson(response.body()!!.string())
+ .last { it["command"].asString == "insert" }.asJsonObject["data"].asString
+
+ return super.popularMangaParse(
+ response.newBuilder()
+ .body(ResponseBody.create(MediaType.parse("text/html; charset=UTF-8"), html))
+ .build()
+ )
+ }
+
+ override fun popularMangaSelector() = ".masonry-item"
+ override fun popularMangaNextPageSelector() = ".pager-next a"
+ override fun popularMangaFromElement(element: Element): SManga = SManga.create().apply {
+ url = element.select(".views-field-title a").attr("href")
+ title = element.select(".views-field-title").text()
+ thumbnail_url = element.select("img").attr("abs:src")
+ }
+
+ // Latest
+
+ private fun buildLatestMangaRequest(page: Int, filters: FilterList = FilterList()): Request {
+ val url = HttpUrl.parse("$baseUrl/new")!!.newBuilder()
+ .addQueryParameter("page", page.toString())
+
+ (if (filters.isEmpty()) getFilterList(LATEST_DEFAULT_SORT_BY_FILTER_STATE) else filters).forEach {
+ when (it) {
+ is SortBySelectFilter -> url.addQueryParameter("sort_by", it.selected.uri)
+ is SortOrderSelectFilter -> url.addQueryParameter("sort_order", it.selected.uri)
+ is LatestTypeSelectFilter -> url.addQueryParameter("type", it.selected.uri)
+ else -> { }
+ }
+ }
+
+ return GET(url.toString(), headers)
+ }
+
+ override fun latestUpdatesRequest(page: Int) = buildLatestMangaRequest(page - 1)
+
+ override fun latestUpdatesSelector() = popularMangaSelector()
+
+ override fun latestUpdatesNextPageSelector() = popularMangaNextPageSelector()
+
+ override fun latestUpdatesFromElement(element: Element) = popularMangaFromElement(element)
+
+ // Search
+
+ private fun textSearchFilterParse(response: Response): MangasPage {
+ val document = response.asJsoup()
+ val mangas = document.select("#content .col-1:contains(Views:),.col-2:contains(Views:)")
+ .map { popularMangaFromElement(it) }
+
+ val hasNextPage = popularMangaNextPageSelector().let {
+ document.select(it).firstOrNull()
+ } != null
+
+ return MangasPage(mangas, hasNextPage)
+ }
+
+ private fun buildSearchMangaRequest(page: Int, query: String, filtersArg: FilterList = FilterList()): Request {
+ val url = HttpUrl.parse("$baseUrl/search")!!.newBuilder()
+ .addQueryParameter("page", page.toString())
+ .addQueryParameter("search_api_views_fulltext", query)
+
+ (if (filtersArg.isEmpty()) getFilterList(SEARCH_DEFAULT_SORT_BY_FILTER_STATE) else filtersArg).forEach {
+ when (it) {
+ is SortBySelectFilter -> url.addQueryParameter("sort_by", it.selected.uri)
+ is SearchTypeSelectFilter -> url.addQueryParameter("type", it.selected.uri)
+ else -> { }
+ }
+ }
+
+ return GET(url.toString(), headers)
+ }
+
+ private fun buildTextSearchFilterRequests(page: Int, filters: List): List {
+ return filters.map {
+ it.stateURIs.map { queryURI ->
+ GET("$baseUrl/${it.uri}/$queryURI?page=0,$page")
+ }
+ }.flatten()
+ }
+
+ private fun squashMangasPageObservables(observables: List>): Observable {
+ return Observable.from(observables)
+ .flatMap { it.observeOn(Schedulers.io()) }
+ .toList()
+ .map { it.filterNotNull() }
+ .map { pages ->
+ MangasPage(
+ pages.map { it.mangas }.flatten().distinctBy { it.url },
+ pages.any { it.hasNextPage }
+ )
+ }
+ }
+
+ override fun searchMangaSelector() = popularMangaSelector()
+
+ override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable {
+
+ val sortByFilterType = filters.findInstance()?.requestType ?: POPULAR_REQUEST_TYPE
+ val textSearchFilters = filters.filterIsInstance().filter { it.state.isNotBlank() }
+
+ return when {
+ textSearchFilters.isNotEmpty() -> {
+ val requests = buildTextSearchFilterRequests(page - 1, textSearchFilters)
+
+ squashMangasPageObservables(
+ requests.map {
+ client.newCall(it).asObservable().map { res ->
+ if (res.code() == 200) textSearchFilterParse(res)
+ else null
+ }
+ }
+ )
+ }
+ query.isNotEmpty() || sortByFilterType == SEARCH_REQUEST_TYPE -> {
+ val request = buildSearchMangaRequest(page - 1, query, filters)
+ client.newCall(request).asObservableSuccess().map { searchMangaParse(it) }
+ }
+ sortByFilterType == LATEST_REQUEST_TYPE -> {
+ val request = buildLatestMangaRequest(page - 1, filters)
+ client.newCall(request).asObservableSuccess().map { latestUpdatesParse(it) }
+ }
+ else -> {
+ val request = buildPopularMangaRequest(page - 1, filters)
+ client.newCall(request).asObservableSuccess().map { popularMangaParse(it) }
+ }
+ }
+ }
+
+ override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
+
+ val sortByFilterType = filters.findInstance()?.requestType ?: POPULAR_REQUEST_TYPE
+
+ return when {
+ query.isNotEmpty() || sortByFilterType == SEARCH_REQUEST_TYPE -> buildSearchMangaRequest(page - 1, query, filters)
+ sortByFilterType == LATEST_REQUEST_TYPE -> buildLatestMangaRequest(page - 1, filters)
+ else -> buildPopularMangaRequest(page - 1, filters)
+ }
+ }
+
+ override fun searchMangaNextPageSelector() = popularMangaNextPageSelector()
+
+ override fun searchMangaFromElement(element: Element) = popularMangaFromElement(element)
+
+ // Details
+
+ private fun parseUnlabelledAuthorNames(document: Document): List = listOf(
+ "field-name-field-author",
+ "field-name-field-authors-gr",
+ "field-name-field-img-group",
+ "field-name-field-hentai-img-group",
+ "field-name-field-rule-63-section"
+ ).map { document.select(".$it a").map { a -> a.text().trim() } }.flatten()
+
+ override fun mangaDetailsParse(document: Document): SManga {
+ return SManga.create().apply {
+
+ title = document.select("h1#page-title").text()
+
+ val infoMap = listOf(
+ "Section",
+ "Characters",
+ "Tags",
+ "Author"
+ ).map {
+ it to document.select(".field:has(.field-label:contains($it:)) .links a").map { t -> t.text().trim() }
+ }.toMap()
+
+ artist = (infoMap.getValue("Author") + parseUnlabelledAuthorNames(document))
+ .distinct().joinToString()
+ author = artist
+
+ genre = listOf("Tags", "Section", "Characters")
+ .map { infoMap.getValue(it) }.flatten().distinct().joinToString()
+
+ status = infoMap["Section"]?.firstOrNull { it == "Ongoings" }?.let { SManga.ONGOING } ?: SManga.COMPLETED
+
+ val pageCount = document.select(".jb-image img").size
+
+ description = infoMap
+ .filter { it.key in arrayOf("Section", "Characters") }
+ .filter { it.value.isNotEmpty() }
+ .map { "${it.key}:\n${it.value.joinToString()}" }
+ .let {
+ it + listOf(
+ "Pages:\n$pageCount"
+ )
+ }
+ .joinToString("\n\n")
+ }
+ }
+
+ // Chapters
+
+ override fun chapterListSelector(): String = throw UnsupportedOperationException("Not used")
+
+ override fun chapterFromElement(element: Element): SChapter = throw UnsupportedOperationException("Not used")
+
+ override fun chapterListParse(response: Response): List = throw UnsupportedOperationException("Not used")
+
+ override fun fetchChapterList(manga: SManga): Observable> = Observable.just(
+ listOf(
+ SChapter.create().apply {
+ url = manga.url
+ name = "Chapter"
+ chapter_number = 1f
+ }
+ )
+ )
+
+ // Pages
+
+ override fun pageListParse(document: Document): List {
+ return document.select(".jb-image img").mapIndexed { i, image ->
+ Page(i, imageUrl = image.attr("abs:src").replace("/styles/juicebox_2k/public", "").substringBefore("?"))
+ }
+ }
+
+ override fun imageUrlParse(document: Document) = throw UnsupportedOperationException("Not used")
+
+ // Filters
+
+ override fun getFilterList() = getFilterList(POPULAR_DEFAULT_SORT_BY_FILTER_STATE)
+
+ open class URIFilter(open val name: String, open val uri: String)
+
+ class RequestTypeURIFilter(
+ val requestType: String,
+ override var name: String,
+ override val uri: String
+ ) : URIFilter(name, uri)
+
+ open class URISelectFilter(name: String, open val filters: List, state: Int = 0) :
+ Filter.Select(name, filters.map { it.name }.toTypedArray(), state) {
+ open val selected: URIFilter
+ get() = filters[state]
+ }
+
+ open class TypeSelectFilter(name: String, filters: List) : URISelectFilter(name, filters)
+
+ private class PopularTypeSelectFilter(filters: List) : TypeSelectFilter("Popular Type", filters)
+
+ private class LatestTypeSelectFilter(filters: List) : TypeSelectFilter("Latest Type", filters)
+
+ private class SearchTypeSelectFilter(filters: List) : TypeSelectFilter("Search Type", filters)
+
+ open class TextSearchFilter(name: String, val uri: String) : Filter.Text(name) {
+ val stateURIs: List
+ get() {
+ return state.split(",").filter { it != "" }.map {
+ Regex("[^A-Za-z0-9]").replace(it, " ").trim()
+ .replace("\\s+".toRegex(), "_").toLowerCase(Locale.getDefault())
+ }.distinct()
+ }
+ }
+
+ private class SortBySelectFilter(override val filters: List, state: Int) :
+ URISelectFilter(
+ "Sort By",
+ filters.map { filter ->
+ filter.let { it.name = "[${it.requestType}] ${it.name}"; it }
+ },
+ state
+ ) {
+ val requestType: String
+ get() = filters[state].requestType
+ }
+
+ private class SortOrderSelectFilter(filters: List) : URISelectFilter("Order By", filters)
+
+ private fun getFilterList(sortByFilterState: Int) = FilterList(
+ Filter.Header("Text search only works with Relevance and Author"),
+ SortBySelectFilter(getSortByFilters(), sortByFilterState),
+ Filter.Header("Order By only works with Popular and Latest"),
+ SortOrderSelectFilter(getSortOrderFilters()),
+ Filter.Header("Type filters apply based on selected Sort By option"),
+ PopularTypeSelectFilter(getPopularTypeFilters()),
+ LatestTypeSelectFilter(getLatestTypeFilters()),
+ SearchTypeSelectFilter(getSearchTypeFilters()),
+ Filter.Separator(),
+ Filter.Header("Filters below ignore text search and all options above"),
+ Filter.Header("Query must match title's non-special characters"),
+ Filter.Header("Separate queries with comma (,)"),
+ TextSearchFilter("Comic Tags", "category"),
+ TextSearchFilter("Comic Characters", "characters"),
+ TextSearchFilter("Comic Authors", "authors_comics"),
+ TextSearchFilter("Comic Sections", "comics"),
+ TextSearchFilter("Manga Categories", "category_hentai"),
+ TextSearchFilter("Manga Characters", "characters_hentai"),
+ TextSearchFilter("Manga Authors", "authors_hentai_comics"),
+ TextSearchFilter("Manga Sections", "hentai_manga"),
+ TextSearchFilter("Picture Authors", "authors_albums"),
+ TextSearchFilter("Picture Sections", "pictures"),
+ TextSearchFilter("Hentai Sections", "hentai"),
+ TextSearchFilter("Rule 63 Sections", "rule_63"),
+ TextSearchFilter("Gay Tags", "category_gay")
+ )
+
+ private fun getPopularTypeFilters() = listOf(
+ URIFilter("Comics", "1"),
+ URIFilter("Hentai Manga", "2"),
+ URIFilter("Cartoon Pictures", "3"),
+ URIFilter("Hentai Pictures", "4"),
+ URIFilter("Rule 63", "10"),
+ URIFilter("Author Albums", "11")
+ )
+
+ private fun getLatestTypeFilters() = listOf(
+ URIFilter("Comics", "1"),
+ URIFilter("Hentai Manga", "2"),
+ URIFilter("Cartoon Pictures", "3"),
+ URIFilter("Hentai Pictures", "4"),
+ URIFilter("Author Albums", "10")
+ )
+
+ private fun getSearchTypeFilters() = listOf(
+ URIFilter("Comics", "1"),
+ URIFilter("Hentai Manga", "2"),
+ URIFilter("Gay Comics", "3"),
+ URIFilter("Cartoon Pictures", "4"),
+ URIFilter("Hentai Pictures", "5"),
+ URIFilter("Rule 63", "11"),
+ URIFilter("Humor", "13")
+ )
+
+ private fun getSortByFilters() = listOf(
+ RequestTypeURIFilter(POPULAR_REQUEST_TYPE, "Total Views", "totalcount_1"),
+ RequestTypeURIFilter(POPULAR_REQUEST_TYPE, "Views Today", "daycount"),
+ RequestTypeURIFilter(POPULAR_REQUEST_TYPE, "Last Viewed", "timestamp"),
+ RequestTypeURIFilter(LATEST_REQUEST_TYPE, "Date Posted", "created"),
+ RequestTypeURIFilter(LATEST_REQUEST_TYPE, "Date Updated", "changed"),
+ RequestTypeURIFilter(SEARCH_REQUEST_TYPE, "Relevance", "search_api_relevance"),
+ RequestTypeURIFilter(SEARCH_REQUEST_TYPE, "Author", "author")
+ )
+
+ private fun getSortOrderFilters() = listOf(
+ URIFilter("Descending", "DESC"),
+ URIFilter("Ascending", "ASC")
+ )
+
+ private inline fun Iterable<*>.findInstance() = find { it is T } as? T
+
+ companion object {
+
+ const val LATEST_DEFAULT_SORT_BY_FILTER_STATE = 3
+ const val POPULAR_DEFAULT_SORT_BY_FILTER_STATE = 0
+ const val SEARCH_DEFAULT_SORT_BY_FILTER_STATE = 5
+
+ const val LATEST_REQUEST_TYPE = "Latest"
+ const val POPULAR_REQUEST_TYPE = "Popular"
+ const val SEARCH_REQUEST_TYPE = "Search"
+
+ const val HEADER_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.122 Safari/537.36"
+ const val HEADER_CONTENT_TYPE = "application/x-www-form-urlencoded; charset=UTF-8"
+ }
+}