From 7929a768c4cb65536c852e81daf2e52432247700 Mon Sep 17 00:00:00 2001
From: Andy Bao <contactnulldev@gmail.com>
Date: Sat, 20 May 2017 06:11:33 -0400
Subject: [PATCH] Add MangaPark, Mangago, Tapastic, nhentai, E-Hentai and Sen
 Manga sources (#36)

* Add MangaPark source

* Add pagination to MangaPark source
Enable HTTPS in MangaPark source

* Add Mangago source

* Add Tapastic source
Fix UriSelectFilters returning incorrect default states

* Add nhentai source

* Fix tapastic source breaking on manga with square brackets in title

* Fix issues found by j2ghz
Fix tapastic source showing scheduled comics

* Add E-Hentai source
Bump Kotlin version for all extensions to 1.1.1
Minor code cleanup in nhentai source

* Add Sen Manga source.
Minor code cleanup.

* Fix incorrect package name in Sen Manga source.

* Fix incorrect Japanese language code on E-Hentai, nhentai and Sen Manga sources.

* Bump Kotlin version to 1.1.2

* Code cleanup
Fix a bug with thumbnails and URLs in E-Hentai that is currently not triggerable but may cause problems in the future

* Code cleanup

* Fix some minor incorrect spacing
---
 build.gradle                                  |   2 +-
 src/all/ehentai/build.gradle                  |  18 +
 .../extension/all/ehentai/EHLangs.kt          |  42 ++
 .../tachiyomi/extension/all/ehentai/EHUtil.kt |  67 ++++
 .../extension/all/ehentai/EHentai.kt          | 378 ++++++++++++++++++
 .../all/ehentai/ExGalleryMetadata.kt          |  31 ++
 .../extension/all/ehentai/MetadataCopier.kt   |  83 ++++
 .../tachiyomi/extension/all/ehentai/Tag.kt    |   7 +
 .../extension/all/ehentai/UriFilter.kt        |  10 +
 .../extension/all/ehentai/UriGroup.kt         |  15 +
 src/all/nhentai/build.gradle                  |  18 +
 .../extension/all/nhentai/MetadataCopier.kt   |  72 ++++
 .../extension/all/nhentai/NHLangs.kt          |  29 ++
 .../tachiyomi/extension/all/nhentai/NHUtil.kt |  17 +
 .../extension/all/nhentai/NHentai.kt          | 216 ++++++++++
 .../extension/all/nhentai/NHentaiMetadata.kt  |  44 ++
 .../tachiyomi/extension/all/nhentai/Tag.kt    |   7 +
 src/en/mangago/build.gradle                   |  13 +
 .../tachiyomi/extension/en/mangago/Mangago.kt | 245 ++++++++++++
 src/en/mangapark/build.gradle                 |  13 +
 .../extension/en/mangapark/MangaPark.kt       | 291 ++++++++++++++
 src/en/tapastic/build.gradle                  |  18 +
 .../extension/en/tapastic/Tapastic.kt         | 212 ++++++++++
 src/ja/senmanga/build.gradle                  |  13 +
 .../extension/ja/senmanga/SenManga.kt         | 327 +++++++++++++++
 25 files changed, 2187 insertions(+), 1 deletion(-)
 create mode 100644 src/all/ehentai/build.gradle
 create mode 100644 src/all/ehentai/src/eu/kanade/tachiyomi/extension/all/ehentai/EHLangs.kt
 create mode 100644 src/all/ehentai/src/eu/kanade/tachiyomi/extension/all/ehentai/EHUtil.kt
 create mode 100644 src/all/ehentai/src/eu/kanade/tachiyomi/extension/all/ehentai/EHentai.kt
 create mode 100644 src/all/ehentai/src/eu/kanade/tachiyomi/extension/all/ehentai/ExGalleryMetadata.kt
 create mode 100644 src/all/ehentai/src/eu/kanade/tachiyomi/extension/all/ehentai/MetadataCopier.kt
 create mode 100644 src/all/ehentai/src/eu/kanade/tachiyomi/extension/all/ehentai/Tag.kt
 create mode 100644 src/all/ehentai/src/eu/kanade/tachiyomi/extension/all/ehentai/UriFilter.kt
 create mode 100644 src/all/ehentai/src/eu/kanade/tachiyomi/extension/all/ehentai/UriGroup.kt
 create mode 100644 src/all/nhentai/build.gradle
 create mode 100644 src/all/nhentai/src/eu/kanade/tachiyomi/extension/all/nhentai/MetadataCopier.kt
 create mode 100644 src/all/nhentai/src/eu/kanade/tachiyomi/extension/all/nhentai/NHLangs.kt
 create mode 100644 src/all/nhentai/src/eu/kanade/tachiyomi/extension/all/nhentai/NHUtil.kt
 create mode 100644 src/all/nhentai/src/eu/kanade/tachiyomi/extension/all/nhentai/NHentai.kt
 create mode 100644 src/all/nhentai/src/eu/kanade/tachiyomi/extension/all/nhentai/NHentaiMetadata.kt
 create mode 100644 src/all/nhentai/src/eu/kanade/tachiyomi/extension/all/nhentai/Tag.kt
 create mode 100644 src/en/mangago/build.gradle
 create mode 100644 src/en/mangago/src/eu/kanade/tachiyomi/extension/en/mangago/Mangago.kt
 create mode 100644 src/en/mangapark/build.gradle
 create mode 100644 src/en/mangapark/src/eu/kanade/tachiyomi/extension/en/mangapark/MangaPark.kt
 create mode 100644 src/en/tapastic/build.gradle
 create mode 100644 src/en/tapastic/src/eu/kanade/tachiyomi/extension/en/tapastic/Tapastic.kt
 create mode 100644 src/ja/senmanga/build.gradle
 create mode 100644 src/ja/senmanga/src/eu/kanade/tachiyomi/extension/ja/senmanga/SenManga.kt

diff --git a/build.gradle b/build.gradle
index 685bd54e2..4d8c7944d 100644
--- a/build.gradle
+++ b/build.gradle
@@ -1,5 +1,5 @@
 buildscript {
-    ext.kotlin_version = '1.0.6'
+    ext.kotlin_version = '1.1.2'
     repositories {
         jcenter()
     }
diff --git a/src/all/ehentai/build.gradle b/src/all/ehentai/build.gradle
new file mode 100644
index 000000000..b41e0f84e
--- /dev/null
+++ b/src/all/ehentai/build.gradle
@@ -0,0 +1,18 @@
+apply plugin: 'com.android.application'
+apply plugin: 'kotlin-android'
+
+ext {
+    appName = 'Tachiyomi: E-Hentai'
+    pkgNameSuffix = "all.ehentai"
+    extClass = '.EHJapanese; .EHEnglish; .EHChinese; .EHDutch; .EHFrench; .EHGerman; .EHHungarian; .EHItalian; .EHKorean; .EHPolish; .EHPolish; .EHPortuguese; .EHRussian; .EHSpanish; .EHThai; .EHVietnamese; .EHSpeechless; .EHOther'
+    extVersionCode = 1
+    extVersionSuffix = 1
+    libVersion = '1.0'
+}
+
+dependencies {
+    provided "com.google.code.gson:gson:2.8.0"
+    provided "com.github.salomonbrys.kotson:kotson:2.5.0"
+}
+
+apply from: "$rootDir/common.gradle"
diff --git a/src/all/ehentai/src/eu/kanade/tachiyomi/extension/all/ehentai/EHLangs.kt b/src/all/ehentai/src/eu/kanade/tachiyomi/extension/all/ehentai/EHLangs.kt
new file mode 100644
index 000000000..ab662ff4c
--- /dev/null
+++ b/src/all/ehentai/src/eu/kanade/tachiyomi/extension/all/ehentai/EHLangs.kt
@@ -0,0 +1,42 @@
+package eu.kanade.tachiyomi.extension.all.ehentai
+
+/**
+ * E-Hentai languages
+ */
+class EHJapanese: EHentai("ja", "japanese")
+class EHEnglish: EHentai("en", "english")
+class EHChinese: EHentai("zh", "chinese")
+class EHDutch: EHentai("nl", "dutch")
+class EHFrench: EHentai("fr", "french")
+class EHGerman: EHentai("de", "german")
+class EHHungarian: EHentai("hu", "hungarian")
+class EHItalian: EHentai("it", "italian")
+class EHKorean: EHentai("ko", "korean")
+class EHPolish: EHentai("pl", "polish")
+class EHPortuguese: EHentai("pt", "portuguese")
+class EHRussian: EHentai("ru", "russian")
+class EHSpanish: EHentai("es", "spanish")
+class EHThai: EHentai("th", "thai")
+class EHVietnamese: EHentai("vi", "vietnamese")
+class EHSpeechless: EHentai("none", "n/a")
+class EHOther: EHentai("other", "other")
+
+fun getAllEHentaiLanguages() = listOf(
+        EHJapanese(),
+        EHEnglish(),
+        EHChinese(),
+        EHDutch(),
+        EHFrench(),
+        EHGerman(),
+        EHHungarian(),
+        EHItalian(),
+        EHKorean(),
+        EHPolish(),
+        EHPortuguese(),
+        EHRussian(),
+        EHSpanish(),
+        EHThai(),
+        EHVietnamese(),
+        EHSpeechless(),
+        EHOther()
+)
diff --git a/src/all/ehentai/src/eu/kanade/tachiyomi/extension/all/ehentai/EHUtil.kt b/src/all/ehentai/src/eu/kanade/tachiyomi/extension/all/ehentai/EHUtil.kt
new file mode 100644
index 000000000..794113b8f
--- /dev/null
+++ b/src/all/ehentai/src/eu/kanade/tachiyomi/extension/all/ehentai/EHUtil.kt
@@ -0,0 +1,67 @@
+package eu.kanade.tachiyomi.extension.all.ehentai
+
+/**
+ * Various utility methods used in the E-Hentai source
+ */
+
+/**
+ * Return null if String is blank, otherwise returns the original String
+ * @returns null if the String is blank, otherwise returns the original String
+ */
+fun String?.nullIfBlank(): String? = if (isNullOrBlank())
+    null
+else
+    this
+
+/**
+ * Ignores any exceptions thrown inside a block
+ */
+fun <T> ignore(expr: () -> T): T? {
+    return try {
+        expr()
+    } catch (t: Throwable) {
+        null
+    }
+}
+
+/**
+ * Use '+' to append Strings onto a StringBuilder
+ */
+operator fun StringBuilder.plusAssign(other: String) {
+    append(other)
+}
+
+/**
+ * Converts bytes into a human readable String
+ */
+fun humanReadableByteCount(bytes: Long, si: Boolean): String {
+    val unit = if (si) 1000 else 1024
+    if (bytes < unit) return bytes.toString() + " B"
+    val exp = (Math.log(bytes.toDouble()) / Math.log(unit.toDouble())).toInt()
+    val pre = (if (si) "kMGTPE" else "KMGTPE")[exp - 1] + if (si) "" else "i"
+    return String.format("%.1f %sB", bytes / Math.pow(unit.toDouble(), exp.toDouble()), pre)
+}
+
+private val KB_FACTOR = 1000
+private val KIB_FACTOR = 1024
+private val MB_FACTOR = 1000 * KB_FACTOR
+private val MIB_FACTOR = 1024 * KIB_FACTOR
+private val GB_FACTOR = 1000 * MB_FACTOR
+private val GIB_FACTOR = 1024 * MIB_FACTOR
+
+/**
+ * Parse human readable size Strings
+ */
+fun parseHumanReadableByteCount(arg0: String): Double? {
+    val spaceNdx = arg0.indexOf(" ")
+    val ret = arg0.substring(0 until spaceNdx).toDouble()
+    when (arg0.substring(spaceNdx + 1)) {
+        "GB" -> return ret * GB_FACTOR
+        "GiB" -> return ret * GIB_FACTOR
+        "MB" -> return ret * MB_FACTOR
+        "MiB" -> return ret * MIB_FACTOR
+        "KB" -> return ret * KB_FACTOR
+        "KiB" -> return ret * KIB_FACTOR
+    }
+    return null
+}
\ No newline at end of file
diff --git a/src/all/ehentai/src/eu/kanade/tachiyomi/extension/all/ehentai/EHentai.kt b/src/all/ehentai/src/eu/kanade/tachiyomi/extension/all/ehentai/EHentai.kt
new file mode 100644
index 000000000..c287a3ffb
--- /dev/null
+++ b/src/all/ehentai/src/eu/kanade/tachiyomi/extension/all/ehentai/EHentai.kt
@@ -0,0 +1,378 @@
+package eu.kanade.tachiyomi.extension.all.ehentai
+
+import android.net.Uri
+import eu.kanade.tachiyomi.network.GET
+import eu.kanade.tachiyomi.network.asObservableSuccess
+import eu.kanade.tachiyomi.source.model.*
+import eu.kanade.tachiyomi.source.online.HttpSource
+import eu.kanade.tachiyomi.util.asJsoup
+import okhttp3.CacheControl
+import okhttp3.Headers
+import okhttp3.Request
+import okhttp3.Response
+import org.jsoup.nodes.Document
+import org.jsoup.nodes.Element
+import rx.Observable
+import java.net.URLEncoder
+
+open class EHentai(override val lang: String, val ehLang: String) : HttpSource() {
+
+    override val name = "E-Hentai"
+
+    override val baseUrl = "https://e-hentai.org"
+
+    override val supportsLatest = true
+
+    /**
+     * Gallery list entry
+     * @param fav The favorite this gallery belongs to (currently unused)
+     * @param manga The manga object
+     */
+    data class ParsedManga(val fav: String?, val manga: SManga)
+
+    fun extendedGenericMangaParse(doc: Document)
+            = with(doc) {
+        //Parse mangas
+        val parsedMangas = select(".gtr0,.gtr1").map {
+            ParsedManga(
+                    fav = it.select(".itd .it3 > .i[id]").attr("title"),
+                    manga = SManga.create().apply {
+                        //Get title
+                        it.select(".itd .it5 a").apply {
+                            title = text()
+                            setUrlWithoutDomain(addParam(attr("href"), "nw", "always"))
+                        }
+                        //Get image
+                        it.select(".itd .it2").first().apply {
+                            children().first()?.let {
+                                thumbnail_url = it.attr("src")
+                            } ?: text().split("~").apply {
+                                thumbnail_url = "http://${this[1]}/${this[2]}"
+                            }
+                        }
+                    })
+
+        }
+        //Add to page if required
+        val hasNextPage = select("a[onclick=return false]").last()?.text() == ">"
+        Pair(parsedMangas, hasNextPage)
+    }
+
+    /**
+     * Parse a list of galleries
+     */
+    fun genericMangaParse(response: Response)
+            = extendedGenericMangaParse(response.asJsoup()).let {
+        MangasPage(it.first.map { it.manga }, it.second)
+    }
+
+    override fun fetchChapterList(manga: SManga): Observable<List<SChapter>>
+            = Observable.just(listOf(SChapter.create().apply {
+        url = manga.url
+        name = "Chapter"
+        chapter_number = 1f
+    }))
+
+    override fun fetchPageList(chapter: SChapter)
+            = fetchChapterPage(chapter, "$baseUrl/${chapter.url}").map {
+        it.mapIndexed { i, s ->
+            Page(i, s)
+        }
+    }!!
+
+    /**
+     * Recursively fetch chapter pages
+     */
+    private fun fetchChapterPage(chapter: SChapter, np: String,
+                                 pastUrls: List<String> = emptyList()): Observable<List<String>> {
+        val urls = pastUrls.toMutableList()
+        return chapterPageCall(np).flatMap {
+            val jsoup = it.asJsoup()
+            urls += parseChapterPage(jsoup)
+            nextPageUrl(jsoup)?.let {
+                fetchChapterPage(chapter, it, urls)
+            } ?: Observable.just(urls)
+        }
+    }
+
+    private fun parseChapterPage(response: Element)
+            = with(response) {
+        select(".gdtm a").map {
+            Pair(it.child(0).attr("alt").toInt(), it.attr("href"))
+        }.sortedBy(Pair<Int, String>::first).map { it.second }
+    }
+
+    private fun chapterPageCall(np: String) = client.newCall(chapterPageRequest(np)).asObservableSuccess()
+    private fun chapterPageRequest(np: String) = exGet(np, null, headers)
+
+    private fun nextPageUrl(element: Element)
+            = element.select("a[onclick=return false]").last()?.let {
+        if (it.text() == ">") it.attr("href") else null
+    }
+
+    override fun popularMangaRequest(page: Int) = latestUpdatesRequest(page)
+    //This source supports finding popular manga but will not respect language filters on the popular manga page!
+    //We currently display the latest updates instead until this is fixed
+    //override fun popularMangaRequest(page: Int) = exGet("$baseUrl/toplist.php?tl=15", page)
+
+    override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
+        val uri = Uri.parse("$baseUrl$QUERY_PREFIX").buildUpon()
+        uri.appendQueryParameter("f_search", query)
+        filters.forEach {
+            if (it is UriFilter) it.addToUri(uri)
+        }
+        return exGet(uri.toString(), page)
+    }
+
+    override fun latestUpdatesRequest(page: Int) = exGet(baseUrl, page)
+
+    override fun popularMangaParse(response: Response) = genericMangaParse(response)
+    override fun searchMangaParse(response: Response) = genericMangaParse(response)
+    override fun latestUpdatesParse(response: Response) = genericMangaParse(response)
+
+    fun exGet(url: String, page: Int? = null, additionalHeaders: Headers? = null, cache: Boolean = true)
+            = GET(page?.let {
+        addParam(url, "page", (it - 1).toString())
+    } ?: url, additionalHeaders?.let {
+        val headers = headers.newBuilder()
+        it.toMultimap().forEach { (t, u) ->
+            u.forEach {
+                headers.add(t, it)
+            }
+        }
+        headers.build()
+    } ?: headers).let {
+        if (!cache)
+            it.newBuilder().cacheControl(CacheControl.FORCE_NETWORK).build()
+        else
+            it
+    }!!
+
+    /**
+     * Parse gallery page to metadata model
+     */
+    override fun mangaDetailsParse(response: Response) = with(response.asJsoup()) {
+        with(ExGalleryMetadata()) {
+            url = response.request().url().encodedPath()
+            title = select("#gn").text().nullIfBlank()?.trim()
+
+            altTitle = select("#gj").text().nullIfBlank()?.trim()
+
+            //Thumbnail is set as background of element in style attribute
+            thumbnailUrl = select("#gd1 div").attr("style").nullIfBlank()?.let {
+                it.substring(it.indexOf('(') + 1 until it.lastIndexOf(')'))
+            }
+
+            genre = select(".ic").parents().attr("href").nullIfBlank()?.trim()?.substringAfterLast('/')
+
+            uploader = select("#gdn").text().nullIfBlank()?.trim()
+
+            //Parse the table
+            select("#gdd tr").forEach {
+                val left = it.select(".gdt1").text().nullIfBlank()?.trim() ?: return@forEach
+                val right = it.select(".gdt2").text().nullIfBlank()?.trim() ?: return@forEach
+                ignore {
+                    when (left.removeSuffix(":")
+                            .toLowerCase()) {
+                        "posted" -> datePosted = EX_DATE_FORMAT.parse(right).time
+                        "visible" -> visible = right.nullIfBlank()
+                        "language" -> {
+                            language = right.removeSuffix(TR_SUFFIX).trim().nullIfBlank()
+                            translated = right.endsWith(TR_SUFFIX, true)
+                        }
+                        "file size" -> size = parseHumanReadableByteCount(right)?.toLong()
+                        "length" -> length = right.removeSuffix("pages").trim().nullIfBlank()?.toInt()
+                        "favorited" -> favorites = right.removeSuffix("times").trim().nullIfBlank()?.toInt()
+                    }
+                }
+            }
+
+            //Parse ratings
+            ignore {
+                averageRating = getElementById("rating_label")
+                        .text()
+                        .removePrefix("Average:")
+                        .trim()
+                        .nullIfBlank()
+                        ?.toDouble()
+                ratingCount = getElementById("rating_count")
+                        .text()
+                        .trim()
+                        .nullIfBlank()
+                        ?.toInt()
+            }
+
+            //Parse tags
+            tags.clear()
+            select("#taglist tr").forEach {
+                val namespace = it.select(".tc").text().removeSuffix(":")
+                val currentTags = it.select("div").map {
+                    Tag(it.text().trim(),
+                            it.hasClass("gtl"))
+                }
+                tags.put(namespace, currentTags)
+            }
+
+            //Copy metadata to manga
+            SManga.create().apply {
+                copyTo(this)
+            }
+        }
+    }
+
+    override fun chapterListParse(response: Response)
+            = throw UnsupportedOperationException("Unused method was called somehow!")
+
+    override fun pageListParse(response: Response)
+            = throw UnsupportedOperationException("Unused method was called somehow!")
+
+    override fun fetchImageUrl(page: Page)
+            = client.newCall(imageUrlRequest(page))
+            .asObservableSuccess()
+            .map { realImageUrlParse(it, page) }!!
+
+    fun realImageUrlParse(response: Response, page: Page)
+            = with(response.asJsoup()) {
+        val currentImage = getElementById("img").attr("src")
+        //TODO We cannot currently do this as page.url is immutable
+        //Each press of the retry button will choose another server
+        /*select("#loadfail").attr("onclick").nullIfBlank()?.let {
+            page.url = addParam(page.url, "nl", it.substring(it.indexOf('\'') + 1 .. it.lastIndexOf('\'') - 1))
+        }*/
+        currentImage
+    }!!
+
+    override fun imageUrlParse(response: Response)
+            = throw UnsupportedOperationException("Unused method was called somehow!")
+
+    val cookiesHeader by lazy {
+        val cookies = mutableMapOf<String, String>()
+
+        //Setup settings
+        val settings = mutableListOf<String>()
+
+        //Do not show popular right now pane as we can't parse it
+        settings += "prn_n"
+
+        //Exclude every other language except the one we have selected
+        settings += "xl_" + languageMappings.filter { it.first != ehLang }
+                .flatMap { it.second }
+                .joinToString("x")
+
+        cookies.put("uconfig", buildSettings(settings))
+
+        buildCookies(cookies)
+    }
+
+    //Headers
+    override fun headersBuilder()
+            = super.headersBuilder().add("Cookie", cookiesHeader)!!
+
+    fun buildSettings(settings: List<String?>)
+            = settings.filterNotNull().joinToString(separator = "-")
+
+    fun buildCookies(cookies: Map<String, String>)
+            = cookies.entries.map {
+        "${URLEncoder.encode(it.key, "UTF-8")}=${URLEncoder.encode(it.value, "UTF-8")}"
+    }.joinToString(separator = "; ", postfix = ";")
+
+    fun addParam(url: String, param: String, value: String)
+            = Uri.parse(url)
+            .buildUpon()
+            .appendQueryParameter(param, value)
+            .toString()
+
+    override val client = network.client.newBuilder()
+            .addNetworkInterceptor { chain ->
+                val newReq = chain
+                        .request()
+                        .newBuilder()
+                        .addHeader("Cookie", cookiesHeader)
+                        .build()
+
+                chain.proceed(newReq)
+            }.build()!!
+
+    //Filters
+    override fun getFilterList() = FilterList(
+            GenreGroup(),
+            AdvancedGroup()
+    )
+
+    class GenreOption(name: String, val genreId: String) : Filter.CheckBox(name, false), UriFilter {
+        override fun addToUri(builder: Uri.Builder) {
+            builder.appendQueryParameter("f_" + genreId, if (state) "1" else "0")
+        }
+    }
+
+    class GenreGroup : UriGroup<GenreOption>("Genres", listOf(
+            GenreOption("Dōjinshi", "doujinshi"),
+            GenreOption("Manga", "manga"),
+            GenreOption("Artist CG", "artistcg"),
+            GenreOption("Game CG", "gamecg"),
+            GenreOption("Western", "western"),
+            GenreOption("Non-H", "non-h"),
+            GenreOption("Image Set", "imageset"),
+            GenreOption("Cosplay", "cosplay"),
+            GenreOption("Asian Porn", "asianporn"),
+            GenreOption("Misc", "misc")
+    ))
+
+    class AdvancedOption(name: String, val param: String, defValue: Boolean = false) : Filter.CheckBox(name, defValue), UriFilter {
+        override fun addToUri(builder: Uri.Builder) {
+            if (state)
+                builder.appendQueryParameter(param, "on")
+        }
+    }
+
+    class RatingOption : Filter.Select<String>("Minimum Rating", arrayOf(
+            "Any",
+            "2 stars",
+            "3 stars",
+            "4 stars",
+            "5 stars"
+    )), UriFilter {
+        override fun addToUri(builder: Uri.Builder) {
+            if (state > 0) builder.appendQueryParameter("f_srdd", Integer.toString(state + 1))
+        }
+    }
+
+    //Explicit type arg for listOf() to workaround this: KT-16570
+    class AdvancedGroup : UriGroup<Filter<*>>("Advanced Options", listOf<Filter<*>>(
+            AdvancedOption("Search Gallery Name", "f_sname", true),
+            AdvancedOption("Search Gallery Tags", "f_stags", true),
+            AdvancedOption("Search Gallery Description", "f_sdesc"),
+            AdvancedOption("Search Torrent Filenames", "f_storr"),
+            AdvancedOption("Only Show Galleries With Torrents", "f_sto"),
+            AdvancedOption("Search Low-Power Tags", "f_sdt1"),
+            AdvancedOption("Search Downvoted Tags", "f_sdt2"),
+            AdvancedOption("Show Expunged Galleries", "f_sh"),
+            RatingOption()
+    ))
+
+    //map languages to their internal ids
+    val languageMappings = listOf(
+            Pair("japanese", listOf("0", "1024", "2048")),
+            Pair("english", listOf("1", "1025", "2049")),
+            Pair("chinese", listOf("10", "1034", "2058")),
+            Pair("dutch", listOf("20", "1044", "2068")),
+            Pair("french", listOf("30", "1054", "2078")),
+            Pair("german", listOf("40", "1064", "2088")),
+            Pair("hungarian", listOf("50", "1074", "2098")),
+            Pair("italian", listOf("60", "1084", "2108")),
+            Pair("korean", listOf("70", "1094", "2118")),
+            Pair("polish", listOf("80", "1104", "2128")),
+            Pair("portuguese", listOf("90", "1114", "2138")),
+            Pair("russian", listOf("100", "1124", "2148")),
+            Pair("spanish", listOf("110", "1134", "2158")),
+            Pair("thai", listOf("120", "1144", "2168")),
+            Pair("vietnamese", listOf("130", "1154", "2178")),
+            Pair("n/a", listOf("254", "1278", "2302")),
+            Pair("other", listOf("255", "1279", "2303"))
+    )
+
+    companion object {
+        const val QUERY_PREFIX = "?f_apply=Apply+Filter"
+        const val TR_SUFFIX = "TR"
+    }
+}
\ No newline at end of file
diff --git a/src/all/ehentai/src/eu/kanade/tachiyomi/extension/all/ehentai/ExGalleryMetadata.kt b/src/all/ehentai/src/eu/kanade/tachiyomi/extension/all/ehentai/ExGalleryMetadata.kt
new file mode 100644
index 000000000..09f3c2451
--- /dev/null
+++ b/src/all/ehentai/src/eu/kanade/tachiyomi/extension/all/ehentai/ExGalleryMetadata.kt
@@ -0,0 +1,31 @@
+package eu.kanade.tachiyomi.extension.all.ehentai;
+
+/**
+ * Gallery metadata storage model
+ */
+
+class ExGalleryMetadata {
+    var url: String? = null
+
+    var thumbnailUrl: String? = null
+
+    var title: String? = null
+    var altTitle: String? = null
+
+    var genre: String? = null
+
+    var datePosted: Long? = null
+    var parent: String? = null
+    var visible: String? = null //Not a boolean
+    var language: String? = null
+    var translated: Boolean? = null
+    var size: Long? = null
+    var length: Int? = null
+    var favorites: Int? = null
+    var ratingCount: Int? = null
+    var averageRating: Double? = null
+
+    var uploader: String? = null
+
+    val tags: MutableMap<String, List<Tag>> = mutableMapOf()
+}
\ No newline at end of file
diff --git a/src/all/ehentai/src/eu/kanade/tachiyomi/extension/all/ehentai/MetadataCopier.kt b/src/all/ehentai/src/eu/kanade/tachiyomi/extension/all/ehentai/MetadataCopier.kt
new file mode 100644
index 000000000..298a71b99
--- /dev/null
+++ b/src/all/ehentai/src/eu/kanade/tachiyomi/extension/all/ehentai/MetadataCopier.kt
@@ -0,0 +1,83 @@
+package eu.kanade.tachiyomi.extension.all.ehentai
+
+import eu.kanade.tachiyomi.source.model.SManga
+import java.text.SimpleDateFormat
+import java.util.*
+
+private const val EH_ARTIST_NAMESPACE = "artist"
+private const val EH_AUTHOR_NAMESPACE = "author"
+
+private val ONGOING_SUFFIX = arrayOf(
+        "[ongoing]",
+        "(ongoing)",
+        "{ongoing}"
+)
+
+val EX_DATE_FORMAT = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.US)
+
+fun ExGalleryMetadata.copyTo(manga: SManga) {
+    url?.let { manga.url = it }
+    thumbnailUrl?.let { manga.thumbnail_url = it }
+
+    (title ?: altTitle)?.let { manga.title = it }
+
+    //Set artist (if we can find one)
+    tags[EH_ARTIST_NAMESPACE]?.let {
+        if (it.isNotEmpty()) manga.artist = it.joinToString(transform = Tag::name)
+    }
+    //Set author (if we can find one)
+    tags[EH_AUTHOR_NAMESPACE]?.let {
+        if (it.isNotEmpty()) manga.author = it.joinToString(transform = Tag::name)
+    }
+    //Set genre
+    genre?.let { manga.genre = it }
+
+    //Try to automatically identify if it is ongoing, we try not to be too lenient here to avoid making mistakes
+    //We default to completed
+    manga.status = SManga.COMPLETED
+    title?.let { t ->
+        if (ONGOING_SUFFIX.any {
+            t.endsWith(it, ignoreCase = true)
+        }) manga.status = SManga.ONGOING
+    }
+
+    //Build a nice looking description out of what we know
+    val titleDesc = StringBuilder()
+    title?.let { titleDesc += "Title: $it\n" }
+    altTitle?.let { titleDesc += "Alternate Title: $it\n" }
+
+    val detailsDesc = StringBuilder()
+    uploader?.let { detailsDesc += "Uploader: $it\n" }
+    datePosted?.let { detailsDesc += "Posted: ${EX_DATE_FORMAT.format(Date(it))}\n" }
+    visible?.let { detailsDesc += "Visible: $it\n" }
+    language?.let {
+        detailsDesc += "Language: $it"
+        if (translated == true) detailsDesc += " TR"
+        detailsDesc += "\n"
+    }
+    size?.let { detailsDesc += "File Size: ${humanReadableByteCount(it, true)}\n" }
+    length?.let { detailsDesc += "Length: $it pages\n" }
+    favorites?.let { detailsDesc += "Favorited: $it times\n" }
+    averageRating?.let {
+        detailsDesc += "Rating: $it"
+        ratingCount?.let { detailsDesc += " ($it)" }
+        detailsDesc += "\n"
+    }
+
+    val tagsDesc = buildTagsDescription(this)
+
+    manga.description = listOf(titleDesc.toString(), detailsDesc.toString(), tagsDesc.toString())
+            .filter(String::isNotBlank)
+            .joinToString(separator = "\n")
+}
+
+private fun buildTagsDescription(metadata: ExGalleryMetadata)
+        = StringBuilder("Tags:\n").apply {
+    //BiConsumer only available in Java 8, we have to use destructuring here
+    metadata.tags.forEach { (namespace, tags) ->
+        if (tags.isNotEmpty()) {
+            val joinedTags = tags.joinToString(separator = " ", transform = { "<${it.name}>" })
+            this += "▪ $namespace: $joinedTags\n"
+        }
+    }
+}
diff --git a/src/all/ehentai/src/eu/kanade/tachiyomi/extension/all/ehentai/Tag.kt b/src/all/ehentai/src/eu/kanade/tachiyomi/extension/all/ehentai/Tag.kt
new file mode 100644
index 000000000..7ee262b0f
--- /dev/null
+++ b/src/all/ehentai/src/eu/kanade/tachiyomi/extension/all/ehentai/Tag.kt
@@ -0,0 +1,7 @@
+package eu.kanade.tachiyomi.extension.all.ehentai;
+
+/**
+ * Simple tag model
+ */
+
+data class Tag(val name: String, val light: Boolean)
\ No newline at end of file
diff --git a/src/all/ehentai/src/eu/kanade/tachiyomi/extension/all/ehentai/UriFilter.kt b/src/all/ehentai/src/eu/kanade/tachiyomi/extension/all/ehentai/UriFilter.kt
new file mode 100644
index 000000000..e51416900
--- /dev/null
+++ b/src/all/ehentai/src/eu/kanade/tachiyomi/extension/all/ehentai/UriFilter.kt
@@ -0,0 +1,10 @@
+package eu.kanade.tachiyomi.extension.all.ehentai
+
+import android.net.Uri
+
+/**
+ * Uri filter
+ */
+interface UriFilter {
+    fun addToUri(builder: Uri.Builder)
+}
diff --git a/src/all/ehentai/src/eu/kanade/tachiyomi/extension/all/ehentai/UriGroup.kt b/src/all/ehentai/src/eu/kanade/tachiyomi/extension/all/ehentai/UriGroup.kt
new file mode 100644
index 000000000..431462f68
--- /dev/null
+++ b/src/all/ehentai/src/eu/kanade/tachiyomi/extension/all/ehentai/UriGroup.kt
@@ -0,0 +1,15 @@
+package eu.kanade.tachiyomi.extension.all.ehentai
+
+import android.net.Uri
+import eu.kanade.tachiyomi.source.model.Filter
+
+/**
+ * UriGroup
+ */
+open class UriGroup<V>(name: String, state: List<V>) : Filter.Group<V>(name, state), UriFilter {
+    override fun addToUri(builder: Uri.Builder) {
+        state.forEach {
+            if (it is UriFilter) it.addToUri(builder)
+        }
+    }
+}
diff --git a/src/all/nhentai/build.gradle b/src/all/nhentai/build.gradle
new file mode 100644
index 000000000..775a9d617
--- /dev/null
+++ b/src/all/nhentai/build.gradle
@@ -0,0 +1,18 @@
+apply plugin: 'com.android.application'
+apply plugin: 'kotlin-android'
+
+ext {
+    appName = 'Tachiyomi: nhentai'
+    pkgNameSuffix = "all.nhentai"
+    extClass = '.NHJapanese; .NHEnglish; .NHChinese; .NHSpeechless; .NHCzech; .NHEsperanto; .NHMongolian; .NHSlovak; .NHArabic; .NHUkrainian'
+    extVersionCode = 1
+    extVersionSuffix = 1
+    libVersion = '1.0'
+}
+
+dependencies {
+    provided "com.google.code.gson:gson:2.8.0"
+    provided "com.github.salomonbrys.kotson:kotson:2.5.0"
+}
+
+apply from: "$rootDir/common.gradle"
diff --git a/src/all/nhentai/src/eu/kanade/tachiyomi/extension/all/nhentai/MetadataCopier.kt b/src/all/nhentai/src/eu/kanade/tachiyomi/extension/all/nhentai/MetadataCopier.kt
new file mode 100644
index 000000000..de5c592ca
--- /dev/null
+++ b/src/all/nhentai/src/eu/kanade/tachiyomi/extension/all/nhentai/MetadataCopier.kt
@@ -0,0 +1,72 @@
+package eu.kanade.tachiyomi.extension.all.nhentai
+
+import eu.kanade.tachiyomi.source.model.SManga
+import java.text.SimpleDateFormat
+import java.util.*
+
+private val ONGOING_SUFFIX = arrayOf(
+        "[ongoing]",
+        "(ongoing)",
+        "{ongoing}"
+)
+
+private val EX_DATE_FORMAT = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.US)
+
+fun NHentaiMetadata.copyTo(manga: SManga) {
+    url?.let { manga.url = it }
+
+    mediaId?.let { mid ->
+        NHentaiMetadata.typeToExtension(thumbnailImageType)?.let {
+            manga.thumbnail_url = "https://t.nhentai.net/galleries/$mid/thumb.$it"
+        }
+    }
+
+    manga.title = englishTitle ?: japaneseTitle ?: shortTitle!!
+
+    //Set artist (if we can find one)
+    tags["artist"]?.let {
+        if (it.isNotEmpty()) manga.artist = it.joinToString(transform = Tag::name)
+    }
+
+    tags["category"]?.let {
+        if (it.isNotEmpty()) manga.genre = it.joinToString(transform = Tag::name)
+    }
+
+    //Try to automatically identify if it is ongoing, we try not to be too lenient here to avoid making mistakes
+    //We default to completed
+    manga.status = SManga.COMPLETED
+    englishTitle?.let { t ->
+        if (ONGOING_SUFFIX.any {
+            t.endsWith(it, ignoreCase = true)
+        }) manga.status = SManga.ONGOING
+    }
+
+    val titleDesc = StringBuilder()
+    englishTitle?.let { titleDesc += "English Title: $it\n" }
+    japaneseTitle?.let { titleDesc += "Japanese Title: $it\n" }
+    shortTitle?.let { titleDesc += "Short Title: $it\n" }
+
+    val detailsDesc = StringBuilder()
+    uploadDate?.let { detailsDesc += "Upload Date: ${EX_DATE_FORMAT.format(Date(it))}\n" }
+    pageImageTypes.size.let { detailsDesc += "Length: $it pages\n" }
+    favoritesCount?.let { detailsDesc += "Favorited: $it times\n" }
+    scanlator?.nullIfBlank()?.let { detailsDesc += "Scanlator: $it\n" }
+
+    val tagsDesc = buildTagsDescription(this)
+
+    manga.description = listOf(titleDesc.toString(), detailsDesc.toString(), tagsDesc.toString())
+            .filter(String::isNotBlank)
+            .joinToString(separator = "\n")
+}
+
+private fun buildTagsDescription(metadata: NHentaiMetadata)
+        = StringBuilder("Tags:\n").apply {
+    //BiConsumer only available in Java 8, we have to use destructuring here
+    metadata.tags.forEach { (namespace, tags) ->
+        if (tags.isNotEmpty()) {
+            val joinedTags = tags.joinToString(separator = " ", transform = { "<${it.name}>" })
+            this += "▪ $namespace: $joinedTags\n"
+        }
+    }
+}
+
diff --git a/src/all/nhentai/src/eu/kanade/tachiyomi/extension/all/nhentai/NHLangs.kt b/src/all/nhentai/src/eu/kanade/tachiyomi/extension/all/nhentai/NHLangs.kt
new file mode 100644
index 000000000..4e90a4ddc
--- /dev/null
+++ b/src/all/nhentai/src/eu/kanade/tachiyomi/extension/all/nhentai/NHLangs.kt
@@ -0,0 +1,29 @@
+package eu.kanade.tachiyomi.extension.all.nhentai
+
+/**
+ * NHentai languages
+ */
+
+class NHJapanese : NHentai("ja", "japanese")
+class NHEnglish : NHentai("en", "english")
+class NHChinese : NHentai("zh", "chinese")
+class NHSpeechless : NHentai("none", "speechless")
+class NHCzech : NHentai("cs", "czech")
+class NHEsperanto : NHentai("eo", "esperanto")
+class NHMongolian : NHentai("mn", "mongolian")
+class NHSlovak : NHentai("sk", "slovak")
+class NHArabic : NHentai("ar", "arabic")
+class NHUkrainian : NHentai("uk", "ukrainian")
+
+fun getAllNHentaiLanguages() = listOf(
+        NHJapanese(),
+        NHEnglish(),
+        NHChinese(),
+        NHSpeechless(),
+        NHCzech(),
+        NHEsperanto(),
+        NHMongolian(),
+        NHSlovak(),
+        NHArabic(),
+        NHUkrainian()
+)
diff --git a/src/all/nhentai/src/eu/kanade/tachiyomi/extension/all/nhentai/NHUtil.kt b/src/all/nhentai/src/eu/kanade/tachiyomi/extension/all/nhentai/NHUtil.kt
new file mode 100644
index 000000000..23e3a3546
--- /dev/null
+++ b/src/all/nhentai/src/eu/kanade/tachiyomi/extension/all/nhentai/NHUtil.kt
@@ -0,0 +1,17 @@
+package eu.kanade.tachiyomi.extension.all.nhentai
+
+/**
+ * Append Strings to StringBuilder with '+' operator
+ */
+operator fun StringBuilder.plusAssign(other: String) {
+    append(other)
+}
+
+/**
+ * Return null if String is blank, otherwise returns the original String
+ * @returns null if the String is blank, otherwise returns the original String
+ */
+fun String?.nullIfBlank(): String? = if (isNullOrBlank())
+    null
+else
+    this
diff --git a/src/all/nhentai/src/eu/kanade/tachiyomi/extension/all/nhentai/NHentai.kt b/src/all/nhentai/src/eu/kanade/tachiyomi/extension/all/nhentai/NHentai.kt
new file mode 100644
index 000000000..aa53cb152
--- /dev/null
+++ b/src/all/nhentai/src/eu/kanade/tachiyomi/extension/all/nhentai/NHentai.kt
@@ -0,0 +1,216 @@
+package eu.kanade.tachiyomi.extension.all.nhentai
+
+import android.net.Uri
+import com.github.salomonbrys.kotson.*
+import com.google.gson.JsonObject
+import com.google.gson.JsonParser
+import eu.kanade.tachiyomi.network.GET
+import eu.kanade.tachiyomi.network.asObservableSuccess
+import eu.kanade.tachiyomi.source.model.*
+import eu.kanade.tachiyomi.source.online.HttpSource
+import okhttp3.Request
+import okhttp3.Response
+import rx.Observable
+
+/**
+ * NHentai source
+ */
+
+open class NHentai(override val lang: String, val nhLang: String) : HttpSource() {
+    override val name = "nhentai"
+
+    override val baseUrl = "https://nhentai.net"
+
+    override val supportsLatest = true
+
+    //TODO There is currently no way to get the most popular mangas
+    //TODO Instead, we delegate this to the latest updates thing to avoid confusing users with an empty screen
+    override fun fetchPopularManga(page: Int) = fetchLatestUpdates(page)
+
+    override fun popularMangaRequest(page: Int)
+            = throw UnsupportedOperationException("This method should not be called!")
+
+    override fun popularMangaParse(response: Response)
+            = throw UnsupportedOperationException("This method should not be called!")
+
+    override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
+        val uri = Uri.parse("$baseUrl/api/galleries/search").buildUpon()
+        uri.appendQueryParameter("query", "language:$nhLang $query")
+        uri.appendQueryParameter("page", page.toString())
+        filters.forEach {
+            if (it is UriFilter)
+                it.addToUri(uri)
+        }
+        return nhGet(uri.toString(), page)
+    }
+
+    override fun searchMangaParse(response: Response) = parseResultPage(response)
+
+    override fun fetchLatestUpdates(page: Int) = fetchSearchManga(page, "", getFilterList())
+
+    override fun latestUpdatesRequest(page: Int)
+            = throw UnsupportedOperationException("This method should not be called!")
+
+    override fun latestUpdatesParse(response: Response)
+            = throw UnsupportedOperationException("This method should not be called!")
+
+    override fun mangaDetailsParse(response: Response)
+            = parseGallery(jsonParser.parse(response.body().string()).obj)
+
+    //Hack so we can use a different URL for fetching manga details and opening the details in the browser
+    override fun fetchMangaDetails(manga: SManga)
+            = client.newCall(urlToDetailsRequest(manga.url))
+            .asObservableSuccess()
+            .map { response ->
+                mangaDetailsParse(response).apply { initialized = true }
+            }
+
+    override fun mangaDetailsRequest(manga: SManga) = nhGet(manga.url)
+
+    fun urlToDetailsRequest(url: String) = nhGet("$baseUrl/api/gallery/${url.substringAfterLast('/')}")
+
+    fun parseResultPage(response: Response): MangasPage {
+        val res = jsonParser.parse(response.body().string()).obj
+
+        res["error"]?.let {
+            throw RuntimeException("An error occurred while performing the search: $it")
+        }
+
+        val results = res.getAsJsonArray("result")?.map {
+            parseGallery(it.obj)
+        }
+        val numPages = res["num_pages"].nullInt
+        if (results != null && numPages != null)
+            return MangasPage(results, numPages > response.request().tag() as Int)
+        return MangasPage(emptyList(), false)
+    }
+
+    fun rawParseGallery(obj: JsonObject) = NHentaiMetadata().apply {
+        uploadDate = obj["upload_date"].nullLong
+
+        favoritesCount = obj["num_favorites"].nullLong
+
+        mediaId = obj["media_id"].nullString
+
+        obj["title"].nullObj?.let {
+            japaneseTitle = it["japanese"].nullString
+            shortTitle = it["pretty"].nullString
+            englishTitle = it["english"].nullString
+        }
+
+        obj["images"].nullObj?.let {
+            coverImageType = it["cover"]?.get("t").nullString
+            it["pages"].nullArray?.map {
+                it.nullObj?.get("t").nullString
+            }?.filterNotNull()?.let {
+                pageImageTypes.clear()
+                pageImageTypes.addAll(it)
+            }
+            thumbnailImageType = it["thumbnail"]?.get("t").nullString
+        }
+
+        scanlator = obj["scanlator"].nullString
+
+        id = obj["id"]?.asLong
+
+        obj["tags"].nullArray?.map {
+            val asObj = it.obj
+            Pair(asObj["type"].nullString, asObj["name"].nullString)
+        }?.apply {
+            tags.clear()
+        }?.forEach {
+            if (it.first != null && it.second != null)
+                tags.getOrPut(it.first!!, { mutableListOf<Tag>() }).add(Tag(it.second!!, false))
+        }!!
+    }
+
+    fun parseGallery(obj: JsonObject) = SManga.create().apply {
+        rawParseGallery(obj).copyTo(this)
+    }
+
+    fun lazyLoadMetadata(url: String) =
+            client.newCall(urlToDetailsRequest(url))
+                    .asObservableSuccess()
+                    .map {
+                        rawParseGallery(jsonParser.parse(it.body().string()).obj)
+                    }!!
+
+    override fun fetchChapterList(manga: SManga)
+            = Observable.just(listOf(SChapter.create().apply {
+        url = manga.url
+        name = "Chapter"
+        chapter_number = 1f
+    }))!!
+
+    override fun fetchPageList(chapter: SChapter)
+            = lazyLoadMetadata(chapter.url).map { metadata ->
+        if (metadata.mediaId == null) emptyList()
+        else
+            metadata.pageImageTypes.mapIndexed { index, s ->
+                val imageUrl = imageUrlFromType(metadata.mediaId!!, index + 1, s)
+                Page(index, imageUrl!!, imageUrl)
+            }
+    }!!
+
+    override fun fetchImageUrl(page: Page) = Observable.just(page.imageUrl!!)!!
+
+    fun imageUrlFromType(mediaId: String, page: Int, t: String) = NHentaiMetadata.typeToExtension(t)?.let {
+        "https://i.nhentai.net/galleries/$mediaId/$page.$it"
+    }
+
+    override fun chapterListParse(response: Response)
+            = throw UnsupportedOperationException("This method should not be called!")
+
+    override fun pageListParse(response: Response)
+            = throw UnsupportedOperationException("This method should not be called!")
+
+    override fun imageUrlParse(response: Response)
+            = throw UnsupportedOperationException("This method should not be called!")
+
+    override fun getFilterList() = FilterList(SortFilter())
+
+    private class SortFilter : UriSelectFilter("Sort", "sort", arrayOf(
+            Pair("date", "Date"),
+            Pair("popular", "Popularity")
+    ), firstIsUnspecified = false)
+
+    private fun nhGet(url: String, tag: Any? = null) = GET(url)
+            .newBuilder()
+            //Requested by nhentai admins to use a custom user agent
+            .header("User-Agent",
+                    "Mozilla/5.0 (X11; Linux x86_64) " +
+                            "AppleWebKit/537.36 (KHTML, like Gecko) " +
+                            "Chrome/56.0.2924.87 " +
+                            "Safari/537.36 " +
+                            "Tachiyomi/1.0")
+            .tag(tag).build()!!
+
+    /**
+     * Class that creates a select filter. Each entry in the dropdown has a name and a display name.
+     * If an entry is selected it is appended as a query parameter onto the end of the URI.
+     * If `firstIsUnspecified` is set to true, if the first entry is selected, nothing will be appended on the the URI.
+     */
+    //vals: <name, display>
+    private open class UriSelectFilter(displayName: String, val uriParam: String, val vals: Array<Pair<String, String>>,
+                                       val firstIsUnspecified: Boolean = true,
+                                       defaultValue: Int = 0) :
+            Filter.Select<String>(displayName, vals.map { it.second }.toTypedArray(), defaultValue), UriFilter {
+        override fun addToUri(uri: Uri.Builder) {
+            if (state != 0 || !firstIsUnspecified)
+                uri.appendQueryParameter(uriParam, vals[state].first)
+        }
+    }
+
+    /**
+     * Represents a filter that is able to modify a URI.
+     */
+    private interface UriFilter {
+        fun addToUri(uri: Uri.Builder)
+    }
+
+    companion object {
+        val jsonParser by lazy {
+            JsonParser()
+        }
+    }
+}
diff --git a/src/all/nhentai/src/eu/kanade/tachiyomi/extension/all/nhentai/NHentaiMetadata.kt b/src/all/nhentai/src/eu/kanade/tachiyomi/extension/all/nhentai/NHentaiMetadata.kt
new file mode 100644
index 000000000..d29f67e2a
--- /dev/null
+++ b/src/all/nhentai/src/eu/kanade/tachiyomi/extension/all/nhentai/NHentaiMetadata.kt
@@ -0,0 +1,44 @@
+package eu.kanade.tachiyomi.extension.all.nhentai
+
+/**
+ * NHentai metadata
+ */
+
+class NHentaiMetadata {
+
+    var id: Long? = null
+
+    var url: String?
+        get() = id?.let { "/g/$it" }
+        set(a) {
+            id = a?.substringAfterLast('/')?.toLong()
+        }
+
+    var uploadDate: Long? = null
+
+    var favoritesCount: Long? = null
+
+    var mediaId: String? = null
+
+    var japaneseTitle: String? = null
+    var englishTitle: String? = null
+    var shortTitle: String? = null
+
+    var coverImageType: String? = null
+    var pageImageTypes: MutableList<String> = mutableListOf()
+    var thumbnailImageType: String? = null
+
+    var scanlator: String? = null
+
+    val tags: MutableMap<String, MutableList<Tag>> = mutableMapOf()
+
+    companion object {
+        fun typeToExtension(t: String?) =
+                when (t) {
+                    "p" -> "png"
+                    "j" -> "jpg"
+                    else -> null
+                }
+    }
+}
+
diff --git a/src/all/nhentai/src/eu/kanade/tachiyomi/extension/all/nhentai/Tag.kt b/src/all/nhentai/src/eu/kanade/tachiyomi/extension/all/nhentai/Tag.kt
new file mode 100644
index 000000000..005301bd9
--- /dev/null
+++ b/src/all/nhentai/src/eu/kanade/tachiyomi/extension/all/nhentai/Tag.kt
@@ -0,0 +1,7 @@
+package eu.kanade.tachiyomi.extension.all.nhentai
+
+/**
+ * Simple tag model
+ */
+
+data class Tag(val name: String, val light: Boolean)
diff --git a/src/en/mangago/build.gradle b/src/en/mangago/build.gradle
new file mode 100644
index 000000000..7b631d988
--- /dev/null
+++ b/src/en/mangago/build.gradle
@@ -0,0 +1,13 @@
+apply plugin: 'com.android.application'
+apply plugin: 'kotlin-android'
+
+ext {
+    appName = 'Tachiyomi: Mangago'
+    pkgNameSuffix = "en.mangago"
+    extClass = '.Mangago'
+    extVersionCode = 1
+    extVersionSuffix = 1
+    libVersion = '1.0'
+}
+
+apply from: "$rootDir/common.gradle"
diff --git a/src/en/mangago/src/eu/kanade/tachiyomi/extension/en/mangago/Mangago.kt b/src/en/mangago/src/eu/kanade/tachiyomi/extension/en/mangago/Mangago.kt
new file mode 100644
index 000000000..3a80e97bf
--- /dev/null
+++ b/src/en/mangago/src/eu/kanade/tachiyomi/extension/en/mangago/Mangago.kt
@@ -0,0 +1,245 @@
+package eu.kanade.tachiyomi.extension.en.mangago
+
+import android.net.Uri
+import eu.kanade.tachiyomi.network.GET
+import eu.kanade.tachiyomi.source.model.*
+import eu.kanade.tachiyomi.source.online.ParsedHttpSource
+import okhttp3.Request
+import org.jsoup.nodes.Document
+import org.jsoup.nodes.Element
+import java.text.SimpleDateFormat
+import java.util.*
+
+/**
+ * Mangago source
+ */
+
+class Mangago : ParsedHttpSource() {
+    override val lang = "en"
+    override val supportsLatest = true
+    override val name = "Mangago"
+    override val baseUrl = "https://www.mangago.me"
+
+    override val client = network.cloudflareClient!!
+
+    //Hybrid selector that selects manga from either the genre listing or the search results
+    private val genreListingSelector = ".updatesli"
+    private val genreListingNextPageSelector = ".current+li > a"
+
+    private val dateFormat = SimpleDateFormat("MMM d, yyyy", Locale.ENGLISH)
+
+    override fun popularMangaSelector() = genreListingSelector
+
+    private fun mangaFromElement(element: Element) = SManga.create().apply {
+        val linkElement = element.select(".thm-effect")
+
+        setUrlWithoutDomain(linkElement.attr("href"))
+
+        title = linkElement.attr("title")
+
+        thumbnail_url = linkElement.first().child(0).attr("src")
+    }
+
+    override fun popularMangaFromElement(element: Element) = mangaFromElement(element)
+
+    override fun popularMangaNextPageSelector() = genreListingNextPageSelector
+
+    //Hybrid selector that selects manga from either the genre listing or the search results
+    override fun searchMangaSelector() = "$genreListingSelector, .pic_list .box"
+
+    override fun searchMangaFromElement(element: Element) = mangaFromElement(element)
+
+    override fun searchMangaNextPageSelector() = genreListingNextPageSelector
+
+    override fun popularMangaRequest(page: Int) = GET("$baseUrl/genre/all/$page/?f=1&o=1&sortby=view&e=")
+
+    override fun latestUpdatesSelector() = genreListingSelector
+
+    override fun latestUpdatesFromElement(element: Element) = mangaFromElement(element)
+
+    override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
+        //If text search is active use text search, otherwise use genre search
+        val url = if (query.isNotBlank()) {
+            Uri.parse("$baseUrl/r/l_search/")
+                    .buildUpon()
+                    .appendQueryParameter("name", query)
+                    .appendQueryParameter("page", page.toString())
+                    .toString()
+        } else {
+            val uri = Uri.parse("$baseUrl/genre/").buildUpon()
+            val genres = filters.flatMap {
+                (it as? GenreGroup)?.stateList ?: emptyList()
+            }
+            //Append included genres
+            val activeGenres = genres.filter { it.isIncluded() }
+            uri.appendPath(if (activeGenres.isEmpty())
+                "all"
+            else
+                activeGenres.joinToString(",", transform = { it.name }))
+            //Append page number
+            uri.appendPath(page.toString())
+            //Append excluded genres
+            uri.appendQueryParameter("e",
+                    genres.filter { it.isExcluded() }
+                            .joinToString(",", transform = GenreFilter::name))
+            //Append uri filters
+            filters.forEach {
+                if (it is UriFilter)
+                    it.addToUri(uri)
+            }
+            uri.toString()
+        }
+        return GET(url)
+    }
+
+    override fun latestUpdatesNextPageSelector() = genreListingNextPageSelector
+
+    override fun mangaDetailsParse(document: Document) = SManga.create().apply {
+        val coverElement = document.select(".left.cover > img")
+
+        title = coverElement.attr("alt")
+
+        thumbnail_url = coverElement.attr("src")
+
+        document.select(".manga_right td").forEach {
+            when (it.getElementsByTag("label").text().trim().toLowerCase()) {
+                "status:" -> {
+                    status = when (it.getElementsByTag("span").first().text().trim().toLowerCase()) {
+                        "ongoing" -> SManga.ONGOING
+                        "completed" -> SManga.COMPLETED
+                        else -> SManga.UNKNOWN
+                    }
+                }
+                "author:" -> {
+                    author = it.getElementsByTag("a").first().text()
+                }
+                "genre(s):" -> {
+                    genre = it.getElementsByTag("a").joinToString(transform = { it.text() })
+                }
+            }
+        }
+
+        description = document.getElementsByClass("manga_summary").first().ownText().trim()
+    }
+
+    override fun latestUpdatesRequest(page: Int) = GET("$baseUrl/genre/all/$page/?f=1&o=1&sortby=update_date&e=")
+
+    override fun chapterListSelector() = "#chapter_table > tbody > tr"
+
+    override fun chapterFromElement(element: Element) = SChapter.create().apply {
+        val link = element.getElementsByTag("a")
+
+        setUrlWithoutDomain(link.attr("href"))
+
+        name = link.text().trim()
+
+        date_upload = dateFormat.parse(element.getElementsByClass("no").text().trim()).time
+    }
+
+    override fun pageListParse(document: Document)
+            = document.getElementById("pagenavigation").getElementsByTag("a").mapIndexed { index, element ->
+        Page(index, element.attr("href"))
+    }
+
+    override fun imageUrlParse(document: Document) = document.getElementById("page1").attr("src")!!
+
+    override fun getFilterList() = FilterList(
+            //Mangago does not support genre filtering and text search at the same time
+            Filter.Header("NOTE: Ignored if using text search!"),
+            Filter.Separator(),
+            Filter.Header("Status"),
+            StatusFilter("Completed", "f"),
+            StatusFilter("Ongoing", "o"),
+            GenreGroup(),
+            SortFilter()
+    )
+
+    private class GenreGroup : UriFilterGroup<GenreFilter>("Genres", listOf(
+            GenreFilter("Yaoi"),
+            GenreFilter("Doujinshi"),
+            GenreFilter("Shounen Ai"),
+            GenreFilter("Shoujo"),
+            GenreFilter("Yuri"),
+            GenreFilter("Romance"),
+            GenreFilter("Fantasy"),
+            GenreFilter("Smut"),
+            GenreFilter("Adult"),
+            GenreFilter("School Life"),
+            GenreFilter("Mystery"),
+            GenreFilter("Comedy"),
+            GenreFilter("Ecchi"),
+            GenreFilter("Shounen"),
+            GenreFilter("Martial Arts"),
+            GenreFilter("Shoujo Ai"),
+            GenreFilter("Supernatural"),
+            GenreFilter("Drama"),
+            GenreFilter("Action"),
+            GenreFilter("Adventure"),
+            GenreFilter("Harem"),
+            GenreFilter("Historical"),
+            GenreFilter("Horror"),
+            GenreFilter("Josei"),
+            GenreFilter("Mature"),
+            GenreFilter("Mecha"),
+            GenreFilter("Psychological"),
+            GenreFilter("Sci-fi"),
+            GenreFilter("Seinen"),
+            GenreFilter("Slice Of Life"),
+            GenreFilter("Sports"),
+            GenreFilter("Gender Bender"),
+            GenreFilter("Tragedy"),
+            GenreFilter("Bara"),
+            GenreFilter("Shotacon")
+    ))
+
+    private class GenreFilter(name: String) : Filter.TriState(name)
+
+    private class StatusFilter(name: String, val uriParam: String) : Filter.CheckBox(name, true), UriFilter {
+        override fun addToUri(uri: Uri.Builder) {
+            uri.appendQueryParameter(uriParam, if (state) "1" else "0")
+        }
+    }
+
+    private class SortFilter : UriSelectFilter("Sort", "sortby", arrayOf(
+            Pair("random", "Random"),
+            Pair("view", "Views"),
+            Pair("comment_count", "Comment Count"),
+            Pair("create_date", "Creation Date"),
+            Pair("update_date", "Update Date")
+    ))
+
+    /**
+     * Class that creates a select filter. Each entry in the dropdown has a name and a display name.
+     * If an entry is selected it is appended as a query parameter onto the end of the URI.
+     * If `firstIsUnspecified` is set to true, if the first entry is selected, nothing will be appended on the the URI.
+     */
+    //vals: <name, display>
+    private open class UriSelectFilter(displayName: String, val uriParam: String, val vals: Array<Pair<String, String>>,
+                                       val firstIsUnspecified: Boolean = true,
+                                       defaultValue: Int = 0) :
+            Filter.Select<String>(displayName, vals.map { it.second }.toTypedArray(), defaultValue), UriFilter {
+        override fun addToUri(uri: Uri.Builder) {
+            if (state != 0 || !firstIsUnspecified)
+                uri.appendQueryParameter(uriParam, vals[state].first)
+        }
+    }
+
+    /**
+     * Uri filter group
+     */
+    private open class UriFilterGroup<V>(name: String, val stateList: List<V>) : Filter.Group<V>(name, stateList), UriFilter {
+        override fun addToUri(uri: Uri.Builder) {
+            stateList.forEach {
+                if (it is UriFilter)
+                    it.addToUri(uri)
+            }
+        }
+    }
+
+    /**
+     * Represents a filter that is able to modify a URI.
+     */
+    private interface UriFilter {
+        fun addToUri(uri: Uri.Builder)
+    }
+}
diff --git a/src/en/mangapark/build.gradle b/src/en/mangapark/build.gradle
new file mode 100644
index 000000000..fb28ebf1c
--- /dev/null
+++ b/src/en/mangapark/build.gradle
@@ -0,0 +1,13 @@
+apply plugin: 'com.android.application'
+apply plugin: 'kotlin-android'
+
+ext {
+    appName = 'Tachiyomi: MangaPark'
+    pkgNameSuffix = "en.mangapark"
+    extClass = '.MangaPark'
+    extVersionCode = 1
+    extVersionSuffix = 1
+    libVersion = '1.0'
+}
+
+apply from: "$rootDir/common.gradle"
diff --git a/src/en/mangapark/src/eu/kanade/tachiyomi/extension/en/mangapark/MangaPark.kt b/src/en/mangapark/src/eu/kanade/tachiyomi/extension/en/mangapark/MangaPark.kt
new file mode 100644
index 000000000..34fc4eda8
--- /dev/null
+++ b/src/en/mangapark/src/eu/kanade/tachiyomi/extension/en/mangapark/MangaPark.kt
@@ -0,0 +1,291 @@
+package eu.kanade.tachiyomi.extension.en.mangapark
+
+import android.net.Uri
+import eu.kanade.tachiyomi.network.GET
+import eu.kanade.tachiyomi.source.model.*
+import eu.kanade.tachiyomi.source.online.ParsedHttpSource
+import okhttp3.Request
+import org.jsoup.nodes.Document
+import org.jsoup.nodes.Element
+import java.text.SimpleDateFormat
+import java.util.*
+
+/**
+ * MangaPark source
+ */
+
+class MangaPark : ParsedHttpSource() {
+    override val lang = "en"
+
+    override val supportsLatest = true
+    override val name = "MangaPark"
+    override val baseUrl = "https://mangapark.me"
+
+    private val directorySelector = ".item"
+    private val directoryUrl = "/genre"
+    private val directoryNextPageSelector = ".paging.full > li:last-child > a"
+
+    private val dateFormat = SimpleDateFormat("MMM d, yyyy, HH:mm a", Locale.ENGLISH)
+
+    override fun popularMangaSelector() = directorySelector
+
+    private fun mangaFromElement(element: Element) = SManga.create().apply {
+        val coverElement = element.getElementsByClass("cover").first()
+        url = coverElement.attr("href")
+
+        title = coverElement.attr("title")
+
+        thumbnail_url = coverElement.getElementsByTag("img").attr("src")
+    }
+
+    override fun popularMangaFromElement(element: Element) = mangaFromElement(element)
+
+    override fun popularMangaNextPageSelector() = directoryNextPageSelector
+
+    override fun searchMangaSelector() = ".item"
+
+    override fun searchMangaFromElement(element: Element) = mangaFromElement(element)
+
+    override fun searchMangaNextPageSelector() = ".paging > li:last-child > a"
+
+    override fun popularMangaRequest(page: Int) = GET("$baseUrl$directoryUrl/$page?views")
+
+    override fun latestUpdatesSelector() = directorySelector
+
+    override fun latestUpdatesFromElement(element: Element) = mangaFromElement(element)
+
+    override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
+        val uri = Uri.parse("$baseUrl/search").buildUpon()
+        uri.appendQueryParameter("q", query)
+        filters.forEach {
+            if (it is UriFilter)
+                it.addToUri(uri)
+        }
+        uri.appendQueryParameter("page", page.toString())
+        return GET(uri.toString())
+    }
+
+    override fun latestUpdatesNextPageSelector() = directoryNextPageSelector
+
+    override fun mangaDetailsParse(document: Document) = SManga.create().apply {
+        val coverElement = document.select(".cover > img").first()
+
+        title = coverElement.attr("title")
+
+        thumbnail_url = coverElement.attr("src")
+
+        document.select(".attr > tbody > tr").forEach {
+            val type = it.getElementsByTag("th").first().text().trim().toLowerCase()
+            when (type) {
+                "author(s)" -> {
+                    author = it.getElementsByTag("a").joinToString(transform = Element::text)
+                }
+                "artist(s)" -> {
+                    artist = it.getElementsByTag("a").joinToString(transform = Element::text)
+                }
+                "genre(s)" -> {
+                    genre = it.getElementsByTag("a").joinToString(transform = Element::text)
+                }
+                "status" -> {
+                    status = when (it.getElementsByTag("td").text().trim().toLowerCase()) {
+                        "ongoing" -> SManga.ONGOING
+                        "completed" -> SManga.COMPLETED
+                        else -> SManga.UNKNOWN
+                    }
+                }
+            }
+        }
+
+        description = document.getElementsByClass("summary").text().trim()
+    }
+
+    override fun latestUpdatesRequest(page: Int) = GET("$baseUrl$directoryUrl/$page?latest")
+
+    //TODO MangaPark has "versioning"
+    //TODO Currently we just use the version that is expanded by default
+    //TODO Maybe make it possible for users to view the other versions as well?
+    override fun chapterListSelector() = ".stream:not(.collapsed) .volume .chapter li"
+
+    override fun chapterFromElement(element: Element) = SChapter.create().apply {
+        url = element.select("em > a").last().attr("href")
+
+        name = element.getElementsByClass("ch").text()
+
+        date_upload = dateFormat.parse(element.getElementsByTag("i").text().trim()).time
+    }
+
+    override fun pageListParse(document: Document)
+            = document.getElementsByClass("img").map {
+        Page(it.attr("i").toInt() - 1, "", it.attr("src"))
+    }
+
+    //Unused, we can get image urls directly from the chapter page
+    override fun imageUrlParse(document: Document)
+            = throw UnsupportedOperationException("This method should not be called!")
+
+    override fun getFilterList() = FilterList(
+            AuthorArtistText(),
+            SearchTypeFilter("Title query", "name-match"),
+            SearchTypeFilter("Author/Artist query", "autart-match"),
+            SortFilter(),
+            GenreGroup(),
+            GenreInclusionFilter(),
+            ChapterCountFilter(),
+            StatusFilter(),
+            RatingFilter(),
+            TypeFilter(),
+            YearFilter()
+    )
+
+    private class SearchTypeFilter(name: String, val uriParam: String) :
+            Filter.Select<String>(name, STATE_MAP), UriFilter {
+        override fun addToUri(uri: Uri.Builder) {
+            uri.appendQueryParameter(uriParam, STATE_MAP[state])
+        }
+
+        companion object {
+            private val STATE_MAP = arrayOf("contain", "begin", "end")
+        }
+    }
+
+    private class AuthorArtistText : Filter.Text("Author/Artist"), UriFilter {
+        override fun addToUri(uri: Uri.Builder) {
+            uri.appendQueryParameter("autart", state)
+        }
+    }
+
+    private class GenreFilter(val uriParam: String, displayName: String) : Filter.TriState(displayName)
+
+    private class GenreGroup : Filter.Group<GenreFilter>("Genres", listOf(
+            GenreFilter("4-koma", "4 koma"),
+            GenreFilter("action", "Action"),
+            GenreFilter("adult", "Adult"),
+            GenreFilter("adventure", "Adventure"),
+            GenreFilter("award-winning", "Award winning"),
+            GenreFilter("comedy", "Comedy"),
+            GenreFilter("cooking", "Cooking"),
+            GenreFilter("demons", "Demons"),
+            GenreFilter("doujinshi", "Doujinshi"),
+            GenreFilter("drama", "Drama"),
+            GenreFilter("ecchi", "Ecchi"),
+            GenreFilter("fantasy", "Fantasy"),
+            GenreFilter("gender-bender", "Gender bender"),
+            GenreFilter("harem", "Harem"),
+            GenreFilter("historical", "Historical"),
+            GenreFilter("horror", "Horror"),
+            GenreFilter("josei", "Josei"),
+            GenreFilter("magic", "Magic"),
+            GenreFilter("martial-arts", "Martial arts"),
+            GenreFilter("mature", "Mature"),
+            GenreFilter("mecha", "Mecha"),
+            GenreFilter("medical", "Medical"),
+            GenreFilter("music", "Music"),
+            GenreFilter("mystery", "Mystery"),
+            GenreFilter("one-shot", "One shot"),
+            GenreFilter("psychological", "Psychological"),
+            GenreFilter("romance", "Romance"),
+            GenreFilter("school-life", "School life"),
+            GenreFilter("sci-fi", "Sci fi"),
+            GenreFilter("seinen", "Seinen"),
+            GenreFilter("shoujo", "Shoujo"),
+            GenreFilter("shoujo-ai", "Shoujo ai"),
+            GenreFilter("shounen", "Shounen"),
+            GenreFilter("shounen-ai", "Shounen ai"),
+            GenreFilter("slice-of-life", "Slice of life"),
+            GenreFilter("smut", "Smut"),
+            GenreFilter("sports", "Sports"),
+            GenreFilter("supernatural", "Supernatural"),
+            GenreFilter("tragedy", "Tragedy"),
+            GenreFilter("webtoon", "Webtoon"),
+            GenreFilter("yaoi", "Yaoi"),
+            GenreFilter("yuri", "Yuri")
+    )), UriFilter {
+        override fun addToUri(uri: Uri.Builder) {
+            uri.appendQueryParameter("genres", state.filter { it.isIncluded() }.map { it.uriParam }.joinToString(","))
+            uri.appendQueryParameter("genres-exclude", state.filter { it.isExcluded() }.map { it.uriParam }.joinToString(","))
+        }
+    }
+
+    private class GenreInclusionFilter : UriSelectFilter("Genre inclusion", "genres-mode", arrayOf(
+            Pair("and", "And mode"),
+            Pair("or", "Or mode")
+    ))
+
+    private class ChapterCountFilter : UriSelectFilter("Chapter count", "chapters", arrayOf(
+            Pair("any", "Any"),
+            Pair("1", "1 +"),
+            Pair("5", "5 +"),
+            Pair("10", "10 +"),
+            Pair("20", "20 +"),
+            Pair("30", "30 +"),
+            Pair("40", "40 +"),
+            Pair("50", "50 +"),
+            Pair("100", "100 +"),
+            Pair("150", "150 +"),
+            Pair("200", "200 +")
+    ))
+
+    private class StatusFilter : UriSelectFilter("Status", "status", arrayOf(
+            Pair("any", "Any"),
+            Pair("completed", "Completed"),
+            Pair("ongoing", "Ongoing")
+    ))
+
+    private class RatingFilter : UriSelectFilter("Rating", "rating", arrayOf(
+            Pair("any", "Any"),
+            Pair("5", "5 stars"),
+            Pair("4", "4 stars"),
+            Pair("3", "3 stars"),
+            Pair("2", "2 stars"),
+            Pair("1", "1 star"),
+            Pair("0", "0 stars")
+    ))
+
+    private class TypeFilter : UriSelectFilter("Type", "types", arrayOf(
+            Pair("any", "Any"),
+            Pair("manga", "Japanese Manga"),
+            Pair("manhwa", "Korean Manhwa"),
+            Pair("manhua", "Chinese Manhua"),
+            Pair("unknown", "Unknown")
+    ))
+
+    private class YearFilter : UriSelectFilter("Release year", "years",
+            arrayOf(Pair("any", "Any"),
+                    //Get all years between today and 1946
+                    *(Calendar.getInstance().get(Calendar.YEAR) downTo 1946).map {
+                        Pair(it.toString(), it.toString())
+                    }.toTypedArray()
+            )
+    )
+
+    private class SortFilter : UriSelectFilter("Sort", "orderby", arrayOf(
+            Pair("a-z", "A-Z"),
+            Pair("views", "Views"),
+            Pair("rating", "Rating"),
+            Pair("latest", "Latest"),
+            Pair("add", "New manga")
+    ), firstIsUnspecified = false, defaultValue = 1)
+
+    /**
+     * Class that creates a select filter. Each entry in the dropdown has a name and a display name.
+     * If an entry is selected it is appended as a query parameter onto the end of the URI.
+     * If `firstIsUnspecified` is set to true, if the first entry is selected, nothing will be appended on the the URI.
+     */
+    //vals: <name, display>
+    private open class UriSelectFilter(displayName: String, val uriParam: String, val vals: Array<Pair<String, String>>,
+                                       val firstIsUnspecified: Boolean = true,
+                                       defaultValue: Int = 0) :
+            Filter.Select<String>(displayName, vals.map { it.second }.toTypedArray(), defaultValue), UriFilter {
+        override fun addToUri(uri: Uri.Builder) {
+            if (state != 0 || !firstIsUnspecified)
+                uri.appendQueryParameter(uriParam, vals[state].first)
+        }
+    }
+
+    /**
+     * Represents a filter that is able to modify a URI.
+     */
+    private interface UriFilter {
+        fun addToUri(uri: Uri.Builder)
+    }
+}
diff --git a/src/en/tapastic/build.gradle b/src/en/tapastic/build.gradle
new file mode 100644
index 000000000..49c2472d4
--- /dev/null
+++ b/src/en/tapastic/build.gradle
@@ -0,0 +1,18 @@
+apply plugin: 'com.android.application'
+apply plugin: 'kotlin-android'
+
+ext {
+    appName = 'Tachiyomi: Tapastic'
+    pkgNameSuffix = "en.tapastic"
+    extClass = '.Tapastic'
+    extVersionCode = 1
+    extVersionSuffix = 1
+    libVersion = '1.0'
+}
+
+dependencies {
+    provided "com.google.code.gson:gson:2.8.0"
+    provided "com.github.salomonbrys.kotson:kotson:2.5.0"
+}
+
+apply from: "$rootDir/common.gradle"
diff --git a/src/en/tapastic/src/eu/kanade/tachiyomi/extension/en/tapastic/Tapastic.kt b/src/en/tapastic/src/eu/kanade/tachiyomi/extension/en/tapastic/Tapastic.kt
new file mode 100644
index 000000000..d023094c0
--- /dev/null
+++ b/src/en/tapastic/src/eu/kanade/tachiyomi/extension/en/tapastic/Tapastic.kt
@@ -0,0 +1,212 @@
+package eu.kanade.tachiyomi.extension.en.tapastic
+
+import android.net.Uri
+import com.github.salomonbrys.kotson.*
+import com.google.gson.JsonParser
+import eu.kanade.tachiyomi.network.GET
+import eu.kanade.tachiyomi.source.model.*
+import eu.kanade.tachiyomi.source.online.ParsedHttpSource
+import eu.kanade.tachiyomi.util.asJsoup
+import okhttp3.Request
+import okhttp3.Response
+import org.jsoup.nodes.Document
+import org.jsoup.nodes.Element
+
+class Tapastic : ParsedHttpSource() {
+    override val lang = "en"
+    override val supportsLatest = true
+    override val name = "Tapastic"
+    override val baseUrl = "https://tapas.io"
+
+    private val browseMangaSelector = ".content-item"
+    private val nextPageSelector = "a.paging-btn.next"
+
+    private val jsonParser by lazy { JsonParser() }
+
+    override fun popularMangaSelector() = browseMangaSelector
+
+    private fun mangaFromElement(element: Element) = SManga.create().apply {
+        val thumb = element.getElementsByClass("thumb-wrap")
+
+        url = thumb.attr("href")
+
+        title = element.getElementsByClass("title").text().trim()
+
+        thumbnail_url = thumb.select("img").attr("src")
+    }
+
+    override fun popularMangaFromElement(element: Element) = mangaFromElement(element)
+
+    override fun popularMangaNextPageSelector() = nextPageSelector
+
+    override fun searchMangaSelector() = "$browseMangaSelector, .search-item-wrap"
+
+    override fun searchMangaFromElement(element: Element) = mangaFromElement(element)
+
+    override fun searchMangaNextPageSelector() = nextPageSelector
+
+    override fun popularMangaRequest(page: Int) = GET("$baseUrl/comics?pageNumber=$page&browse=POPULAR")
+
+    override fun latestUpdatesSelector() = browseMangaSelector
+
+    override fun latestUpdatesFromElement(element: Element) = mangaFromElement(element)
+
+    override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
+        //If there is any search text, use text search, otherwise use filter search
+        val uri = if (query.isNotBlank()) {
+            Uri.parse("$baseUrl/search")
+                    .buildUpon()
+                    .appendQueryParameter("t", "COMICS")
+                    .appendQueryParameter("q", query)
+        } else {
+            val uri = Uri.parse("$baseUrl/comics").buildUpon()
+            //Append uri filters
+            filters.forEach {
+                if (it is UriFilter)
+                    it.addToUri(uri)
+            }
+            uri
+        }
+        //Append page number
+        uri.appendQueryParameter("pageNumber", page.toString())
+        return GET(uri.toString())
+    }
+
+    override fun latestUpdatesNextPageSelector() = nextPageSelector
+
+    override fun mangaDetailsParse(document: Document) = SManga.create().apply {
+        title = document.getElementsByClass("series-header-title").text().trim()
+
+        author = document.getElementsByClass("name").text().trim()
+        artist = author
+
+        description = document.getElementById("series-desc-body").text().trim()
+
+        genre = document.getElementsByClass("genre").text()
+
+        status = SManga.UNKNOWN
+    }
+
+    override fun latestUpdatesRequest(page: Int) = GET("$baseUrl/comics?pageNumber=$page&browse=FRESH")
+
+    override fun chapterListParse(response: Response)
+            //Chapters are stored in JavaScript as JSON!
+            = response.asJsoup().getElementsByTag("script").filter {
+        it.data().trim().startsWith("var _data")
+    }.flatMap {
+        val text = it.data()
+        val episodeVar = text.indexOf("episodeList")
+        if (episodeVar == -1)
+            return@flatMap emptyList<SChapter>()
+
+        val episodeLeftBracket = text.indexOf('[', startIndex = episodeVar)
+        if (episodeLeftBracket == -1)
+            return@flatMap emptyList<SChapter>()
+
+        val endOfLine = text.indexOf('\n', startIndex = episodeLeftBracket)
+        if (endOfLine == -1)
+            return@flatMap emptyList<SChapter>()
+
+        val episodeRightBracket = text.lastIndexOf(']', startIndex = endOfLine)
+        if (episodeRightBracket == -1)
+            return@flatMap emptyList<SChapter>()
+
+        val episodeListText = text.substring(episodeLeftBracket..episodeRightBracket)
+
+        jsonParser.parse(episodeListText).array.map {
+            val json = it.asJsonObject
+            //Ensure that the chapter is published (tapastic allows scheduling chapters)
+            if (json["orgScene"].int != 0)
+                SChapter.create().apply {
+                    url = "/episode/${json["id"].string}"
+
+                    name = json["title"].string
+
+                    date_upload = json["publishDate"].long
+
+                    chapter_number = json["scene"].float
+                }
+            else null
+        }.filterNotNull().sortedByDescending(SChapter::chapter_number)
+    }
+
+    override fun chapterListSelector()
+            = throw UnsupportedOperationException("This method should not be called!")
+
+    override fun chapterFromElement(element: Element)
+            = throw UnsupportedOperationException("This method should not be called!")
+
+    override fun pageListParse(document: Document)
+            = document.getElementsByClass("art-image").mapIndexed { index, element ->
+        Page(index, "", element.attr("src"))
+    }
+
+    //Unused, we can get image urls directly from the chapter page
+    override fun imageUrlParse(document: Document)
+            = throw UnsupportedOperationException("This method should not be called!")
+
+    override fun getFilterList() = FilterList(
+            //Tapastic does not support genre filtering and text search at the same time
+            Filter.Header("NOTE: Ignored if using text search!"),
+            Filter.Separator(),
+            FilterFilter(),
+            GenreFilter(),
+            Filter.Separator(),
+            Filter.Header("Sort is ignored when filter is active!"),
+            SortFilter()
+    )
+
+    private class FilterFilter : UriSelectFilter("Filter", "browse", arrayOf(
+            Pair("ALL", "None"),
+            Pair("POPULAR", "Popular"),
+            Pair("TRENDING", "Trending"),
+            Pair("FRESH", "Fresh"),
+            Pair("TAPASTIC", "Staff Picks")
+    ), firstIsUnspecified = false, defaultValue = 1)
+
+    private class GenreFilter : UriSelectFilter("Genre", "genreIds", arrayOf(
+            Pair("", "Any"),
+            Pair("7", "Action"),
+            Pair("2", "Comedy"),
+            Pair("8", "Drama"),
+            Pair("3", "Fantasy"),
+            Pair("9", "Gaming"),
+            Pair("6", "Horror"),
+            Pair("10", "Mystery"),
+            Pair("5", "Romance"),
+            Pair("4", "Science Fiction"),
+            Pair("1", "Slice of Life")
+    ))
+
+    private class SortFilter : UriSelectFilter("Sort", "sortType", arrayOf(
+            Pair("SUBSCRIBE", "Subscribers"),
+            Pair("LIKE", "Likes"),
+            Pair("VIEW", "Views"),
+            Pair("COMMENT", "Comments"),
+            Pair("CREATED", "Date"),
+            Pair("TITLE", "Name")
+    ))
+
+    /**
+     * Class that creates a select filter. Each entry in the dropdown has a name and a display name.
+     * If an entry is selected it is appended as a query parameter onto the end of the URI.
+     * If `firstIsUnspecified` is set to true, if the first entry is selected, nothing will be appended on the the URI.
+     */
+    //vals: <name, display>
+    private open class UriSelectFilter(displayName: String, val uriParam: String, val vals: Array<Pair<String, String>>,
+                                       val firstIsUnspecified: Boolean = true,
+                                       defaultValue: Int = 0) :
+            Filter.Select<String>(displayName, vals.map { it.second }.toTypedArray(), defaultValue), UriFilter {
+        override fun addToUri(uri: Uri.Builder) {
+            if (state != 0 || !firstIsUnspecified)
+                uri.appendQueryParameter(uriParam, vals[state].first)
+        }
+    }
+
+    /**
+     * Represents a filter that is able to modify a URI.
+     */
+    private interface UriFilter {
+        fun addToUri(uri: Uri.Builder)
+    }
+}
diff --git a/src/ja/senmanga/build.gradle b/src/ja/senmanga/build.gradle
new file mode 100644
index 000000000..9bf70d2d4
--- /dev/null
+++ b/src/ja/senmanga/build.gradle
@@ -0,0 +1,13 @@
+apply plugin: 'com.android.application'
+apply plugin: 'kotlin-android'
+
+ext {
+    appName = 'Tachiyomi: Sen Manga'
+    pkgNameSuffix = "ja.senmanga"
+    extClass = '.SenManga'
+    extVersionCode = 1
+    extVersionSuffix = 1
+    libVersion = '1.0'
+}
+
+apply from: "$rootDir/common.gradle"
diff --git a/src/ja/senmanga/src/eu/kanade/tachiyomi/extension/ja/senmanga/SenManga.kt b/src/ja/senmanga/src/eu/kanade/tachiyomi/extension/ja/senmanga/SenManga.kt
new file mode 100644
index 000000000..76c6ee4af
--- /dev/null
+++ b/src/ja/senmanga/src/eu/kanade/tachiyomi/extension/ja/senmanga/SenManga.kt
@@ -0,0 +1,327 @@
+package eu.kanade.tachiyomi.extension.ja.senmanga
+
+import android.net.Uri
+import eu.kanade.tachiyomi.network.GET
+import eu.kanade.tachiyomi.source.model.*
+import eu.kanade.tachiyomi.source.online.ParsedHttpSource
+import eu.kanade.tachiyomi.util.asJsoup
+import okhttp3.Response
+import org.jsoup.nodes.Document
+import org.jsoup.nodes.Element
+import java.util.*
+
+/**
+ * Sen Manga source
+ */
+
+class SenManga : ParsedHttpSource() {
+    override val lang: String = "ja"
+
+    //Latest updates currently returns duplicate manga as it separates manga into chapters
+    override val supportsLatest = false
+    override val name = "Sen Manga"
+    override val baseUrl = "http://raw.senmanga.com"
+
+    override val client = super.client.newBuilder().addInterceptor {
+        //Intercept any image requests and add a referer to them
+        //Enables bandwidth stealing feature
+        val request = if (it.request().url().pathSegments().firstOrNull()?.trim()?.toLowerCase() == "viewer") {
+            it.request().newBuilder()
+                    .addHeader("Referer", it.request().url().newBuilder()
+                            .removePathSegment(0)
+                            .toString())
+                    .build()
+        } else it.request()
+        it.proceed(request)
+    }.build()!!
+
+    //Sen Manga doesn't follow the specs and decides to use multiple elements with the same ID on the page...
+    override fun popularMangaSelector() = "#manga-list"
+
+    override fun popularMangaFromElement(element: Element) = SManga.create().apply {
+        val linkElement = element.select("h1 a")
+
+        url = linkElement.attr("href")
+
+        title = linkElement.text()
+
+        thumbnail_url = baseUrl + element.getElementsByClass("series-cover").attr("src")
+    }
+
+    override fun popularMangaNextPageSelector() = "#Navigation > span > ul > li:nth-last-child(2)"
+
+    override fun popularMangaParse(response: Response): MangasPage {
+        val document = response.asJsoup()
+
+        val mangas = document.select(popularMangaSelector()).map { element ->
+            popularMangaFromElement(element)
+        }
+
+        val hasNextPage = document.select(popularMangaNextPageSelector()).let {
+            it.isNotEmpty() && it.text().trim().toLowerCase() == "Next"
+        }
+
+        return MangasPage(mangas, hasNextPage)
+    }
+
+    override fun searchMangaSelector() = ".search-results"
+
+    override fun searchMangaFromElement(element: Element) = SManga.create().apply {
+        val coverImage = element.getElementsByTag("img")
+
+        url = coverImage.parents().attr("href")
+
+        title = coverImage.attr("alt")
+
+        thumbnail_url = baseUrl + coverImage.attr("src")
+    }
+
+    //Sen Manga search returns one page max!
+    override fun searchMangaNextPageSelector() = null
+
+    override fun popularMangaRequest(page: Int) = GET("$baseUrl/Manga/?order=popular&page=$page")
+
+    override fun latestUpdatesSelector()
+            = throw UnsupportedOperationException("This method should not be called!")
+
+    override fun latestUpdatesFromElement(element: Element)
+            = throw UnsupportedOperationException("This method should not be called!")
+
+    override fun searchMangaParse(response: Response)
+            = if (response.request().url().pathSegments().firstOrNull()?.toLowerCase() != "search.php") {
+        //Use popular manga parser if we are not actually doing text search
+        popularMangaParse(response)
+    } else {
+        val document = response.asJsoup()
+
+        val mangas = document.select(searchMangaSelector()).map { element ->
+            searchMangaFromElement(element)
+        }
+
+        MangasPage(mangas, false)
+    }
+
+    override fun searchMangaRequest(page: Int, query: String, filters: FilterList)
+            = GET(if (query.isNullOrBlank()) {
+        val genreFilter = filters.find { it is GenreFilter } as GenreFilter
+        val sortFilter = filters.find { it is SortFilter } as SortFilter
+        //If genre sort is not active or sort settings are changed
+        if (!sortFilter.isDefault() || genreFilter.genrePath() == ALL_GENRES_PATH) {
+            val uri = Uri.parse("$baseUrl/Manga/")
+                    .buildUpon()
+            sortFilter.addToUri(uri)
+            uri.toString()
+        } else "$baseUrl/directory/category/${genreFilter.genrePath()}/"
+    } else {
+        Uri.parse("$baseUrl/Search.php")
+                .buildUpon()
+                .appendQueryParameter("q", query)
+                .toString()
+    })
+
+    override fun latestUpdatesNextPageSelector()
+            = throw UnsupportedOperationException("This method should not be called!")
+
+    override fun mangaDetailsParse(document: Document) = SManga.create().apply {
+        title = document.select("h1[itemprop=name]").text()
+
+        thumbnail_url = baseUrl + document.select(".cover > img").attr("src")
+
+        val seriesDesc = document.getElementsByClass("series_desc")
+
+        //Get the next paragraph after paragraph with "Categorize in:"
+        genre = seriesDesc.first()
+                .children()
+                .find {
+                    it.tagName().toLowerCase() == "p"
+                            && it.text().trim().toLowerCase() == "categorize in:"
+                }?.nextElementSibling()
+                ?.text()
+                ?.trim()
+
+
+        author = seriesDesc.select("div > span")?.text()?.trim()
+
+        seriesDesc?.first()?.children()?.forEach {
+            val keyText = it.select("p > strong").text().trim().toLowerCase()
+            val valueText = it.select("p > .desc").text().trim()
+
+            when (keyText) {
+                "artist:" -> artist = valueText
+                "status:" -> status = when (valueText.toLowerCase()) {
+                    "ongoing" -> SManga.ONGOING
+                    "complete" -> SManga.COMPLETED
+                    else -> SManga.UNKNOWN
+                }
+            }
+        }
+
+        description = seriesDesc.select("div[itemprop=description]").text()
+    }
+
+    override fun latestUpdatesRequest(page: Int)
+            = throw UnsupportedOperationException("This method should not be called!")
+
+    //This may be unreliable as Sen Manga breaks the specs by having multiple elements with the same ID
+    override fun chapterListSelector() = "#post > table > tbody > tr:not(.headline)"
+
+    override fun chapterFromElement(element: Element) = SChapter.create().apply {
+        val linkElement = element.getElementsByTag("a")
+
+        url = linkElement.attr("href")
+
+        name = linkElement.text()
+
+        chapter_number = element.child(0).text().trim().toFloatOrNull() ?: -1f
+
+        date_upload = parseRelativeDate(element.children().last().text().trim().toLowerCase())
+    }
+
+    /**
+     * Parses dates in this form:
+     * `11 days ago`
+     */
+    private fun parseRelativeDate(date: String): Long {
+        val trimmedDate = date.split(" ")
+
+        if (trimmedDate[2] != "ago") return 0
+
+        val number = trimmedDate[0].toIntOrNull() ?: return 0
+        val unit = trimmedDate[1].removeSuffix("s") //Remove 's' suffix
+
+        val now = Calendar.getInstance()
+
+        //Map English unit to Java unit
+        val javaUnit = when (unit) {
+            "year" -> Calendar.YEAR
+            "month" -> Calendar.MONTH
+            "week" -> Calendar.WEEK_OF_MONTH
+            "day" -> Calendar.DAY_OF_MONTH
+            "hour" -> Calendar.HOUR
+            "minute" -> Calendar.MINUTE
+            "second" -> Calendar.SECOND
+            else -> return 0
+        }
+
+        now.add(javaUnit, -number)
+
+        return now.timeInMillis
+    }
+
+    override fun pageListParse(document: Document): List<Page> {
+        //Base URI (document URI but without page index)
+        val baseUri = Uri.parse(baseUrl).buildUpon().apply {
+            Uri.parse(document.baseUri()).pathSegments.let {
+                it.take(it.size - 1)
+            }.forEach {
+                appendPath(it)
+            }
+        }.build()
+
+        //Base Image URI (document URI but without page index and with "viewer" inserted as first path segment
+        val baseImageUri = Uri.parse(baseUrl).buildUpon().appendPath("viewer").apply {
+            baseUri.pathSegments.forEach {
+                appendPath(it)
+            }
+        }.build()
+
+        return document.select("select[name=page] > option").map {
+            val index = it.attr("value")
+
+            val uri = baseUri.buildUpon().appendPath(index).build()
+
+            val imageUriBuilder = baseImageUri.buildUpon().appendPath(index).build()
+
+            Page(index.toInt() - 1, uri.toString(), imageUriBuilder.toString())
+        }
+    }
+
+    //We are able to get the image URL directly from the page list
+    override fun imageUrlParse(document: Document)
+            = throw UnsupportedOperationException("This method should not be called!")
+
+    override fun getFilterList() = FilterList(
+            Filter.Header("NOTE: Ignored if using text search!"),
+            GenreFilter(),
+            Filter.Header("NOTE: Sort ignores genres search!"),
+            SortFilter()
+    )
+
+    private class GenreFilter : Filter.Select<String>("Genre", GENRES.map { it.second }.toTypedArray()) {
+        fun genrePath() = GENRES[state].first
+    }
+
+    private class SortFilter : UriSelectFilter("Sort", "order", arrayOf(
+            Pair("popular", "Popularity"),
+            Pair("title", "Title"),
+            Pair("rating", "Rating")
+    ), false) {
+        fun isDefault() = state == 0
+    }
+
+    /**
+     * Class that creates a select filter. Each entry in the dropdown has a name and a display name.
+     * If an entry is selected it is appended as a query parameter onto the end of the URI.
+     * If `firstIsUnspecified` is set to true, if the first entry is selected, nothing will be appended on the the URI.
+     */
+    //vals: <name, display>
+    private open class UriSelectFilter(displayName: String, val uriParam: String, val vals: Array<Pair<String, String>>,
+                                       val firstIsUnspecified: Boolean = true,
+                                       defaultValue: Int = 0) :
+            Filter.Select<String>(displayName, vals.map { it.second }.toTypedArray(), defaultValue), UriFilter {
+        override fun addToUri(uri: Uri.Builder) {
+            if (state != 0 || !firstIsUnspecified)
+                uri.appendQueryParameter(uriParam, vals[state].first)
+        }
+    }
+
+    /**
+     * Represents a filter that is able to modify a URI.
+     */
+    private interface UriFilter {
+        fun addToUri(uri: Uri.Builder)
+    }
+
+    companion object {
+        private val ALL_GENRES_PATH = "all"
+        //<path, display name>
+        private val GENRES = listOf(
+                Pair(ALL_GENRES_PATH, "All"),
+                Pair("Action", "Action"),
+                Pair("Adult", "Adult"),
+                Pair("Adventure", "Adventure"),
+                Pair("Comedy", "Comedy"),
+                Pair("Cooking", "Cooking"),
+                Pair("Drama", "Drama"),
+                Pair("Ecchi", "Ecchi"),
+                Pair("Fantasy", "Fantasy"),
+                Pair("Gender-Bender", "Gender Bender"),
+                Pair("Harem", "Harem"),
+                Pair("Historical", "Historical"),
+                Pair("Horror", "Horror"),
+                Pair("Josei", "Josei"),
+                Pair("Light_Novel", "Light Novel"),
+                Pair("Martial_Arts", "Martial Arts"),
+                Pair("Mature", "Mature"),
+                Pair("Music", "Music"),
+                Pair("Mystery", "Mystery"),
+                Pair("Psychological", "Psychological"),
+                Pair("Romance", "Romance"),
+                Pair("School_Life", "School Life"),
+                Pair("Sci-Fi", "Sci-Fi"),
+                Pair("Seinen", "Seinen"),
+                Pair("Shoujo", "Shoujo"),
+                Pair("Shoujo-Ai", "Shoujo Ai"),
+                Pair("Shounen", "Shounen"),
+                Pair("Shounen-Ai", "Shounen Ai"),
+                Pair("Slice_of_Life", "Slice of Life"),
+                Pair("Smut", "Smut"),
+                Pair("Sports", "Sports"),
+                Pair("Supernatural", "Supernatural"),
+                Pair("Tragedy", "Tragedy"),
+                Pair("Webtoons", "Webtoons"),
+                Pair("Yaoi", "Yaoi"),
+                Pair("Yuri", "Yuri")
+        )
+    }
+}