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) +}