diff --git a/src/all/ninenineninehentai/AndroidManifest.xml b/src/all/ninenineninehentai/AndroidManifest.xml
new file mode 100644
index 000000000..733320219
--- /dev/null
+++ b/src/all/ninenineninehentai/AndroidManifest.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/all/ninenineninehentai/build.gradle b/src/all/ninenineninehentai/build.gradle
new file mode 100644
index 000000000..5f7f2d5e3
--- /dev/null
+++ b/src/all/ninenineninehentai/build.gradle
@@ -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"
\ No newline at end of file
diff --git a/src/all/ninenineninehentai/res/mipmap-hdpi/ic_launcher.png b/src/all/ninenineninehentai/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 000000000..f5d448ee4
Binary files /dev/null and b/src/all/ninenineninehentai/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/src/all/ninenineninehentai/res/mipmap-mdpi/ic_launcher.png b/src/all/ninenineninehentai/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 000000000..929615309
Binary files /dev/null and b/src/all/ninenineninehentai/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/src/all/ninenineninehentai/res/mipmap-xhdpi/ic_launcher.png b/src/all/ninenineninehentai/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 000000000..2a76504d5
Binary files /dev/null and b/src/all/ninenineninehentai/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/src/all/ninenineninehentai/res/mipmap-xxhdpi/ic_launcher.png b/src/all/ninenineninehentai/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 000000000..8df4c773f
Binary files /dev/null and b/src/all/ninenineninehentai/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/src/all/ninenineninehentai/res/mipmap-xxxhdpi/ic_launcher.png b/src/all/ninenineninehentai/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 000000000..0f3523c57
Binary files /dev/null and b/src/all/ninenineninehentai/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/src/all/ninenineninehentai/res/web_hi_res_512.png b/src/all/ninenineninehentai/res/web_hi_res_512.png
new file mode 100644
index 000000000..7e94372b4
Binary files /dev/null and b/src/all/ninenineninehentai/res/web_hi_res_512.png differ
diff --git a/src/all/ninenineninehentai/src/eu/kanade/tachiyomi/extension/all/ninenineninehentai/NineNineHentaiFilters.kt b/src/all/ninenineninehentai/src/eu/kanade/tachiyomi/extension/all/ninenineninehentai/NineNineHentaiFilters.kt
new file mode 100644
index 000000000..0526b14ff
--- /dev/null
+++ b/src/all/ninenineninehentai/src/eu/kanade/tachiyomi/extension/all/ninenineninehentai/NineNineHentaiFilters.kt
@@ -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>,
+) : Filter.Select(
+ 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"),
+)
diff --git a/src/all/ninenineninehentai/src/eu/kanade/tachiyomi/extension/all/ninenineninehentai/NineNineNineHentai.kt b/src/all/ninenineninehentai/src/eu/kanade/tachiyomi/extension/all/ninenineninehentai/NineNineNineHentai.kt
new file mode 100644
index 000000000..230091d6d
--- /dev/null
+++ b/src/all/ninenineninehentai/src/eu/kanade/tachiyomi/extension/all/ninenineninehentai/NineNineNineHentai.kt
@@ -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().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()
+ 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 {
+ 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()?.selected,
+ format = filters.firstInstanceOrNull()?.selected,
+ tags = filters.firstInstanceOrNull()?.tags,
+ excludeTags = filters.firstInstanceOrNull()?.tags,
+ pagesRangeStart = filters.firstInstanceOrNull()?.value,
+ pagesRangeEnd = filters.firstInstanceOrNull()?.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()
+ 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()
+
+ 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()
+ 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> {
+ 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 {
+ val res = response.parseAs()
+
+ 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 String.parseAs(): T =
+ json.decodeFromString(this)
+
+ private inline fun Response.parseAs(): T =
+ use { body.string() }.parseAs()
+
+ private inline fun List<*>.firstInstanceOrNull(): T? =
+ filterIsInstance().firstOrNull()
+
+ private inline fun 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
+ get() {
+ val jsonMap = getString(PREF_DATE_MAP_KEY, "{}")!!
+ val dateMap = runCatching { jsonMap.parseAs>() }
+ 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"
+ }
+}
diff --git a/src/all/ninenineninehentai/src/eu/kanade/tachiyomi/extension/all/ninenineninehentai/NineNineNineHentaiDto.kt b/src/all/ninenineninehentai/src/eu/kanade/tachiyomi/extension/all/ninenineninehentai/NineNineNineHentaiDto.kt
new file mode 100644
index 000000000..48a66577a
--- /dev/null
+++ b/src/all/ninenineninehentai/src/eu/kanade/tachiyomi/extension/all/ninenineninehentai/NineNineNineHentaiDto.kt
@@ -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
+
+typealias ApiSearchResponse = Data
+
+typealias ApiDetailsResponse = Data
+
+typealias ApiPageListResponse = Data
+
+@Serializable
+data class Data(val data: T)
+
+@Serializable
+data class Edges(val edges: List)
+
+@Serializable
+data class PopularResponse(
+ @SerialName("queryPopularChapters") val popular: Edges,
+)
+
+@Serializable
+data class SearchResponse(
+ @SerialName("queryChapters") val search: Edges,
+)
+
+@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? = emptyList(),
+ val tags: List? = 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? = emptyList(),
+)
+
+@Serializable
+data class Pages(
+ @SerialName("picCdn") val urlPart: String,
+ @SerialName("pics") val qualityOriginal: List,
+ @SerialName("picsM") val qualityMedium: List? = emptyList(),
+)
diff --git a/src/all/ninenineninehentai/src/eu/kanade/tachiyomi/extension/all/ninenineninehentai/NineNineNineHentaiFactory.kt b/src/all/ninenineninehentai/src/eu/kanade/tachiyomi/extension/all/ninenineninehentai/NineNineNineHentaiFactory.kt
new file mode 100644
index 000000000..eb3d6ba7b
--- /dev/null
+++ b/src/all/ninenineninehentai/src/eu/kanade/tachiyomi/extension/all/ninenineninehentai/NineNineNineHentaiFactory.kt
@@ -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"),
+ )
+}
diff --git a/src/all/ninenineninehentai/src/eu/kanade/tachiyomi/extension/all/ninenineninehentai/NineNineNineHentaiPayloadDto.kt b/src/all/ninenineninehentai/src/eu/kanade/tachiyomi/extension/all/ninenineninehentai/NineNineNineHentaiPayloadDto.kt
new file mode 100644
index 000000000..e7ebc3b3f
--- /dev/null
+++ b/src/all/ninenineninehentai/src/eu/kanade/tachiyomi/extension/all/ninenineninehentai/NineNineNineHentaiPayloadDto.kt
@@ -0,0 +1,39 @@
+package eu.kanade.tachiyomi.extension.all.ninenineninehentai
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class GraphQL(
+ 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?,
+ val excludeTags: List?,
+ val pagesRangeStart: Int?,
+ val pagesRangeEnd: Int?,
+)
+
+@Serializable
+data class IdVariables(val id: String)
diff --git a/src/all/ninenineninehentai/src/eu/kanade/tachiyomi/extension/all/ninenineninehentai/NineNineNineHentaiQueries.kt b/src/all/ninenineninehentai/src/eu/kanade/tachiyomi/extension/all/ninenineninehentai/NineNineNineHentaiQueries.kt
new file mode 100644
index 000000000..26759bb53
--- /dev/null
+++ b/src/all/ninenineninehentai/src/eu/kanade/tachiyomi/extension/all/ninenineninehentai/NineNineNineHentaiQueries.kt
@@ -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
+ }
+ }
+ }
+ """
+}
diff --git a/src/all/ninenineninehentai/src/eu/kanade/tachiyomi/extension/all/ninenineninehentai/NineNineNineHentaiUrlActivity.kt b/src/all/ninenineninehentai/src/eu/kanade/tachiyomi/extension/all/ninenineninehentai/NineNineNineHentaiUrlActivity.kt
new file mode 100644
index 000000000..d598b5fba
--- /dev/null
+++ b/src/all/ninenineninehentai/src/eu/kanade/tachiyomi/extension/all/ninenineninehentai/NineNineNineHentaiUrlActivity.kt
@@ -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)
+ }
+}