diff --git a/src/all/danbooru/AndroidManifest.xml b/src/all/danbooru/AndroidManifest.xml
new file mode 100644
index 000000000..30deb7f79
--- /dev/null
+++ b/src/all/danbooru/AndroidManifest.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/src/all/danbooru/build.gradle b/src/all/danbooru/build.gradle
new file mode 100644
index 000000000..345adb676
--- /dev/null
+++ b/src/all/danbooru/build.gradle
@@ -0,0 +1,13 @@
+apply plugin: 'com.android.application'
+apply plugin: 'kotlin-android'
+apply plugin: 'kotlinx-serialization'
+
+ext {
+ extName = 'Danbooru'
+ pkgNameSuffix = 'all.danbooru'
+ extClass = '.Danbooru'
+ extVersionCode = 1
+ isNsfw = true
+}
+
+apply from: "$rootDir/common.gradle"
diff --git a/src/all/danbooru/res/mipmap-hdpi/ic_launcher.png b/src/all/danbooru/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 000000000..65b32f373
Binary files /dev/null and b/src/all/danbooru/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/src/all/danbooru/res/mipmap-mdpi/ic_launcher.png b/src/all/danbooru/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 000000000..23a1f8ed2
Binary files /dev/null and b/src/all/danbooru/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/src/all/danbooru/res/mipmap-xhdpi/ic_launcher.png b/src/all/danbooru/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 000000000..a4bafa84f
Binary files /dev/null and b/src/all/danbooru/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/src/all/danbooru/res/mipmap-xxhdpi/ic_launcher.png b/src/all/danbooru/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 000000000..02ce9e839
Binary files /dev/null and b/src/all/danbooru/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/src/all/danbooru/res/mipmap-xxxhdpi/ic_launcher.png b/src/all/danbooru/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 000000000..2487b4b94
Binary files /dev/null and b/src/all/danbooru/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/src/all/danbooru/res/web_hi_res_512.png b/src/all/danbooru/res/web_hi_res_512.png
new file mode 100644
index 000000000..a6586a5fb
Binary files /dev/null and b/src/all/danbooru/res/web_hi_res_512.png differ
diff --git a/src/all/danbooru/src/eu/kanade/tachiyomi/extension/all/danbooru/Danbooru.kt b/src/all/danbooru/src/eu/kanade/tachiyomi/extension/all/danbooru/Danbooru.kt
new file mode 100644
index 000000000..5ae5b9009
--- /dev/null
+++ b/src/all/danbooru/src/eu/kanade/tachiyomi/extension/all/danbooru/Danbooru.kt
@@ -0,0 +1,188 @@
+package eu.kanade.tachiyomi.extension.all.danbooru
+
+import eu.kanade.tachiyomi.network.GET
+import eu.kanade.tachiyomi.source.model.FilterList
+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.model.UpdateStrategy
+import eu.kanade.tachiyomi.source.online.ParsedHttpSource
+import kotlinx.serialization.decodeFromString
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.jsonArray
+import kotlinx.serialization.json.jsonPrimitive
+import okhttp3.HttpUrl.Companion.toHttpUrl
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.Response
+import org.jsoup.nodes.Document
+import org.jsoup.nodes.Element
+import uy.kohesive.injekt.injectLazy
+import java.text.SimpleDateFormat
+import java.util.Locale
+
+class Danbooru : ParsedHttpSource() {
+ override val name: String = "Danbooru"
+ override val baseUrl: String = "https://danbooru.donmai.us"
+ override val lang: String = "all"
+ override val supportsLatest: Boolean = true
+
+ override val client: OkHttpClient = network.cloudflareClient
+ private val json: Json by injectLazy()
+
+ private val dateFormat: SimpleDateFormat by lazy {
+ SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.ENGLISH)
+ }
+
+ override fun popularMangaRequest(page: Int): Request =
+ searchMangaRequest(page, "", FilterList())
+
+ override fun popularMangaFromElement(element: Element): SManga =
+ searchMangaFromElement(element)
+
+ override fun popularMangaNextPageSelector(): String =
+ searchMangaNextPageSelector()
+
+ override fun popularMangaSelector(): String =
+ searchMangaSelector()
+
+ override fun searchMangaRequest(page: Int, query: String, filters: FilterList) = Request(
+ url = "$baseUrl/pools/gallery".toHttpUrl().newBuilder().run {
+ setEncodedQueryParameter("search[category]", "series")
+
+ filters.forEach {
+ when (it) {
+ is FilterTags -> if (it.state.isNotBlank()) {
+ addQueryParameter("search[post_tags_match]", it.state)
+ }
+
+ is FilterDescription -> if (it.state.isNotBlank()) {
+ addQueryParameter("search[description_matches]", it.state)
+ }
+
+ is FilterIsDeleted -> if (it.state) {
+ addEncodedQueryParameter("search[is_deleted]", "true")
+ }
+
+ is FilterCategory -> {
+ setEncodedQueryParameter("search[category]", it.selected)
+ }
+
+ is FilterOrder -> if (it.selected != null) {
+ addEncodedQueryParameter("search[order]", it.selected)
+ }
+
+ else -> throw IllegalStateException("Unrecognized filter")
+ }
+ }
+
+ addEncodedQueryParameter("page", page.toString())
+
+ if (query.isNotBlank()) {
+ addQueryParameter("search[name_contains]", query)
+ }
+
+ build()
+ },
+
+ headers = headers,
+ )
+
+ override fun searchMangaSelector(): String =
+ ".post-preview"
+
+ override fun searchMangaFromElement(element: Element) = SManga.create().apply {
+ url = element.selectFirst(".post-preview-link")?.attr("href")!!
+ title = element.selectFirst(".desc")?.text() ?: ""
+
+ thumbnail_url = element.selectFirst("source")?.attr("srcset")
+ ?.substringAfterLast(',')?.trim()
+ ?.substringBeforeLast(' ')?.trimStart()
+ }
+
+ override fun searchMangaNextPageSelector(): String =
+ "a.paginator-next"
+
+ override fun latestUpdatesRequest(page: Int): Request =
+ searchMangaRequest(page, "", FilterList(FilterOrder("created_at")))
+
+ override fun latestUpdatesSelector(): String =
+ searchMangaSelector()
+
+ override fun latestUpdatesFromElement(element: Element): SManga =
+ searchMangaFromElement(element)
+
+ override fun latestUpdatesNextPageSelector(): String =
+ searchMangaNextPageSelector()
+
+ override fun mangaDetailsParse(document: Document) = SManga.create().apply {
+ setUrlWithoutDomain(document.location())
+
+ title = document.selectFirst(".pool-category-series, .pool-category-collection")?.text() ?: ""
+ description = document.getElementById("description")?.wholeText() ?: ""
+ update_strategy = UpdateStrategy.ONLY_FETCH_ONCE
+ }
+
+ override fun chapterListRequest(manga: SManga): Request =
+ GET("$baseUrl${manga.url}.json?only=id,created_at", headers)
+
+ override fun chapterListParse(response: Response): List = listOf(
+ SChapter.create().apply {
+ val data = json.decodeFromString(response.body.string())
+
+ val id = data["id"]!!.jsonPrimitive.content
+ val createdAt = data["created_at"]?.jsonPrimitive?.content
+
+ url = "/pools/$id"
+ name = "Oneshot"
+ date_upload = createdAt?.let(::parseTimestamp) ?: 0
+ chapter_number = 0F
+ },
+ )
+
+ override fun chapterListSelector(): String =
+ throw IllegalStateException("Not used")
+
+ override fun chapterFromElement(element: Element): SChapter =
+ throw IllegalStateException("Not used")
+
+ override fun pageListRequest(chapter: SChapter): Request =
+ GET("$baseUrl${chapter.url}.json?only=post_ids", headers)
+
+ override fun pageListParse(response: Response): List =
+ json.decodeFromString(response.body.string())
+ .get("post_ids")?.jsonArray
+ ?.map { it.jsonPrimitive.content }
+ ?.mapIndexed { i, id -> Page(index = i, url = "/posts/$id") }
+ ?: emptyList()
+
+ override fun pageListParse(document: Document): List =
+ throw IllegalStateException("Not used")
+
+ override fun imageUrlRequest(page: Page): Request =
+ GET("$baseUrl${page.url}.json?only=file_url", headers)
+
+ override fun imageUrlParse(response: Response): String =
+ json.decodeFromString(response.body.string())
+ .get("file_url")!!.jsonPrimitive.content
+
+ override fun imageUrlParse(document: Document): String =
+ throw IllegalStateException("Not used")
+
+ override fun getChapterUrl(chapter: SChapter): String =
+ baseUrl + chapter.url
+
+ override fun getFilterList() = FilterList(
+ listOf(
+ FilterDescription(),
+ FilterTags(),
+ FilterIsDeleted(),
+ FilterCategory(),
+ FilterOrder(),
+ ),
+ )
+
+ private fun parseTimestamp(string: String): Long? =
+ runCatching { dateFormat.parse(string)?.time!! }.getOrNull()
+}
diff --git a/src/all/danbooru/src/eu/kanade/tachiyomi/extension/all/danbooru/Filters.kt b/src/all/danbooru/src/eu/kanade/tachiyomi/extension/all/danbooru/Filters.kt
new file mode 100644
index 000000000..9db604004
--- /dev/null
+++ b/src/all/danbooru/src/eu/kanade/tachiyomi/extension/all/danbooru/Filters.kt
@@ -0,0 +1,28 @@
+package eu.kanade.tachiyomi.extension.all.danbooru
+import eu.kanade.tachiyomi.source.model.Filter
+
+internal class FilterTags : Filter.Text("Tags")
+internal class FilterDescription : Filter.Text("Description")
+internal class FilterIsDeleted : Filter.CheckBox("Deleted")
+
+internal class FilterCategory : Filter.Select("Category", values, 1) {
+ companion object {
+ val values = arrayOf("", "Series", "Collection")
+ val keys = arrayOf("", "series", "collection")
+ }
+
+ val selected: String get() = keys[state]
+}
+
+internal class FilterOrder : Filter.Sort("Order", values, Selection(0, false)) {
+ companion object {
+ val values = arrayOf("Last updated", "Name", "Recently created", "Post count")
+ val keys = arrayOf("updated_at", "name", "created_at", "post_count")
+ }
+
+ val selected: String? get() = state?.let { keys[it.index] }
+}
+
+internal fun FilterOrder(key: String?, ascending: Boolean = false) = FilterOrder().apply {
+ state = Filter.Sort.Selection(FilterOrder.keys.indexOf(key), ascending)
+}