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) + } +}