diff --git a/.run/KemonoGenerator.run.xml b/.run/KemonoGenerator.run.xml
new file mode 100644
index 0000000000..2ac0c58daa
--- /dev/null
+++ b/.run/KemonoGenerator.run.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/multisrc/overrides/kemono/coomer/res/mipmap-hdpi/ic_launcher.png b/multisrc/overrides/kemono/coomer/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 0000000000..fdf273a9bb
Binary files /dev/null and b/multisrc/overrides/kemono/coomer/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/multisrc/overrides/kemono/coomer/res/mipmap-mdpi/ic_launcher.png b/multisrc/overrides/kemono/coomer/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 0000000000..bf37a1b7a8
Binary files /dev/null and b/multisrc/overrides/kemono/coomer/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/multisrc/overrides/kemono/coomer/res/mipmap-xhdpi/ic_launcher.png b/multisrc/overrides/kemono/coomer/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 0000000000..f9b11f5f09
Binary files /dev/null and b/multisrc/overrides/kemono/coomer/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/multisrc/overrides/kemono/coomer/res/mipmap-xxhdpi/ic_launcher.png b/multisrc/overrides/kemono/coomer/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 0000000000..04edc56b21
Binary files /dev/null and b/multisrc/overrides/kemono/coomer/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/multisrc/overrides/kemono/coomer/res/mipmap-xxxhdpi/ic_launcher.png b/multisrc/overrides/kemono/coomer/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 0000000000..61b49c814a
Binary files /dev/null and b/multisrc/overrides/kemono/coomer/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/multisrc/overrides/kemono/coomer/res/web_hi_res_512.png b/multisrc/overrides/kemono/coomer/res/web_hi_res_512.png
new file mode 100644
index 0000000000..0f401f9ebe
Binary files /dev/null and b/multisrc/overrides/kemono/coomer/res/web_hi_res_512.png differ
diff --git a/multisrc/overrides/kemono/default/res/mipmap-hdpi/ic_launcher.png b/multisrc/overrides/kemono/default/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 0000000000..404a9888f2
Binary files /dev/null and b/multisrc/overrides/kemono/default/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/multisrc/overrides/kemono/default/res/mipmap-mdpi/ic_launcher.png b/multisrc/overrides/kemono/default/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 0000000000..b7e10b9f9a
Binary files /dev/null and b/multisrc/overrides/kemono/default/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/multisrc/overrides/kemono/default/res/mipmap-xhdpi/ic_launcher.png b/multisrc/overrides/kemono/default/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 0000000000..fabf9b671f
Binary files /dev/null and b/multisrc/overrides/kemono/default/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/multisrc/overrides/kemono/default/res/mipmap-xxhdpi/ic_launcher.png b/multisrc/overrides/kemono/default/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 0000000000..aeff4ae0ee
Binary files /dev/null and b/multisrc/overrides/kemono/default/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/multisrc/overrides/kemono/default/res/mipmap-xxxhdpi/ic_launcher.png b/multisrc/overrides/kemono/default/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 0000000000..1597c0783d
Binary files /dev/null and b/multisrc/overrides/kemono/default/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/multisrc/overrides/kemono/default/res/web_hi_res_512.png b/multisrc/overrides/kemono/default/res/web_hi_res_512.png
new file mode 100644
index 0000000000..e5cbe8a723
Binary files /dev/null and b/multisrc/overrides/kemono/default/res/web_hi_res_512.png differ
diff --git a/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/kemono/Kemono.kt b/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/kemono/Kemono.kt
new file mode 100644
index 0000000000..5b03d40b7a
--- /dev/null
+++ b/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/kemono/Kemono.kt
@@ -0,0 +1,155 @@
+package eu.kanade.tachiyomi.multisrc.kemono
+
+import android.app.Application
+import androidx.preference.ListPreference
+import androidx.preference.PreferenceScreen
+import eu.kanade.tachiyomi.network.GET
+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 eu.kanade.tachiyomi.util.asJsoup
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.decodeFromStream
+import okhttp3.Request
+import okhttp3.Response
+import org.jsoup.nodes.Element
+import org.jsoup.select.Evaluator
+import rx.Observable
+import rx.Single
+import uy.kohesive.injekt.Injekt
+import uy.kohesive.injekt.api.get
+import uy.kohesive.injekt.injectLazy
+import java.util.TimeZone
+
+open class Kemono(
+ override val name: String,
+ override val baseUrl: String,
+ override val lang: String = "all",
+) : HttpSource(), ConfigurableSource {
+ override val supportsLatest = true
+
+ override val client = network.client.newBuilder().rateLimit(2).build()
+
+ private val json: Json by injectLazy()
+
+ private val preferences by lazy {
+ Injekt.get().getSharedPreferences("source_$id", 0x0000)
+ }
+
+ override fun popularMangaRequest(page: Int): Request =
+ GET("$baseUrl/artists?o=${PAGE_SIZE * (page - 1)}", headers)
+
+ override fun popularMangaParse(response: Response): MangasPage {
+ val document = response.asJsoup()
+ val cardList = document.selectFirst(Evaluator.Class("card-list"))
+ val creators = cardList.select(Evaluator.Tag("article")).map {
+ val children = it.children()
+ val avatar = children[0].selectFirst(Evaluator.Tag("img")).attr("src")
+ val link = children[1].child(0)
+ val service = children[2].ownText()
+ SManga.create().apply {
+ url = link.attr("href")
+ title = link.ownText()
+ author = service
+ thumbnail_url = baseUrl + avatar
+ description = PROMPT
+ initialized = true
+ }
+ }.filterUnsupported()
+ return MangasPage(creators, document.hasNextPage())
+ }
+
+ override fun latestUpdatesRequest(page: Int): Request =
+ GET("$baseUrl/artists/updated?o=${PAGE_SIZE * (page - 1)}", headers)
+
+ override fun latestUpdatesParse(response: Response) = popularMangaParse(response)
+
+ override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable = Single.create { subscriber ->
+ val baseUrl = this.baseUrl
+ val response = client.newCall(GET("$baseUrl/api/creators", headers)).execute()
+ val result = response.parseAs>()
+ .filter { it.name.contains(query, ignoreCase = true) }
+ .sortedByDescending { it.updatedDate }
+ .map { it.toSManga(baseUrl) }
+ .filterUnsupported()
+ subscriber.onSuccess(MangasPage(result, false))
+ }.toObservable()
+
+ override fun searchMangaRequest(page: Int, query: String, filters: FilterList) = throw UnsupportedOperationException("Not used.")
+ override fun searchMangaParse(response: Response) = throw UnsupportedOperationException("Not used.")
+
+ override fun fetchMangaDetails(manga: SManga): Observable = Observable.just(manga)
+
+ override fun mangaDetailsParse(response: Response) = throw UnsupportedOperationException("Not used.")
+
+ override fun fetchChapterList(manga: SManga): Observable> = Single.create> {
+ KemonoPostDto.dateFormat.timeZone = when (manga.author) {
+ "Pixiv Fanbox", "Fantia" -> TimeZone.getTimeZone("GMT+09:00")
+ else -> TimeZone.getTimeZone("GMT")
+ }
+ val maxPosts = preferences.getString(POST_PAGES_PREF, POST_PAGES_DEFAULT)!!
+ .toInt().coerceAtMost(POST_PAGES_MAX) * POST_PAGE_SIZE
+ var offset = 0
+ var hasNextPage = true
+ val result = ArrayList()
+ while (offset < maxPosts && hasNextPage) {
+ val request = GET("$baseUrl/api${manga.url}?limit=$POST_PAGE_SIZE&o=$offset", headers)
+ val page: List = client.newCall(request).execute().parseAs()
+ page.forEach { post -> if (post.images.isNotEmpty()) result.add(post.toSChapter()) }
+ offset += POST_PAGE_SIZE
+ hasNextPage = page.size == POST_PAGE_SIZE
+ }
+ it.onSuccess(result)
+ }.toObservable()
+
+ override fun chapterListParse(response: Response) = throw UnsupportedOperationException("Not used.")
+
+ override fun pageListRequest(chapter: SChapter): Request =
+ GET("$baseUrl/api${chapter.url}", headers)
+
+ override fun pageListParse(response: Response): List {
+ val post: List = response.parseAs()
+ return post[0].images.mapIndexed { i, path -> Page(i, imageUrl = baseUrl + path) }
+ }
+
+ override fun imageUrlParse(response: Response) = throw UnsupportedOperationException("Not used.")
+
+ private inline fun Response.parseAs(): T = use {
+ json.decodeFromStream(it.body!!.byteStream())
+ }
+
+ override fun setupPreferenceScreen(screen: PreferenceScreen) {
+ ListPreference(screen.context).apply {
+ key = POST_PAGES_PREF
+ title = "Maximum posts to load"
+ summary = "Loading more posts costs more time and network traffic.\nCurrently: %s"
+ entryValues = (1..POST_PAGES_MAX).map { it.toString() }.toTypedArray()
+ entries = (1..POST_PAGES_MAX).map {
+ if (it == 1) "1 page ($POST_PAGE_SIZE posts)" else "$it pages (${it * POST_PAGE_SIZE} posts)"
+ }.toTypedArray()
+ setDefaultValue(POST_PAGES_DEFAULT)
+ }.let { screen.addPreference(it) }
+ }
+
+ companion object {
+ private const val PAGE_SIZE = 25
+ const val PROMPT = "You can change how many posts to load in the extension preferences."
+
+ private const val POST_PAGE_SIZE = 50
+ private const val POST_PAGES_PREF = "POST_PAGES"
+ private const val POST_PAGES_DEFAULT = "1"
+ private const val POST_PAGES_MAX = 50
+
+ private fun Element.hasNextPage(): Boolean {
+ val pagination = selectFirst(Evaluator.Class("paginator"))
+ return pagination.selectFirst("a[title=Next page]") != null
+ }
+
+ private fun List.filterUnsupported() = filterNot { it.author == "Discord" }
+ }
+}
diff --git a/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/kemono/KemonoDto.kt b/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/kemono/KemonoDto.kt
new file mode 100644
index 0000000000..9f50f81c77
--- /dev/null
+++ b/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/kemono/KemonoDto.kt
@@ -0,0 +1,82 @@
+package eu.kanade.tachiyomi.multisrc.kemono
+
+import eu.kanade.tachiyomi.source.model.SChapter
+import eu.kanade.tachiyomi.source.model.SManga
+import kotlinx.serialization.Serializable
+import java.text.SimpleDateFormat
+import java.util.Locale
+
+@Serializable
+class KemonoCreatorDto(
+ private val id: String,
+ val name: String,
+ private val service: String,
+ private val updated: String,
+) {
+ val updatedDate get() = dateFormat.parse(updated)?.time ?: 0
+
+ fun toSManga(baseUrl: String) = SManga.create().apply {
+ url = "/$service/user/$id" // should be /server/ for Discord but will be filtered anyway
+ title = name
+ author = service.serviceName()
+ thumbnail_url = "$baseUrl/icons/$service/$id"
+ description = Kemono.PROMPT
+ initialized = true
+ }
+
+ companion object {
+ private val dateFormat by lazy { getApiDateFormat() }
+
+ fun String.serviceName() = when (this) {
+ "fanbox" -> "Pixiv Fanbox"
+ "subscribestar" -> "SubscribeStar"
+ "dlsite" -> "DLsite"
+ "onlyfans" -> "OnlyFans"
+ else -> replaceFirstChar { it.uppercase() }
+ }
+ }
+}
+
+@Serializable
+class KemonoPostDto(
+ private val id: String,
+ private val service: String,
+ private val user: String,
+ private val title: String,
+ private val added: String,
+ private val published: String?,
+ private val edited: String?,
+ private val file: KemonoFileDto,
+ private val attachments: List,
+) {
+ val images: List
+ get() = buildList(attachments.size + 1) {
+ file.path?.let { add(it) }
+ attachments.mapTo(this) { it.path }
+ }.filter {
+ when (it.substringAfterLast('.').lowercase()) {
+ "png", "jpg", "gif", "jpeg", "webp" -> true
+ else -> false
+ }
+ }.distinct()
+
+ fun toSChapter() = SChapter.create().apply {
+ url = "/$service/user/$user/post/$id"
+ name = title
+ date_upload = dateFormat.parse(edited ?: published ?: added)?.time ?: 0
+ chapter_number = -2f
+ }
+
+ companion object {
+ val dateFormat by lazy { getApiDateFormat() }
+ }
+}
+
+@Serializable
+class KemonoFileDto(val path: String? = null)
+
+@Serializable
+class KemonoAttachmentDto(val path: String)
+
+private fun getApiDateFormat() =
+ SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss 'GMT'", Locale.ENGLISH)
diff --git a/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/kemono/KemonoGenerator.kt b/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/kemono/KemonoGenerator.kt
new file mode 100644
index 0000000000..977e449444
--- /dev/null
+++ b/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/kemono/KemonoGenerator.kt
@@ -0,0 +1,21 @@
+package eu.kanade.tachiyomi.multisrc.kemono
+
+import generator.ThemeSourceData.SingleLang
+import generator.ThemeSourceGenerator
+
+class KemonoGenerator : ThemeSourceGenerator {
+ override val themeClass = "Kemono"
+ override val themePkg = "kemono"
+ override val baseVersionCode = 1
+ override val sources = listOf(
+ SingleLang("Kemono", "https://kemono.party", "all", isNsfw = true, className = "KemonoParty", pkgName = "kemono"),
+ SingleLang("Coomer", "https://coomer.party", "all", isNsfw = true)
+ )
+
+ companion object {
+ @JvmStatic
+ fun main(args: Array) {
+ KemonoGenerator().createAll()
+ }
+ }
+}