999Hentai (#17425)
* 999Hentai Coomer brain * 999Hentai: small changes * 999Hentai: refactor * 999Hentai: put dates in preference * 999Hentai: page number filter * 999Hentai: image quality setting * 999Hentai: fix null medium images... * 999Hentai: remove useless helper file * 999Hentai: small changes * 999Hentai: fix deep link * format filter * exclude tags filter * move around filters * remove non-functional filter option
This commit is contained in:
parent
bd1cf25bb9
commit
fc9a363934
|
@ -0,0 +1,22 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest package="eu.kanade.tachiyomi.extension"
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<application android:icon="@mipmap/ic_launcher">
|
||||||
|
<activity
|
||||||
|
android:name=".all.ninenineninehentai.NineNineNineHentaiUrlActivity"
|
||||||
|
android:excludeFromRecents="true"
|
||||||
|
android:exported="true"
|
||||||
|
android:theme="@android:style/Theme.NoDisplay">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
|
||||||
|
<data android:host="999hentai.to"/>
|
||||||
|
<data android:scheme="https"/>
|
||||||
|
<data android:pathPattern="/hchapter/..*"/>
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
|
@ -0,0 +1,13 @@
|
||||||
|
apply plugin: 'com.android.application'
|
||||||
|
apply plugin: 'kotlin-android'
|
||||||
|
apply plugin: 'kotlinx-serialization'
|
||||||
|
|
||||||
|
ext {
|
||||||
|
extName = '999Hentai'
|
||||||
|
pkgNameSuffix = 'all.ninenineninehentai'
|
||||||
|
extClass = '.NineNineNineHentaiFactory'
|
||||||
|
extVersionCode = 1
|
||||||
|
isNsfw = true
|
||||||
|
}
|
||||||
|
|
||||||
|
apply from: "$rootDir/common.gradle"
|
Binary file not shown.
After Width: | Height: | Size: 4.5 KiB |
Binary file not shown.
After Width: | Height: | Size: 2.4 KiB |
Binary file not shown.
After Width: | Height: | Size: 6.4 KiB |
Binary file not shown.
After Width: | Height: | Size: 12 KiB |
Binary file not shown.
After Width: | Height: | Size: 18 KiB |
Binary file not shown.
After Width: | Height: | Size: 88 KiB |
|
@ -0,0 +1,69 @@
|
||||||
|
package eu.kanade.tachiyomi.extension.all.ninenineninehentai
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.source.model.Filter
|
||||||
|
import eu.kanade.tachiyomi.source.model.FilterList
|
||||||
|
|
||||||
|
abstract class SelectFilter(
|
||||||
|
displayName: String,
|
||||||
|
private val options: Array<Pair<String, String>>,
|
||||||
|
) : Filter.Select<String>(
|
||||||
|
displayName,
|
||||||
|
options.map { it.first }.toTypedArray(),
|
||||||
|
) {
|
||||||
|
val selected get() = options[state].second.takeUnless { it.isEmpty() }
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class TextFilter(name: String) : Filter.Text(name)
|
||||||
|
|
||||||
|
abstract class TagFilter(name: String) : TextFilter(name) {
|
||||||
|
val tags get() = state.split(",")
|
||||||
|
.map { it.trim().lowercase() }
|
||||||
|
.filter { it.isNotEmpty() }
|
||||||
|
.takeUnless { it.isEmpty() }
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class PageFilter(name: String) : TextFilter(name) {
|
||||||
|
val value get() = state.trim().toIntOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
class SortFilter : SelectFilter(
|
||||||
|
"Sort By",
|
||||||
|
arrayOf(
|
||||||
|
Pair("Update", ""),
|
||||||
|
Pair("Popular", "Popular"),
|
||||||
|
Pair("Top", "Top"),
|
||||||
|
Pair("Name Ascending", "Name_ASC"),
|
||||||
|
Pair("Name Descending", "Name_DESC"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
class FormatFilter : SelectFilter(
|
||||||
|
"Format",
|
||||||
|
arrayOf(
|
||||||
|
Pair("", ""),
|
||||||
|
Pair("Manga", "manga"),
|
||||||
|
Pair("Doujinshi", "doujinshi"),
|
||||||
|
Pair("ArtistCG", "artistcg"),
|
||||||
|
Pair("GameCG", "gamecg"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
class MinPageFilter : PageFilter("Minimum Pages")
|
||||||
|
|
||||||
|
class MaxPageFilter : PageFilter("Maximum Pages")
|
||||||
|
|
||||||
|
class IncludedTagFilter : TagFilter("Include Tags")
|
||||||
|
|
||||||
|
class ExcludedTagFilter : TagFilter("Exclude Tags")
|
||||||
|
|
||||||
|
fun getFilters() = FilterList(
|
||||||
|
SortFilter(),
|
||||||
|
FormatFilter(),
|
||||||
|
Filter.Separator(),
|
||||||
|
MinPageFilter(),
|
||||||
|
MaxPageFilter(),
|
||||||
|
Filter.Separator(),
|
||||||
|
IncludedTagFilter(),
|
||||||
|
ExcludedTagFilter(),
|
||||||
|
Filter.Header("comma (,) separated tag/parody/character/artist/group"),
|
||||||
|
)
|
|
@ -0,0 +1,305 @@
|
||||||
|
package eu.kanade.tachiyomi.extension.all.ninenineninehentai
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.app.Application
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import androidx.preference.ListPreference
|
||||||
|
import androidx.preference.PreferenceScreen
|
||||||
|
import eu.kanade.tachiyomi.extension.all.ninenineninehentai.Url.Companion.toAbsUrl
|
||||||
|
import eu.kanade.tachiyomi.network.POST
|
||||||
|
import eu.kanade.tachiyomi.network.asObservableSuccess
|
||||||
|
import eu.kanade.tachiyomi.network.interceptor.rateLimit
|
||||||
|
import eu.kanade.tachiyomi.source.ConfigurableSource
|
||||||
|
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 kotlinx.serialization.decodeFromString
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import okhttp3.Headers
|
||||||
|
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import okhttp3.Response
|
||||||
|
import rx.Observable
|
||||||
|
import uy.kohesive.injekt.Injekt
|
||||||
|
import uy.kohesive.injekt.api.get
|
||||||
|
import uy.kohesive.injekt.injectLazy
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
open class NineNineNineHentai(
|
||||||
|
final override val lang: String,
|
||||||
|
private val siteLang: String = lang,
|
||||||
|
) : HttpSource(), ConfigurableSource {
|
||||||
|
|
||||||
|
override val name = "999Hentai"
|
||||||
|
|
||||||
|
override val baseUrl = "https://999hentai.to"
|
||||||
|
|
||||||
|
private val apiUrl = "https://api.999hentai.to/api"
|
||||||
|
|
||||||
|
override val supportsLatest = true
|
||||||
|
|
||||||
|
private val json: Json by injectLazy()
|
||||||
|
|
||||||
|
override val client = network.cloudflareClient.newBuilder()
|
||||||
|
.rateLimit(1)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
private val preference by lazy {
|
||||||
|
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun popularMangaRequest(page: Int): Request {
|
||||||
|
val payload = GraphQL(
|
||||||
|
PopularVariables(size, page, 1, siteLang),
|
||||||
|
POPULAR_QUERY,
|
||||||
|
)
|
||||||
|
|
||||||
|
val requestBody = payload.toJsonRequestBody()
|
||||||
|
|
||||||
|
val apiHeaders = headersBuilder().buildApiHeaders(requestBody)
|
||||||
|
|
||||||
|
return POST(apiUrl, apiHeaders, requestBody)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun popularMangaParse(response: Response): MangasPage {
|
||||||
|
val res = response.parseAs<ApiPopularResponse>()
|
||||||
|
val mangas = res.data.popular.edges
|
||||||
|
val dateMap = preference.dateMap
|
||||||
|
val entries = mangas.map { manga ->
|
||||||
|
manga.uploadDate?.let { dateMap[manga.id] = it }
|
||||||
|
manga.toSManga()
|
||||||
|
}
|
||||||
|
preference.dateMap = dateMap
|
||||||
|
val hasNextPage = mangas.size == size
|
||||||
|
|
||||||
|
return MangasPage(entries, hasNextPage)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun latestUpdatesRequest(page: Int) = searchMangaRequest(page, "", FilterList())
|
||||||
|
|
||||||
|
override fun latestUpdatesParse(response: Response) = searchMangaParse(response)
|
||||||
|
|
||||||
|
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
|
||||||
|
return if (query.startsWith(SEARCH_PREFIX)) {
|
||||||
|
val mangaId = query.substringAfter(SEARCH_PREFIX)
|
||||||
|
client.newCall(mangaFromIDRequest(mangaId))
|
||||||
|
.asObservableSuccess()
|
||||||
|
.map(::searchMangaFromIDParse)
|
||||||
|
} else {
|
||||||
|
super.fetchSearchManga(page, query, filters)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||||
|
val payload = GraphQL(
|
||||||
|
SearchVariables(
|
||||||
|
size = size,
|
||||||
|
page = page,
|
||||||
|
search = SearchPayload(
|
||||||
|
query = query.trim().takeUnless { it.isEmpty() },
|
||||||
|
language = siteLang,
|
||||||
|
sortBy = filters.firstInstanceOrNull<SortFilter>()?.selected,
|
||||||
|
format = filters.firstInstanceOrNull<FormatFilter>()?.selected,
|
||||||
|
tags = filters.firstInstanceOrNull<IncludedTagFilter>()?.tags,
|
||||||
|
excludeTags = filters.firstInstanceOrNull<ExcludedTagFilter>()?.tags,
|
||||||
|
pagesRangeStart = filters.firstInstanceOrNull<MinPageFilter>()?.value,
|
||||||
|
pagesRangeEnd = filters.firstInstanceOrNull<MaxPageFilter>()?.value,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SEARCH_QUERY,
|
||||||
|
)
|
||||||
|
|
||||||
|
val requestBody = payload.toJsonRequestBody()
|
||||||
|
|
||||||
|
val apiHeaders = headersBuilder().buildApiHeaders(requestBody)
|
||||||
|
|
||||||
|
return POST(apiUrl, apiHeaders, requestBody)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun searchMangaParse(response: Response): MangasPage {
|
||||||
|
val res = response.parseAs<ApiSearchResponse>()
|
||||||
|
val mangas = res.data.search.edges
|
||||||
|
val dateMap = preference.dateMap
|
||||||
|
val entries = mangas.map { manga ->
|
||||||
|
manga.uploadDate?.let { dateMap[manga.id] = it }
|
||||||
|
manga.toSManga()
|
||||||
|
}
|
||||||
|
preference.dateMap = dateMap
|
||||||
|
val hasNextPage = mangas.size == size
|
||||||
|
|
||||||
|
return MangasPage(entries, hasNextPage)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getFilterList() = getFilters()
|
||||||
|
|
||||||
|
private fun mangaFromIDRequest(id: String): Request {
|
||||||
|
val payload = GraphQL(
|
||||||
|
IdVariables(id),
|
||||||
|
DETAILS_QUERY,
|
||||||
|
)
|
||||||
|
|
||||||
|
val requestBody = payload.toJsonRequestBody()
|
||||||
|
|
||||||
|
val apiHeaders = headersBuilder().buildApiHeaders(requestBody)
|
||||||
|
|
||||||
|
return POST(apiUrl, apiHeaders, requestBody)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun searchMangaFromIDParse(response: Response): MangasPage {
|
||||||
|
val res = response.parseAs<ApiDetailsResponse>()
|
||||||
|
|
||||||
|
val manga = res.data.details
|
||||||
|
.takeIf { it.language == siteLang || lang == "all" }
|
||||||
|
?.let { manga ->
|
||||||
|
preference.dateMap = preference.dateMap.also { dateMap ->
|
||||||
|
manga.uploadDate?.let { dateMap[manga.id] = it }
|
||||||
|
}
|
||||||
|
manga.toSManga()
|
||||||
|
}
|
||||||
|
|
||||||
|
return MangasPage(listOfNotNull(manga), false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun mangaDetailsRequest(manga: SManga): Request {
|
||||||
|
return mangaFromIDRequest(manga.url)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun mangaDetailsParse(response: Response): SManga {
|
||||||
|
val res = response.parseAs<ApiDetailsResponse>()
|
||||||
|
val manga = res.data.details
|
||||||
|
|
||||||
|
preference.dateMap = preference.dateMap.also { dateMap ->
|
||||||
|
manga.uploadDate?.let { dateMap[manga.id] = it }
|
||||||
|
}
|
||||||
|
|
||||||
|
return manga.toSManga()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getMangaUrl(manga: SManga) = "$baseUrl/hchapter/${manga.url}"
|
||||||
|
|
||||||
|
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
|
||||||
|
val group = manga.description
|
||||||
|
?.substringAfter("Group:", "")
|
||||||
|
?.substringBefore("\n")
|
||||||
|
?.trim()
|
||||||
|
?.takeUnless { it.isEmpty() }
|
||||||
|
|
||||||
|
return Observable.just(
|
||||||
|
listOf(
|
||||||
|
SChapter.create().apply {
|
||||||
|
name = "Chapter"
|
||||||
|
url = manga.url
|
||||||
|
date_upload = preference.dateMap[manga.url].parseDate()
|
||||||
|
scanlator = group
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getChapterUrl(chapter: SChapter) = "$baseUrl/hchapter/${chapter.url}"
|
||||||
|
|
||||||
|
override fun pageListRequest(chapter: SChapter): Request {
|
||||||
|
val payload = GraphQL(
|
||||||
|
IdVariables(chapter.url),
|
||||||
|
PAGES_QUERY,
|
||||||
|
)
|
||||||
|
|
||||||
|
val requestBody = payload.toJsonRequestBody()
|
||||||
|
|
||||||
|
val apiHeaders = headersBuilder().buildApiHeaders(requestBody)
|
||||||
|
|
||||||
|
return POST(apiUrl, apiHeaders, requestBody)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun pageListParse(response: Response): List<Page> {
|
||||||
|
val res = response.parseAs<ApiPageListResponse>()
|
||||||
|
|
||||||
|
val pages = res.data.chapter.pages?.firstOrNull()
|
||||||
|
?: return emptyList()
|
||||||
|
|
||||||
|
val cdn = pages.urlPart.toAbsUrl()
|
||||||
|
|
||||||
|
val selectedImages = when (preference.getString(PREF_IMG_QUALITY_KEY, "original")) {
|
||||||
|
"medium" -> pages.qualityMedium?.mapIndexed { i, it ->
|
||||||
|
it ?: pages.qualityOriginal[i]
|
||||||
|
}
|
||||||
|
else -> pages.qualityOriginal
|
||||||
|
} ?: pages.qualityOriginal
|
||||||
|
|
||||||
|
return selectedImages.mapIndexed { index, image ->
|
||||||
|
Page(index, "", "$cdn/${image.url}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private inline fun <reified T> String.parseAs(): T =
|
||||||
|
json.decodeFromString(this)
|
||||||
|
|
||||||
|
private inline fun <reified T> Response.parseAs(): T =
|
||||||
|
use { body.string() }.parseAs()
|
||||||
|
|
||||||
|
private inline fun <reified T> List<*>.firstInstanceOrNull(): T? =
|
||||||
|
filterIsInstance<T>().firstOrNull()
|
||||||
|
|
||||||
|
private inline fun <reified T : Any> T.toJsonRequestBody(): RequestBody =
|
||||||
|
json.encodeToString(this)
|
||||||
|
.toRequestBody(JSON_MEDIA_TYPE)
|
||||||
|
|
||||||
|
private fun Headers.Builder.buildApiHeaders(requestBody: RequestBody) = this
|
||||||
|
.add("Content-Length", requestBody.contentLength().toString())
|
||||||
|
.add("Content-Type", requestBody.contentType().toString())
|
||||||
|
.build()
|
||||||
|
|
||||||
|
private fun String?.parseDate(): Long {
|
||||||
|
return runCatching {
|
||||||
|
dateFormat.parse(this!!.trim())!!.time
|
||||||
|
}.getOrDefault(0L)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setupPreferenceScreen(screen: PreferenceScreen) {
|
||||||
|
ListPreference(screen.context).apply {
|
||||||
|
key = PREF_IMG_QUALITY_KEY
|
||||||
|
title = "Default Image Quality"
|
||||||
|
entries = arrayOf("Original", "Medium")
|
||||||
|
entryValues = arrayOf("original", "medium")
|
||||||
|
setDefaultValue("original")
|
||||||
|
summary = "%s"
|
||||||
|
}.also(screen::addPreference)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var SharedPreferences.dateMap: MutableMap<String, String>
|
||||||
|
get() {
|
||||||
|
val jsonMap = getString(PREF_DATE_MAP_KEY, "{}")!!
|
||||||
|
val dateMap = runCatching { jsonMap.parseAs<MutableMap<String, String>>() }
|
||||||
|
return dateMap.getOrDefault(mutableMapOf())
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("ApplySharedPref")
|
||||||
|
set(dateMap) {
|
||||||
|
edit()
|
||||||
|
.putString(PREF_DATE_MAP_KEY, json.encodeToString(dateMap))
|
||||||
|
.commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun chapterListParse(response: Response) = throw UnsupportedOperationException("Not Used")
|
||||||
|
override fun imageUrlParse(response: Response) = throw UnsupportedOperationException("Not Used")
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val size = 20
|
||||||
|
const val SEARCH_PREFIX = "id:"
|
||||||
|
|
||||||
|
private val JSON_MEDIA_TYPE = "application/json; charset=utf-8".toMediaTypeOrNull()
|
||||||
|
private val dateFormat by lazy {
|
||||||
|
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.ENGLISH)
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val PREF_DATE_MAP_KEY = "pref_date_map"
|
||||||
|
private const val PREF_IMG_QUALITY_KEY = "pref_image_quality"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,159 @@
|
||||||
|
package eu.kanade.tachiyomi.extension.all.ninenineninehentai
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.source.model.SManga
|
||||||
|
import eu.kanade.tachiyomi.source.model.UpdateStrategy
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
typealias ApiPopularResponse = Data<PopularResponse>
|
||||||
|
|
||||||
|
typealias ApiSearchResponse = Data<SearchResponse>
|
||||||
|
|
||||||
|
typealias ApiDetailsResponse = Data<DetailsResponse>
|
||||||
|
|
||||||
|
typealias ApiPageListResponse = Data<PageList>
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Data<T>(val data: T)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Edges<T>(val edges: List<T>)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class PopularResponse(
|
||||||
|
@SerialName("queryPopularChapters") val popular: Edges<ChapterResponse>,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class SearchResponse(
|
||||||
|
@SerialName("queryChapters") val search: Edges<ChapterResponse>,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class DetailsResponse(
|
||||||
|
@SerialName("queryChapter") val details: ChapterResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ChapterResponse(
|
||||||
|
@SerialName("_id") val id: String,
|
||||||
|
val name: String,
|
||||||
|
val uploadDate: String? = null,
|
||||||
|
val format: String? = null,
|
||||||
|
val language: String? = null,
|
||||||
|
val pages: Int? = null,
|
||||||
|
@SerialName("firstPics") val cover: List<Url>? = emptyList(),
|
||||||
|
val tags: List<Tag>? = emptyList(),
|
||||||
|
) {
|
||||||
|
fun toSManga() = SManga.create().apply {
|
||||||
|
url = id
|
||||||
|
title = name
|
||||||
|
thumbnail_url = cover?.firstOrNull()?.absUrl
|
||||||
|
author = this@ChapterResponse.author
|
||||||
|
artist = author
|
||||||
|
genre = genres
|
||||||
|
description = buildString {
|
||||||
|
if (formatParsed != null) append("Format: ${formatParsed}\n")
|
||||||
|
if (languageParsed != null) append("Language: $languageParsed\n")
|
||||||
|
if (group != null) append("Group: $group\n")
|
||||||
|
if (characters != null) append("Character(s): $characters\n")
|
||||||
|
if (parody != null) append("Parody: $parody\n")
|
||||||
|
if (pages != null) append("Pages: $pages\n")
|
||||||
|
}
|
||||||
|
status = SManga.COMPLETED
|
||||||
|
update_strategy = UpdateStrategy.ONLY_FETCH_ONCE
|
||||||
|
initialized = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private val formatParsed = when (format) {
|
||||||
|
"artistcg" -> "ArtistCG"
|
||||||
|
"gamecg" -> "GameCG"
|
||||||
|
else -> format?.capitalize()
|
||||||
|
}
|
||||||
|
|
||||||
|
private val languageParsed = when (language) {
|
||||||
|
"en" -> "English"
|
||||||
|
"jp" -> "Japanese"
|
||||||
|
"cn" -> "Chinese"
|
||||||
|
"es" -> "Spanish"
|
||||||
|
else -> language
|
||||||
|
}
|
||||||
|
|
||||||
|
private val author = tags?.firstOrNull { it.tagType == "artist" }?.tagName?.capitalize()
|
||||||
|
|
||||||
|
private val group = tags?.filter { it.tagType == "group" }
|
||||||
|
?.joinToString { it.tagName.capitalize() }
|
||||||
|
?.takeUnless { it.isEmpty() }
|
||||||
|
|
||||||
|
private val characters = tags?.filter { it.tagType == "character" }
|
||||||
|
?.joinToString { it.tagName.capitalize() }
|
||||||
|
?.takeUnless { it.isEmpty() }
|
||||||
|
|
||||||
|
private val parody = tags?.filter { it.tagType == "parody" }
|
||||||
|
?.joinToString { it.tagName.capitalize() }
|
||||||
|
?.takeUnless { it.isEmpty() }
|
||||||
|
|
||||||
|
private val genres = tags?.filterNot { it.tagType in filterTags }
|
||||||
|
?.joinToString { it.tagName.capitalize() }
|
||||||
|
?.takeUnless { it.isEmpty() }
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val filterTags = listOf("artist", "group", "character", "parody")
|
||||||
|
|
||||||
|
private fun String.capitalize(): String {
|
||||||
|
return this.trim().split(" ").joinToString(" ") { word ->
|
||||||
|
word.replaceFirstChar {
|
||||||
|
if (it.isLowerCase()) {
|
||||||
|
it.titlecase(
|
||||||
|
Locale.getDefault(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
it.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Url(val url: String) {
|
||||||
|
val absUrl get() = url.toAbsUrl()
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun String.toAbsUrl(): String {
|
||||||
|
return if (this.matches(urlRegex)) {
|
||||||
|
this
|
||||||
|
} else {
|
||||||
|
cdnUrl + this
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val cdnUrl = "https://edge.timmm111.online/"
|
||||||
|
private val urlRegex = Regex("^https?://.*")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Tag(
|
||||||
|
val tagName: String,
|
||||||
|
val tagType: String? = "genre",
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class PageList(
|
||||||
|
@SerialName("queryChapter") val chapter: PageUrl,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class PageUrl(
|
||||||
|
@SerialName("pictureUrls") val pages: List<Pages?>? = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Pages(
|
||||||
|
@SerialName("picCdn") val urlPart: String,
|
||||||
|
@SerialName("pics") val qualityOriginal: List<Url>,
|
||||||
|
@SerialName("picsM") val qualityMedium: List<Url?>? = emptyList(),
|
||||||
|
)
|
|
@ -0,0 +1,13 @@
|
||||||
|
package eu.kanade.tachiyomi.extension.all.ninenineninehentai
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.source.SourceFactory
|
||||||
|
|
||||||
|
class NineNineNineHentaiFactory : SourceFactory {
|
||||||
|
override fun createSources() = listOf(
|
||||||
|
NineNineNineHentai("all"),
|
||||||
|
NineNineNineHentai("en"),
|
||||||
|
NineNineNineHentai("ja", "jp"),
|
||||||
|
NineNineNineHentai("zh", "cn"),
|
||||||
|
NineNineNineHentai("es"),
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,39 @@
|
||||||
|
package eu.kanade.tachiyomi.extension.all.ninenineninehentai
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class GraphQL<T>(
|
||||||
|
val variables: T,
|
||||||
|
val query: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class PopularVariables(
|
||||||
|
val size: Int,
|
||||||
|
val page: Int,
|
||||||
|
val dateRange: Int,
|
||||||
|
val language: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class SearchVariables(
|
||||||
|
val size: Int,
|
||||||
|
val page: Int,
|
||||||
|
val search: SearchPayload,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class SearchPayload(
|
||||||
|
val query: String?,
|
||||||
|
val language: String,
|
||||||
|
val sortBy: String?,
|
||||||
|
val format: String?,
|
||||||
|
val tags: List<String>?,
|
||||||
|
val excludeTags: List<String>?,
|
||||||
|
val pagesRangeStart: Int?,
|
||||||
|
val pagesRangeEnd: Int?,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class IdVariables(val id: String)
|
|
@ -0,0 +1,102 @@
|
||||||
|
package eu.kanade.tachiyomi.extension.all.ninenineninehentai
|
||||||
|
|
||||||
|
private fun buildQuery(queryAction: () -> String): String {
|
||||||
|
return queryAction()
|
||||||
|
.trimIndent()
|
||||||
|
.replace("%", "$")
|
||||||
|
}
|
||||||
|
|
||||||
|
val POPULAR_QUERY: String = buildQuery {
|
||||||
|
"""
|
||||||
|
query(
|
||||||
|
%size: Int
|
||||||
|
%language: String
|
||||||
|
%dateRange: Int
|
||||||
|
%page: Int
|
||||||
|
) {
|
||||||
|
queryPopularChapters(
|
||||||
|
size: %size
|
||||||
|
language: %language
|
||||||
|
dateRange: %dateRange
|
||||||
|
page: %page
|
||||||
|
) {
|
||||||
|
edges {
|
||||||
|
_id
|
||||||
|
name
|
||||||
|
uploadDate
|
||||||
|
format
|
||||||
|
language
|
||||||
|
pages
|
||||||
|
firstPics
|
||||||
|
tags
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
|
val SEARCH_QUERY: String = buildQuery {
|
||||||
|
"""
|
||||||
|
query(
|
||||||
|
%search: SearchInput
|
||||||
|
%size: Int
|
||||||
|
%page: Int
|
||||||
|
) {
|
||||||
|
queryChapters(
|
||||||
|
limit: %size
|
||||||
|
search: %search
|
||||||
|
page: %page
|
||||||
|
) {
|
||||||
|
edges {
|
||||||
|
_id
|
||||||
|
name
|
||||||
|
uploadDate
|
||||||
|
format
|
||||||
|
language
|
||||||
|
pages
|
||||||
|
firstPics
|
||||||
|
tags
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
|
val DETAILS_QUERY: String = buildQuery {
|
||||||
|
"""
|
||||||
|
query(
|
||||||
|
%id: String
|
||||||
|
) {
|
||||||
|
queryChapter(
|
||||||
|
chapterId: %id
|
||||||
|
) {
|
||||||
|
_id
|
||||||
|
name
|
||||||
|
uploadDate
|
||||||
|
format
|
||||||
|
language
|
||||||
|
pages
|
||||||
|
firstPics
|
||||||
|
tags
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
|
val PAGES_QUERY: String = buildQuery {
|
||||||
|
"""
|
||||||
|
query(
|
||||||
|
%id: String
|
||||||
|
) {
|
||||||
|
queryChapter(
|
||||||
|
chapterId: %id
|
||||||
|
) {
|
||||||
|
pictureUrls {
|
||||||
|
picCdn
|
||||||
|
pics
|
||||||
|
picsM
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
}
|
|
@ -0,0 +1,34 @@
|
||||||
|
package eu.kanade.tachiyomi.extension.all.ninenineninehentai
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.ActivityNotFoundException
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.util.Log
|
||||||
|
import kotlin.system.exitProcess
|
||||||
|
|
||||||
|
class NineNineNineHentaiUrlActivity : 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", "${NineNineNineHentai.SEARCH_PREFIX}$id")
|
||||||
|
putExtra("filter", packageName)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
startActivity(mainIntent)
|
||||||
|
} catch (e: ActivityNotFoundException) {
|
||||||
|
Log.e("999HentaiUrlActivity", e.toString())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log.e("999HentaiUrlActivity", "could not parse uri from intent $intent")
|
||||||
|
}
|
||||||
|
|
||||||
|
finish()
|
||||||
|
exitProcess(0)
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue