diff --git a/src/en/mangabtt/AndroidManifest.xml b/src/en/mangabtt/AndroidManifest.xml new file mode 100644 index 000000000..8072ee00d --- /dev/null +++ b/src/en/mangabtt/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/src/en/mangabtt/build.gradle b/src/en/mangabtt/build.gradle new file mode 100644 index 000000000..8f7233eb9 --- /dev/null +++ b/src/en/mangabtt/build.gradle @@ -0,0 +1,8 @@ +ext { + extName = 'MangaBTT' + extClass = '.MangaBTT' + extVersionCode = 1 + isNsfw = true +} + +apply from: "$rootDir/common.gradle" diff --git a/src/en/mangabtt/res/mipmap-hdpi/ic_launcher.png b/src/en/mangabtt/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..fe9b345a3 Binary files /dev/null and b/src/en/mangabtt/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/en/mangabtt/res/mipmap-mdpi/ic_launcher.png b/src/en/mangabtt/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..70fc06d95 Binary files /dev/null and b/src/en/mangabtt/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/en/mangabtt/res/mipmap-xhdpi/ic_launcher.png b/src/en/mangabtt/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..defd761e3 Binary files /dev/null and b/src/en/mangabtt/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/en/mangabtt/res/mipmap-xxhdpi/ic_launcher.png b/src/en/mangabtt/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..64e0b45e2 Binary files /dev/null and b/src/en/mangabtt/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/en/mangabtt/res/mipmap-xxxhdpi/ic_launcher.png b/src/en/mangabtt/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..72b23c5f9 Binary files /dev/null and b/src/en/mangabtt/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/en/mangabtt/src/eu/kanade/tachiyomi/extension/en/mangabtt/MangaBTT.kt b/src/en/mangabtt/src/eu/kanade/tachiyomi/extension/en/mangabtt/MangaBTT.kt new file mode 100644 index 000000000..cf3ec31b1 --- /dev/null +++ b/src/en/mangabtt/src/eu/kanade/tachiyomi/extension/en/mangabtt/MangaBTT.kt @@ -0,0 +1,263 @@ +package eu.kanade.tachiyomi.extension.en.mangabtt + +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.POST +import eu.kanade.tachiyomi.network.interceptor.rateLimit +import eu.kanade.tachiyomi.source.model.Filter +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.online.ParsedHttpSource +import okhttp3.FormBody +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.Request +import org.jsoup.nodes.Document +import org.jsoup.nodes.Element +import java.util.Calendar + +class MangaBTT : ParsedHttpSource() { + + override val name = "MangaBTT" + + override val baseUrl = "https://mangabtt.com" + + override val lang = "en" + + override val supportsLatest = true + + override val client by lazy { + network.cloudflareClient.newBuilder() + .rateLimit(2) + .build() + } + + override fun headersBuilder() = super.headersBuilder() + .add("Referer", "$baseUrl/") + + // ============================== Popular =============================== + + override fun popularMangaRequest(page: Int): Request = searchMangaRequest( + page = page, + query = "", + filters = FilterList( + SortByFilter(default = 2), + StatusFilter(default = 1), + GenreFilter(default = 1), + ), + ) + + override fun popularMangaSelector(): String = + searchMangaSelector() + + override fun popularMangaFromElement(element: Element): SManga = + searchMangaFromElement(element) + + override fun popularMangaNextPageSelector(): String = + searchMangaNextPageSelector() + + // =============================== Latest =============================== + + override fun latestUpdatesRequest(page: Int): Request = searchMangaRequest( + page = page, + query = "", + filters = FilterList( + SortByFilter(default = 8), + StatusFilter(default = 1), + GenreFilter(default = 1), + ), + ) + + override fun latestUpdatesSelector(): String = + searchMangaSelector() + + override fun latestUpdatesFromElement(element: Element): SManga = + searchMangaFromElement(element) + + override fun latestUpdatesNextPageSelector(): String = + searchMangaNextPageSelector() + + // =============================== Search =============================== + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + val url = "$baseUrl/find-story".toHttpUrl().newBuilder().apply { + if (query.isNotBlank()) { + addQueryParameter("keyword", query) + } else { + val genre = filters.firstInstanceOrNull()?.selectedValue.orEmpty() + val status = filters.firstInstanceOrNull()?.selectedValue.orEmpty() + val sortBy = filters.firstInstanceOrNull()?.selectedValue.orEmpty() + + addQueryParameter("status", status) + addQueryParameter("sort", sortBy) + if (genre.isNotBlank()) { + addPathSegment(genre) + } + } + + addQueryParameter("page", page.toString()) + } + + return GET(url.build(), headers) + } + + override fun searchMangaSelector(): String = ".items > .row > .item" + + override fun searchMangaFromElement(element: Element): SManga = SManga.create().apply { + thumbnail_url = element.selectFirst(".image img")?.imgAttr() + element.selectFirst("figcaption h3 a")!!.run { + title = text() + setUrlWithoutDomain(attr("abs:href")) + } + } + + override fun searchMangaNextPageSelector(): String = + "ul.pagination > li.active + li:not(.disabled)" + + // =============================== Filters ============================== + + override fun getFilterList(): FilterList = FilterList( + Filter.Header("Ignored when using text search"), + Filter.Separator(), + GenreFilter(), + StatusFilter(), + SortByFilter(), + ) + + // =========================== Manga Details ============================ + + override fun mangaDetailsParse(document: Document): SManga = SManga.create().apply { + title = document.selectFirst("h1.title-detail")!!.text() + description = document.selectFirst(".detail-content p")?.text() + ?.substringAfter("comic site. The Summary is ") + + document.selectFirst(".detail-info")?.run { + thumbnail_url = selectFirst("img")?.imgAttr() + status = selectFirst(".status p:not(.name)").parseStatus() + genre = select(".kind a").joinToString(", ") { it.text() } + author = selectFirst(".author p:not(.name)")?.text()?.takeUnless { + it.equals("updating", true) + } + } + } + + private fun Element?.parseStatus(): Int = with(this?.text()) { + return when { + equals("ongoing", true) -> SManga.ONGOING + equals("Đang cập nhật", true) -> SManga.ONGOING + equals("completed", true) -> SManga.COMPLETED + equals("on-hold", true) -> SManga.ON_HIATUS + equals("canceled", true) -> SManga.CANCELLED + else -> SManga.UNKNOWN + } + } + + // ============================== Chapters ============================== + + override fun chapterListRequest(manga: SManga): Request { + val postHeaders = headersBuilder().apply { + add("Accept", "*/*") + add("Host", baseUrl.toHttpUrl().host) + add("Origin", baseUrl) + set("Referer", baseUrl + manga) + add("X-Requested-With", "XMLHttpRequest") + }.build() + + val postBody = FormBody.Builder() + .add("StoryID", manga.url.substringAfterLast("-")) + .build() + + return POST("$baseUrl/Story/ListChapterByStoryID", postHeaders, postBody) + } + + override fun chapterListSelector() = "ul > li:not(.heading)" + + override fun chapterFromElement(element: Element): SChapter = SChapter.create().apply { + element.selectFirst(".col-xs-4")?.also { + date_upload = it.text().parseRelativeDate() + } + element.selectFirst("a")!!.run { + name = text() + setUrlWithoutDomain(attr("abs:href")) + } + } + + // From OppaiStream + private fun String.parseRelativeDate(): Long { + val now = Calendar.getInstance().apply { + set(Calendar.HOUR_OF_DAY, 0) + set(Calendar.MINUTE, 0) + set(Calendar.SECOND, 0) + set(Calendar.MILLISECOND, 0) + } + + var parsedDate = 0L + val relativeDate = this.split(" ").firstOrNull() + ?.replace("one", "1") + ?.replace("a", "1") + ?.toIntOrNull() + ?: return 0L + + when { + // parse: 30 seconds ago + "second" in this -> { + parsedDate = now.apply { add(Calendar.SECOND, -relativeDate) }.timeInMillis + } + // parses: "42 minutes ago" + "minute" in this -> { + parsedDate = now.apply { add(Calendar.MINUTE, -relativeDate) }.timeInMillis + } + // parses: "1 hour ago" and "2 hours ago" + "hour" in this -> { + parsedDate = now.apply { add(Calendar.HOUR, -relativeDate) }.timeInMillis + } + // parses: "2 days ago" + "day" in this -> { + parsedDate = now.apply { add(Calendar.DAY_OF_YEAR, -relativeDate) }.timeInMillis + } + // parses: "2 weeks ago" + "week" in this -> { + parsedDate = now.apply { add(Calendar.WEEK_OF_YEAR, -relativeDate) }.timeInMillis + } + // parses: "2 months ago" + "month" in this -> { + parsedDate = now.apply { add(Calendar.MONTH, -relativeDate) }.timeInMillis + } + // parse: "2 years ago" + "year" in this -> { + parsedDate = now.apply { add(Calendar.YEAR, -relativeDate) }.timeInMillis + } + } + return parsedDate + } + + // =============================== Pages ================================ + + override fun pageListParse(document: Document): List { + return document.select(".reading-detail > .page-chapter").map { page -> + val img = page.selectFirst("img[data-index]")!! + val index = img.attr("data-index").toInt() + val url = img.imgAttr() + Page(index, imageUrl = url) + }.sortedBy { it.index } + } + + override fun imageUrlParse(document: Document) = "" + + override fun imageRequest(page: Page): Request { + val imgHeaders = headersBuilder().apply { + add("Accept", "image/avif,image/webp,*/*") + add("Host", page.imageUrl!!.toHttpUrl().host) + }.build() + + return GET(page.imageUrl!!, imgHeaders) + } + + // ============================= Utilities ============================== + + private fun Element.imgAttr(): String = when { + hasAttr("data-lazy-src") -> attr("abs:data-lazy-src") + hasAttr("data-src") -> attr("abs:data-src") + else -> attr("abs:src") + } +} diff --git a/src/en/mangabtt/src/eu/kanade/tachiyomi/extension/en/mangabtt/MangaBTTFilters.kt b/src/en/mangabtt/src/eu/kanade/tachiyomi/extension/en/mangabtt/MangaBTTFilters.kt new file mode 100644 index 000000000..fbf85082f --- /dev/null +++ b/src/en/mangabtt/src/eu/kanade/tachiyomi/extension/en/mangabtt/MangaBTTFilters.kt @@ -0,0 +1,76 @@ +package eu.kanade.tachiyomi.extension.en.mangabtt + +import eu.kanade.tachiyomi.source.model.Filter + +data class FilterOption(val displayName: String, val value: String) + +inline fun List<*>.firstInstanceOrNull() = firstOrNull { it is T } as? T + +open class EnhancedSelect(name: String, private val _values: List, state: Int = 0) : + Filter.Select(name, _values.map { it.displayName }.toTypedArray(), state) { + + val selectedValue: String? + get() = _values.getOrNull(state)?.value +} + +class SortByFilter(default: Int = 1) : EnhancedSelect( + "Sort By", + listOf( + FilterOption("Top day", "13"), + FilterOption("Top week", "12"), + FilterOption("Top month", "11"), + FilterOption("Top All", "10"), + FilterOption("Comment", "25"), + FilterOption("New Manga", "15"), + FilterOption("Chapter", "30"), + FilterOption("Latest Updates", "0"), + ), + default - 1, +) + +class StatusFilter(default: Int = 1) : EnhancedSelect( + "Status", + listOf( + FilterOption("All", "-1"), + FilterOption("Completed", "2"), + FilterOption("Ongoing", "1"), + ), + default - 1, +) + +class GenreFilter(default: Int = 1) : EnhancedSelect( + "Genre", + listOf( + FilterOption("All", ""), + FilterOption("Action", "action"), + FilterOption("ADVENTURE", "adventure"), + FilterOption("Comedy", "comedy"), + FilterOption("Cooking", "cooking"), + FilterOption("Drama", "drama"), + FilterOption("Fantasy", "fantasy"), + FilterOption("Historical", "historical"), + FilterOption("Horror", "horror"), + FilterOption("Isekai", "isekai"), + FilterOption("Josei", "josei"), + FilterOption("Manhua", "manhua"), + FilterOption("Manhwa", "manhwa"), + FilterOption("Martial Arts", "martial-arts"), + FilterOption("Mecha", "mecha"), + FilterOption("MYSTERY", "mystery"), + FilterOption("PSYCHOLOGICAL", "psychological"), + FilterOption("Romance", "romance"), + FilterOption("School Life", "school-life"), + FilterOption("Sci fi", "sci-fi"), + FilterOption("Seinen", "seinen"), + FilterOption("Shoujo", "shoujo"), + FilterOption("Shounen", "shounen"), + FilterOption("SLICE OF LIF", "slice-of-lif"), + FilterOption("Slice of Life", "slice-of-life"), + FilterOption("Sports", "sports"), + FilterOption("SUGGESTIVE", "suggestive"), + FilterOption("SUPERNATURAL", "supernatural"), + FilterOption("TRAGEDY", "tragedy"), + FilterOption("Webtoons", "webtoons"), + ), + default - 1, +)