Cleanup 9Hentai (#13642)
* Cleanup 9Hentai * Recache cover when total page count changes
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
<application>
|
<application>
|
||||||
<activity
|
<activity
|
||||||
android:name=".all.ninehentai.NineHentaiUrlActivity"
|
android:name=".en.ninehentai.NineHentaiUrlActivity"
|
||||||
android:excludeFromRecents="true"
|
android:excludeFromRecents="true"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:theme="@android:style/Theme.NoDisplay">
|
android:theme="@android:style/Theme.NoDisplay">
|
|
@ -4,9 +4,9 @@ apply plugin: 'kotlinx-serialization'
|
||||||
|
|
||||||
ext {
|
ext {
|
||||||
extName = 'NineHentai'
|
extName = 'NineHentai'
|
||||||
pkgNameSuffix = 'all.ninehentai'
|
pkgNameSuffix = 'en.ninehentai'
|
||||||
extClass = '.NineHentai'
|
extClass = '.NineHentai'
|
||||||
extVersionCode = 14
|
extVersionCode = 1
|
||||||
isNsfw = true
|
isNsfw = true
|
||||||
}
|
}
|
||||||
|
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.7 KiB |
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.0 KiB |
Before Width: | Height: | Size: 5.1 KiB After Width: | Height: | Size: 5.1 KiB |
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 9.3 KiB |
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 64 KiB |
|
@ -1,5 +1,6 @@
|
||||||
package eu.kanade.tachiyomi.extension.all.ninehentai
|
package eu.kanade.tachiyomi.extension.en.ninehentai
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
import eu.kanade.tachiyomi.network.GET
|
import eu.kanade.tachiyomi.network.GET
|
||||||
import eu.kanade.tachiyomi.network.POST
|
import eu.kanade.tachiyomi.network.POST
|
||||||
import eu.kanade.tachiyomi.network.asObservableSuccess
|
import eu.kanade.tachiyomi.network.asObservableSuccess
|
||||||
|
@ -11,10 +12,11 @@ import eu.kanade.tachiyomi.source.model.SChapter
|
||||||
import eu.kanade.tachiyomi.source.model.SManga
|
import eu.kanade.tachiyomi.source.model.SManga
|
||||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||||
import eu.kanade.tachiyomi.util.asJsoup
|
import eu.kanade.tachiyomi.util.asJsoup
|
||||||
|
import kotlinx.serialization.decodeFromString
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.json.buildJsonObject
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
import kotlinx.serialization.json.decodeFromJsonElement
|
import kotlinx.serialization.json.decodeFromJsonElement
|
||||||
import kotlinx.serialization.json.encodeToJsonElement
|
|
||||||
import kotlinx.serialization.json.jsonArray
|
import kotlinx.serialization.json.jsonArray
|
||||||
import kotlinx.serialization.json.jsonObject
|
import kotlinx.serialization.json.jsonObject
|
||||||
import kotlinx.serialization.json.put
|
import kotlinx.serialization.json.put
|
||||||
|
@ -23,6 +25,7 @@ import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
|
import okio.Buffer
|
||||||
import org.jsoup.nodes.Element
|
import org.jsoup.nodes.Element
|
||||||
import rx.Observable
|
import rx.Observable
|
||||||
import rx.schedulers.Schedulers
|
import rx.schedulers.Schedulers
|
||||||
|
@ -52,7 +55,7 @@ class NineHentai : HttpSource() {
|
||||||
includedTags: List<Tag> = listOf(),
|
includedTags: List<Tag> = listOf(),
|
||||||
excludedTags: List<Tag> = listOf()
|
excludedTags: List<Tag> = listOf()
|
||||||
): Request {
|
): Request {
|
||||||
val request = SearchRequest(
|
val searchRequest = SearchRequest(
|
||||||
text = searchText,
|
text = searchText,
|
||||||
page = page - 1, // Source starts counting from 0, not 1
|
page = page - 1, // Source starts counting from 0, not 1
|
||||||
sort = sort,
|
sort = sort,
|
||||||
|
@ -64,41 +67,119 @@ class NineHentai : HttpSource() {
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
val jsonString = buildJsonObject {
|
val jsonString = json.encodeToString(SearchRequestPayload(search = searchRequest))
|
||||||
put("search", json.encodeToJsonElement(request))
|
|
||||||
}.toString()
|
|
||||||
return POST("$baseUrl$SEARCH_URL", headers, jsonString.toRequestBody(MEDIA_TYPE))
|
return POST("$baseUrl$SEARCH_URL", headers, jsonString.toRequestBody(MEDIA_TYPE))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun parseSearchResponse(response: Response): MangasPage {
|
||||||
|
return response.use {
|
||||||
|
val page = json.decodeFromString<SearchRequestPayload>(it.request.bodyString).search.page
|
||||||
|
json.decodeFromString<SearchResponse>(it.body?.string().orEmpty()).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
|
// Builds request for /api/getBookById endpoint
|
||||||
private fun buildDetailRequest(id: Int): Request {
|
private fun buildDetailRequest(id: Int): Request {
|
||||||
val jsonString = buildJsonObject { put("id", id) }.toString()
|
val jsonString = buildJsonObject { put("id", id) }.toString()
|
||||||
return POST("$baseUrl$MANGA_URL", headers, jsonString.toRequestBody(MEDIA_TYPE))
|
return POST("$baseUrl$MANGA_URL", headers, jsonString.toRequestBody(MEDIA_TYPE))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Popular and Latest
|
// Popular
|
||||||
|
|
||||||
override fun popularMangaRequest(page: Int): Request = buildSearchRequest(page = page, sort = 1)
|
override fun popularMangaRequest(page: Int): Request = buildSearchRequest(page = page, sort = 1)
|
||||||
|
|
||||||
override fun popularMangaParse(response: Response): MangasPage {
|
override fun popularMangaParse(response: Response): MangasPage = parseSearchResponse(response)
|
||||||
val results = json.parseToJsonElement(response.body!!.string()).jsonObject["results"]!!.jsonArray
|
|
||||||
if (results.isEmpty()) return MangasPage(listOf(), false)
|
// Latest
|
||||||
return MangasPage(
|
override fun latestUpdatesRequest(page: Int): Request = buildSearchRequest(page = page)
|
||||||
results.map {
|
|
||||||
val manga = json.decodeFromJsonElement<Manga>(it)
|
override fun latestUpdatesParse(response: Response): MangasPage = parseSearchResponse(response)
|
||||||
SManga.create().apply {
|
|
||||||
setUrlWithoutDomain("/g/${manga.id}")
|
// Search
|
||||||
title = manga.title
|
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
|
||||||
thumbnail_url = "${manga.image_server + manga.id}/cover.jpg"
|
if (query.startsWith("id:")) {
|
||||||
|
val id = query.substringAfter("id:").toInt()
|
||||||
|
return client.newCall(buildDetailRequest(id))
|
||||||
|
.asObservableSuccess()
|
||||||
|
.map { response ->
|
||||||
|
fetchSingleManga(response)
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
true
|
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<Tag>()
|
||||||
|
val excludedTags = mutableListOf<Tag>()
|
||||||
|
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 latestUpdatesRequest(page: Int): Request = buildSearchRequest(page = page)
|
override fun searchMangaParse(response: Response): MangasPage = parseSearchResponse(response)
|
||||||
|
|
||||||
override fun latestUpdatesParse(response: Response): MangasPage = popularMangaParse(response)
|
|
||||||
|
|
||||||
// Manga Details
|
// Manga Details
|
||||||
|
|
||||||
|
@ -109,7 +190,7 @@ class NineHentai : HttpSource() {
|
||||||
thumbnail_url = info.selectFirst("div#cover v-lazy-image").attr("abs:src")
|
thumbnail_url = info.selectFirst("div#cover v-lazy-image").attr("abs:src")
|
||||||
status = SManga.COMPLETED
|
status = SManga.COMPLETED
|
||||||
artist = info.selectTextOrNull("div.field-name:contains(Artist:) a.tag")
|
artist = info.selectTextOrNull("div.field-name:contains(Artist:) a.tag")
|
||||||
author = info.selectTextOrNull("div.field-name:contains(Group:) a.tag") ?: artist
|
author = info.selectTextOrNull("div.field-name:contains(Group:) a.tag") ?: "Unknown circle"
|
||||||
genre = info.selectTextOrNull("div.field-name:contains(Tag:) a.tag")
|
genre = info.selectTextOrNull("div.field-name:contains(Tag:) a.tag")
|
||||||
// Additional details
|
// Additional details
|
||||||
description = listOf(
|
description = listOf(
|
||||||
|
@ -141,16 +222,16 @@ class NineHentai : HttpSource() {
|
||||||
SChapter.create().apply {
|
SChapter.create().apply {
|
||||||
name = "Chapter"
|
name = "Chapter"
|
||||||
date_upload = parseChapterDate(time)
|
date_upload = parseChapterDate(time)
|
||||||
setUrlWithoutDomain(response.request.url.encodedPath)
|
url = response.request.url.encodedPath
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun parseChapterDate(date: String): Long {
|
private fun parseChapterDate(date: String): Long {
|
||||||
val value = date.split(' ')[0].toInt()
|
val dateStringSplit = date.split(" ")
|
||||||
val timeStr = date.split(' ')[1].removeSuffix("s")
|
val value = dateStringSplit[0].toInt()
|
||||||
|
|
||||||
return when (timeStr) {
|
return when (dateStringSplit[1].removeSuffix("s")) {
|
||||||
"sec" -> Calendar.getInstance().apply {
|
"sec" -> Calendar.getInstance().apply {
|
||||||
add(Calendar.SECOND, value * -1)
|
add(Calendar.SECOND, value * -1)
|
||||||
}.timeInMillis
|
}.timeInMillis
|
||||||
|
@ -191,8 +272,6 @@ class NineHentai : HttpSource() {
|
||||||
val imageUrl = manga.image_server + manga.id
|
val imageUrl = manga.image_server + manga.id
|
||||||
var totalPages = manga.total_page
|
var totalPages = manga.total_page
|
||||||
|
|
||||||
val pages = mutableListOf<Page>()
|
|
||||||
|
|
||||||
client.newCall(
|
client.newCall(
|
||||||
GET(
|
GET(
|
||||||
"$imageUrl/preview/${totalPages}t.jpg",
|
"$imageUrl/preview/${totalPages}t.jpg",
|
||||||
|
@ -202,71 +281,9 @@ class NineHentai : HttpSource() {
|
||||||
if (code == 404) totalPages--
|
if (code == 404) totalPages--
|
||||||
}
|
}
|
||||||
|
|
||||||
for (i in 1..totalPages) {
|
return (1..totalPages).map {
|
||||||
pages.add(Page(pages.size, "", "$imageUrl/$i.jpg"))
|
Page(it, "", "$imageUrl/$it.jpg")
|
||||||
}
|
}
|
||||||
|
|
||||||
return pages
|
|
||||||
}
|
|
||||||
|
|
||||||
// Search
|
|
||||||
|
|
||||||
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<Tag>()
|
|
||||||
val excludedTags = mutableListOf<Tag>()
|
|
||||||
for (filter in filterList) {
|
|
||||||
when (filter) {
|
|
||||||
is SortFilter -> {
|
|
||||||
sort = filter.state!!.index
|
|
||||||
}
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return buildSearchRequest(
|
|
||||||
searchText = query,
|
|
||||||
page = page,
|
|
||||||
sort = sort,
|
|
||||||
range = range,
|
|
||||||
includedTags = includedTags,
|
|
||||||
excludedTags = excludedTags
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getTags(queries: String, type: Int): List<Tag> {
|
private fun getTags(queries: String, type: Int): List<Tag> {
|
||||||
|
@ -295,18 +312,6 @@ class NineHentai : HttpSource() {
|
||||||
}.toBlocking().first()
|
}.toBlocking().first()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun fetchSingleManga(response: Response): MangasPage {
|
private fun fetchSingleManga(response: Response): MangasPage {
|
||||||
val resultsObj = json.parseToJsonElement(response.body!!.string()).jsonObject["results"]!!
|
val resultsObj = json.parseToJsonElement(response.body!!.string()).jsonObject["results"]!!
|
||||||
val manga = json.decodeFromJsonElement<Manga>(resultsObj)
|
val manga = json.decodeFromJsonElement<Manga>(resultsObj)
|
||||||
|
@ -320,14 +325,11 @@ class NineHentai : HttpSource() {
|
||||||
return MangasPage(list, false)
|
return MangasPage(list, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun searchMangaParse(response: Response): MangasPage = popularMangaParse(response)
|
|
||||||
|
|
||||||
// Filters
|
// Filters
|
||||||
|
|
||||||
private class SortFilter : Filter.Sort(
|
private class SortFilter : Filter.Select<String>(
|
||||||
"Sort by",
|
"Sort by",
|
||||||
arrayOf("Newest", "Popular Right now", "Most Fapped", "Most Viewed", "By Title"),
|
arrayOf("Newest", "Popular Right now", "Most Fapped", "Most Viewed", "By Title")
|
||||||
Selection(1, false)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
private class MinPagesFilter : Filter.Text("Minimum Pages")
|
private class MinPagesFilter : Filter.Text("Minimum Pages")
|
||||||
|
@ -357,6 +359,15 @@ class NineHentai : HttpSource() {
|
||||||
|
|
||||||
override fun imageUrlParse(response: Response): String = throw Exception("Not Used")
|
override fun imageUrlParse(response: Response): String = throw Exception("Not Used")
|
||||||
|
|
||||||
|
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 {
|
companion object {
|
||||||
private val MEDIA_TYPE = "application/json; charset=utf-8".toMediaTypeOrNull()
|
private val MEDIA_TYPE = "application/json; charset=utf-8".toMediaTypeOrNull()
|
||||||
private const val SEARCH_URL = "/api/getBook"
|
private const val SEARCH_URL = "/api/getBook"
|
|
@ -1,5 +1,6 @@
|
||||||
package eu.kanade.tachiyomi.extension.all.ninehentai
|
package eu.kanade.tachiyomi.extension.en.ninehentai
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
@ -47,6 +48,17 @@ data class SearchRequest(
|
||||||
val tag: Items
|
val tag: Items
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class SearchRequestPayload(
|
||||||
|
val search: SearchRequest
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class SearchResponse(
|
||||||
|
@SerialName("total_count") val totalCount: Int,
|
||||||
|
val results: List<Manga>
|
||||||
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class Range(
|
data class Range(
|
||||||
val range: List<Int>
|
val range: List<Int>
|
|
@ -1,4 +1,4 @@
|
||||||
package eu.kanade.tachiyomi.extension.all.ninehentai
|
package eu.kanade.tachiyomi.extension.en.ninehentai
|
||||||
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.content.ActivityNotFoundException
|
import android.content.ActivityNotFoundException
|