diff --git a/src/en/ninehentai/AndroidManifest.xml b/src/en/ninehentai/AndroidManifest.xml
new file mode 100644
index 000000000..24abafc3d
--- /dev/null
+++ b/src/en/ninehentai/AndroidManifest.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/en/ninehentai/build.gradle b/src/en/ninehentai/build.gradle
new file mode 100644
index 000000000..276f8e689
--- /dev/null
+++ b/src/en/ninehentai/build.gradle
@@ -0,0 +1,8 @@
+ext {
+ extName = 'NineHentai'
+ extClass = '.NineHentai'
+ extVersionCode = 4
+ isNsfw = true
+}
+
+apply from: "$rootDir/common.gradle"
diff --git a/src/en/ninehentai/res/mipmap-hdpi/ic_launcher.png b/src/en/ninehentai/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 000000000..1fa8c130b
Binary files /dev/null and b/src/en/ninehentai/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/src/en/ninehentai/res/mipmap-mdpi/ic_launcher.png b/src/en/ninehentai/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 000000000..45a458885
Binary files /dev/null and b/src/en/ninehentai/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/src/en/ninehentai/res/mipmap-xhdpi/ic_launcher.png b/src/en/ninehentai/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 000000000..227b84683
Binary files /dev/null and b/src/en/ninehentai/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/src/en/ninehentai/res/mipmap-xxhdpi/ic_launcher.png b/src/en/ninehentai/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 000000000..2d854fe03
Binary files /dev/null and b/src/en/ninehentai/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/src/en/ninehentai/res/mipmap-xxxhdpi/ic_launcher.png b/src/en/ninehentai/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 000000000..93bd7ff8b
Binary files /dev/null and b/src/en/ninehentai/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/src/en/ninehentai/src/eu/kanade/tachiyomi/extension/en/ninehentai/NineHentai.kt b/src/en/ninehentai/src/eu/kanade/tachiyomi/extension/en/ninehentai/NineHentai.kt
new file mode 100644
index 000000000..c4b4c7d7c
--- /dev/null
+++ b/src/en/ninehentai/src/eu/kanade/tachiyomi/extension/en/ninehentai/NineHentai.kt
@@ -0,0 +1,379 @@
+package eu.kanade.tachiyomi.extension.en.ninehentai
+
+import eu.kanade.tachiyomi.network.GET
+import eu.kanade.tachiyomi.network.POST
+import eu.kanade.tachiyomi.network.asObservableSuccess
+import eu.kanade.tachiyomi.source.model.Filter
+import eu.kanade.tachiyomi.source.model.FilterList
+import eu.kanade.tachiyomi.source.model.MangasPage
+import eu.kanade.tachiyomi.source.model.Page
+import eu.kanade.tachiyomi.source.model.SChapter
+import eu.kanade.tachiyomi.source.model.SManga
+import eu.kanade.tachiyomi.source.online.HttpSource
+import eu.kanade.tachiyomi.util.asJsoup
+import kotlinx.serialization.decodeFromString
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.buildJsonObject
+import kotlinx.serialization.json.decodeFromJsonElement
+import kotlinx.serialization.json.jsonArray
+import kotlinx.serialization.json.jsonObject
+import kotlinx.serialization.json.put
+import okhttp3.MediaType.Companion.toMediaTypeOrNull
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.RequestBody.Companion.toRequestBody
+import okhttp3.Response
+import okio.Buffer
+import org.jsoup.nodes.Element
+import rx.Observable
+import rx.schedulers.Schedulers
+import uy.kohesive.injekt.injectLazy
+import java.util.Calendar
+
+class NineHentai : HttpSource() {
+
+ override val baseUrl = "https://9hentai.so"
+
+ override val name = "NineHentai"
+
+ override val lang = "en"
+
+ override val supportsLatest = true
+
+ override val client: OkHttpClient = network.cloudflareClient
+
+ private val json: Json by injectLazy()
+
+ // Builds request for /api/getBooks endpoint
+ private fun buildSearchRequest(
+ searchText: String = "",
+ page: Int,
+ sort: Int = 0,
+ range: List = listOf(0, 2000),
+ includedTags: List = listOf(),
+ excludedTags: List = listOf(),
+ ): Request {
+ val searchRequest = SearchRequest(
+ text = searchText,
+ page = page - 1, // Source starts counting from 0, not 1
+ sort = sort,
+ pages = Range(range),
+ tag = Items(
+ items = TagArrays(
+ included = includedTags,
+ excluded = excludedTags,
+ ),
+ ),
+ )
+ val jsonString = json.encodeToString(SearchRequestPayload(search = searchRequest))
+ return POST("$baseUrl$SEARCH_URL", headers, jsonString.toRequestBody(MEDIA_TYPE))
+ }
+
+ private fun parseSearchResponse(response: Response): MangasPage {
+ return response.use {
+ val page = json.decodeFromString(it.request.bodyString).search.page
+ json.decodeFromString(it.body.string()).let { searchResponse ->
+ MangasPage(
+ searchResponse.results.map {
+ SManga.create().apply {
+ url = "/g/${it.id}"
+ title = it.title
+ // Cover is the compressed first page (cover might change if page count changes)
+ thumbnail_url = "${it.image_server}${it.id}/1.jpg?${it.total_page}"
+ }
+ },
+ searchResponse.totalCount - 1 > page,
+ )
+ }
+ }
+ }
+
+ // Builds request for /api/getBookById endpoint
+ private fun buildDetailRequest(id: Int): Request {
+ val jsonString = buildJsonObject { put("id", id) }.toString()
+ return POST("$baseUrl$MANGA_URL", headers, jsonString.toRequestBody(MEDIA_TYPE))
+ }
+
+ // Popular
+
+ override fun popularMangaRequest(page: Int): Request = buildSearchRequest(page = page, sort = 1)
+
+ override fun popularMangaParse(response: Response): MangasPage = parseSearchResponse(response)
+
+ // Latest
+ override fun latestUpdatesRequest(page: Int): Request = buildSearchRequest(page = page)
+
+ override fun latestUpdatesParse(response: Response): MangasPage = parseSearchResponse(response)
+
+ // Search
+ override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable {
+ if (query.startsWith("id:")) {
+ val id = query.substringAfter("id:").toInt()
+ return client.newCall(buildDetailRequest(id))
+ .asObservableSuccess()
+ .map { response ->
+ fetchSingleManga(response)
+ }
+ }
+ return super.fetchSearchManga(page, query, filters)
+ }
+
+ override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
+ val filterList = if (filters.isEmpty()) getFilterList() else filters
+ var sort = 0
+ val range = mutableListOf(0, 2000)
+ val includedTags = mutableListOf()
+ val excludedTags = mutableListOf()
+ for (filter in filterList) {
+ when (filter) {
+ is SortFilter -> {
+ sort = filter.state
+ }
+ is MinPagesFilter -> {
+ try {
+ range[0] = filter.state.toInt()
+ } catch (_: NumberFormatException) {
+ // Suppress and retain default value
+ }
+ }
+ is MaxPagesFilter -> {
+ try {
+ range[1] = filter.state.toInt()
+ } catch (_: NumberFormatException) {
+ // Suppress and retain default value
+ }
+ }
+ is IncludedFilter -> {
+ includedTags += getTags(filter.state, 1)
+ }
+ is ExcludedFilter -> {
+ excludedTags += getTags(filter.state, 1)
+ }
+ is GroupFilter -> {
+ includedTags += getTags(filter.state, 2)
+ }
+ is ParodyFilter -> {
+ includedTags += getTags(filter.state, 3)
+ }
+ is ArtistFilter -> {
+ includedTags += getTags(filter.state, 4)
+ }
+ is CharacterFilter -> {
+ includedTags += getTags(filter.state, 5)
+ }
+ is CategoryFilter -> {
+ includedTags += getTags(filter.state, 6)
+ }
+ else -> { /* Do nothing */ }
+ }
+ }
+ return buildSearchRequest(
+ searchText = query,
+ page = page,
+ sort = sort,
+ range = range,
+ includedTags = includedTags,
+ excludedTags = excludedTags,
+ )
+ }
+
+ override fun searchMangaParse(response: Response): MangasPage = parseSearchResponse(response)
+
+ // Manga Details
+
+ override fun mangaDetailsParse(response: Response): SManga {
+ return SManga.create().apply {
+ response.asJsoup().selectFirst("div#bigcontainer")!!.let { info ->
+ title = info.select("h1").text()
+ thumbnail_url = info.selectFirst("div#cover v-lazy-image")!!.attr("abs:src")
+ status = SManga.COMPLETED
+ artist = info.selectTextOrNull("div.field-name:contains(Artist:) a.tag")
+ author = info.selectTextOrNull("div.field-name:contains(Group:) a.tag") ?: "Unknown circle"
+ genre = info.selectTextOrNull("div.field-name:contains(Tag:) a.tag")
+ // Additional details
+ description = listOf(
+ Pair("Alternative Title", info.selectTextOrNull("h2")),
+ Pair("Pages", info.selectTextOrNull("div#info > div:contains(pages)")),
+ Pair("Parody", info.selectTextOrNull("div.field-name:contains(Parody:) a.tag")),
+ Pair("Category", info.selectTextOrNull("div.field-name:contains(Category:) a.tag")),
+ Pair("Language", info.selectTextOrNull("div.field-name:contains(Language:) a.tag")),
+ ).filterNot { it.second.isNullOrEmpty() }.joinToString("\n\n") { "${it.first}: ${it.second}" }
+ }
+ }
+ }
+
+ // Ensures no exceptions are thrown when scraping additional details
+ private fun Element.selectTextOrNull(selector: String): String? {
+ val list = this.select(selector)
+ return if (list.isEmpty()) {
+ null
+ } else {
+ list.joinToString(", ") { it.text() }
+ }
+ }
+
+ // Chapter
+
+ override fun chapterListParse(response: Response): List {
+ val time = response.asJsoup().select("div#info div time").text()
+ return listOf(
+ SChapter.create().apply {
+ name = "Chapter"
+ date_upload = parseChapterDate(time)
+ url = response.request.url.encodedPath
+ },
+ )
+ }
+
+ private fun parseChapterDate(date: String): Long {
+ val dateStringSplit = date.split(" ")
+ val value = dateStringSplit[0].toInt()
+
+ return when (dateStringSplit[1].removeSuffix("s")) {
+ "sec" -> Calendar.getInstance().apply {
+ add(Calendar.SECOND, value * -1)
+ }.timeInMillis
+ "min" -> Calendar.getInstance().apply {
+ add(Calendar.MINUTE, value * -1)
+ }.timeInMillis
+ "hour" -> Calendar.getInstance().apply {
+ add(Calendar.HOUR_OF_DAY, value * -1)
+ }.timeInMillis
+ "day" -> Calendar.getInstance().apply {
+ add(Calendar.DATE, value * -1)
+ }.timeInMillis
+ "week" -> Calendar.getInstance().apply {
+ add(Calendar.DATE, value * 7 * -1)
+ }.timeInMillis
+ "month" -> Calendar.getInstance().apply {
+ add(Calendar.MONTH, value * -1)
+ }.timeInMillis
+ "year" -> Calendar.getInstance().apply {
+ add(Calendar.YEAR, value * -1)
+ }.timeInMillis
+ else -> {
+ return 0
+ }
+ }
+ }
+
+ // Page List
+
+ override fun pageListRequest(chapter: SChapter): Request {
+ val mangaId = chapter.url.substringAfter("/g/").toInt()
+ return buildDetailRequest(mangaId)
+ }
+
+ override fun pageListParse(response: Response): List {
+ val resultsObj = json.parseToJsonElement(response.body.string()).jsonObject["results"]!!
+ val manga = json.decodeFromJsonElement(resultsObj)
+ val imageUrl = manga.image_server + manga.id
+ var totalPages = manga.total_page
+
+ client.newCall(
+ GET(
+ "$imageUrl/preview/${totalPages}t.jpg",
+ headersBuilder().build(),
+ ),
+ ).execute().code.let { code ->
+ if (code == 404) totalPages--
+ }
+
+ return (1..totalPages).map {
+ Page(it - 1, "", "$imageUrl/$it.jpg")
+ }
+ }
+
+ private fun getTags(queries: String, type: Int): List {
+ return queries.split(",").map(String::trim)
+ .filterNot(String::isBlank).mapNotNull { query ->
+ val jsonString = buildJsonObject {
+ put("tag_name", query)
+ put("tag_type", type)
+ }.toString()
+ lookupTags(jsonString)
+ }
+ }
+
+ // Based on HentaiHand ext
+ private fun lookupTags(request: String): Tag? {
+ return client.newCall(POST("$baseUrl$TAG_URL", headers, request.toRequestBody(MEDIA_TYPE)))
+ .asObservableSuccess()
+ .subscribeOn(Schedulers.io())
+ .map { response ->
+ // Returns the first matched tag, or null if there are no results
+ val tagList = json.parseToJsonElement(response.body.string()).jsonObject["results"]!!.jsonArray.map {
+ json.decodeFromJsonElement(it)
+ }
+ if (tagList.isEmpty()) {
+ return@map null
+ } else {
+ tagList.first()
+ }
+ }.toBlocking().first()
+ }
+
+ private fun fetchSingleManga(response: Response): MangasPage {
+ val resultsObj = json.parseToJsonElement(response.body.string()).jsonObject["results"]!!
+ val manga = json.decodeFromJsonElement(resultsObj)
+ val list = listOf(
+ SManga.create().apply {
+ setUrlWithoutDomain("/g/${manga.id}")
+ title = manga.title
+ thumbnail_url = "${manga.image_server + manga.id}/cover.jpg"
+ },
+ )
+ return MangasPage(list, false)
+ }
+
+ // Filters
+
+ private class SortFilter : Filter.Select(
+ "Sort by",
+ arrayOf("Newest", "Popular Right now", "Most Fapped", "Most Viewed", "By Title"),
+ )
+
+ private class MinPagesFilter : Filter.Text("Minimum Pages")
+ private class MaxPagesFilter : Filter.Text("Maximum Pages")
+ private class IncludedFilter : Filter.Text("Included Tags")
+ private class ExcludedFilter : Filter.Text("Excluded Tags")
+ private class ArtistFilter : Filter.Text("Artist")
+ private class GroupFilter : Filter.Text("Group")
+ private class ParodyFilter : Filter.Text("Parody")
+ private class CharacterFilter : Filter.Text("Character")
+ private class CategoryFilter : Filter.Text("Category")
+
+ override fun getFilterList() = FilterList(
+ Filter.Header("Search by id with \"id:\" in front of query"),
+ Filter.Separator(),
+ SortFilter(),
+ MinPagesFilter(),
+ MaxPagesFilter(),
+ IncludedFilter(),
+ ExcludedFilter(),
+ ArtistFilter(),
+ GroupFilter(),
+ ParodyFilter(),
+ CharacterFilter(),
+ CategoryFilter(),
+ )
+
+ override fun imageUrlParse(response: Response): String = throw UnsupportedOperationException()
+
+ private val Request.bodyString: String
+ get() {
+ val requestCopy = newBuilder().build()
+ val buffer = Buffer()
+
+ return runCatching { buffer.apply { requestCopy.body!!.writeTo(this) }.readUtf8() }
+ .getOrNull() ?: ""
+ }
+
+ companion object {
+ private val MEDIA_TYPE = "application/json; charset=utf-8".toMediaTypeOrNull()
+ private const val SEARCH_URL = "/api/getBook"
+ private const val MANGA_URL = "/api/getBookByID"
+ private const val TAG_URL = "/api/getTag"
+ }
+}
diff --git a/src/en/ninehentai/src/eu/kanade/tachiyomi/extension/en/ninehentai/NineHentaiDto.kt b/src/en/ninehentai/src/eu/kanade/tachiyomi/extension/en/ninehentai/NineHentaiDto.kt
new file mode 100644
index 000000000..d2e63f05e
--- /dev/null
+++ b/src/en/ninehentai/src/eu/kanade/tachiyomi/extension/en/ninehentai/NineHentaiDto.kt
@@ -0,0 +1,84 @@
+package eu.kanade.tachiyomi.extension.en.ninehentai
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class Manga(
+ val id: Int,
+ val title: String,
+ val image_server: String,
+ val total_page: Int,
+)
+
+/*
+The basic search request JSON object looks like this:
+{
+ "search": {
+ "text": "",
+ "page": 1,
+ "sort": 1,
+ "pages": {
+ "range": [0, 2000]
+ },
+ "tag": {
+ "items": {
+ "included": [],
+ "excluded": []
+ }
+ }
+ }
+}
+*/
+
+/*
+ Sort = 0, Newest
+ Sort = 1, Popular right now
+ Sort = 2, Most Fapped
+ Sort = 3, Most Viewed
+ Sort = 4, By title
+ */
+
+@Serializable
+data class SearchRequest(
+ val text: String,
+ val page: Int,
+ val sort: Int,
+ val pages: Range,
+ val tag: Items,
+)
+
+@Serializable
+data class SearchRequestPayload(
+ val search: SearchRequest,
+)
+
+@Serializable
+data class SearchResponse(
+ @SerialName("total_count") val totalCount: Int,
+ val results: List,
+)
+
+@Serializable
+data class Range(
+ val range: List,
+)
+
+@Serializable
+data class Items(
+ val items: TagArrays,
+)
+
+@Serializable
+data class TagArrays(
+ val included: List,
+ val excluded: List,
+)
+
+@Serializable
+data class Tag(
+ val id: Int,
+ val name: String,
+ val description: String? = null,
+ val type: Int = 1,
+)
diff --git a/src/en/ninehentai/src/eu/kanade/tachiyomi/extension/en/ninehentai/NineHentaiUrlActivity.kt b/src/en/ninehentai/src/eu/kanade/tachiyomi/extension/en/ninehentai/NineHentaiUrlActivity.kt
new file mode 100644
index 000000000..f0f2cd3fb
--- /dev/null
+++ b/src/en/ninehentai/src/eu/kanade/tachiyomi/extension/en/ninehentai/NineHentaiUrlActivity.kt
@@ -0,0 +1,38 @@
+package eu.kanade.tachiyomi.extension.en.ninehentai
+
+import android.app.Activity
+import android.content.ActivityNotFoundException
+import android.content.Intent
+import android.os.Bundle
+import android.util.Log
+import kotlin.system.exitProcess
+
+/**
+ * Springboard that accepts https://9hentai.so/g/xxxxxx intents and redirects them to
+ * the main Tachiyomi process.
+ */
+class NineHentaiUrlActivity : Activity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ val pathSegments = intent?.data?.pathSegments
+ if (pathSegments != null && pathSegments.size > 1) {
+ val id = pathSegments[1]
+ val mainIntent = Intent().apply {
+ action = "eu.kanade.tachiyomi.SEARCH"
+ putExtra("query", "id:$id")
+ putExtra("filter", packageName)
+ }
+
+ try {
+ startActivity(mainIntent)
+ } catch (e: ActivityNotFoundException) {
+ Log.e("NineHentaiUrlActivity", e.toString())
+ }
+ } else {
+ Log.e("NineHentaiUrlActivity", "could not parse uri from intent $intent")
+ }
+
+ finish()
+ exitProcess(0)
+ }
+}