diff --git a/src/en/weebcentral/build.gradle b/src/en/weebcentral/build.gradle new file mode 100644 index 000000000..ac13a189c --- /dev/null +++ b/src/en/weebcentral/build.gradle @@ -0,0 +1,8 @@ +ext { + extName = 'Weeb Central' + extClass = '.WeebCentral' + extVersionCode = 1 + isNsfw = true +} + +apply from: "$rootDir/common.gradle" diff --git a/src/en/weebcentral/res/mipmap-hdpi/ic_launcher.png b/src/en/weebcentral/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..ccadbf82d Binary files /dev/null and b/src/en/weebcentral/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/en/weebcentral/res/mipmap-mdpi/ic_launcher.png b/src/en/weebcentral/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..25063f087 Binary files /dev/null and b/src/en/weebcentral/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/en/weebcentral/res/mipmap-xhdpi/ic_launcher.png b/src/en/weebcentral/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..9c288a810 Binary files /dev/null and b/src/en/weebcentral/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/en/weebcentral/res/mipmap-xxhdpi/ic_launcher.png b/src/en/weebcentral/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..47acb6883 Binary files /dev/null and b/src/en/weebcentral/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/en/weebcentral/res/mipmap-xxxhdpi/ic_launcher.png b/src/en/weebcentral/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..7f6b1532a Binary files /dev/null and b/src/en/weebcentral/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/en/weebcentral/src/eu/kanade/tachiyomi/extension/en/weebcentral/Filters.kt b/src/en/weebcentral/src/eu/kanade/tachiyomi/extension/en/weebcentral/Filters.kt new file mode 100644 index 000000000..a1190f626 --- /dev/null +++ b/src/en/weebcentral/src/eu/kanade/tachiyomi/extension/en/weebcentral/Filters.kt @@ -0,0 +1,164 @@ +package eu.kanade.tachiyomi.extension.en.weebcentral + +import eu.kanade.tachiyomi.source.model.Filter +import okhttp3.HttpUrl + +interface UriFilter { + fun addToUri(builder: HttpUrl.Builder) +} + +open class UriPartFilter( + name: String, + private val param: String, + private val vals: Array<Pair<String, String>>, + private val default: String = "", +) : UriFilter, Filter.Select<String>( + name, + vals.map { it.first }.toTypedArray(), + vals.indexOfFirst { it.second == default }.takeIf { it != -1 } ?: 0, +) { + override fun addToUri(builder: HttpUrl.Builder) { + builder.addQueryParameter(param, vals[state].second) + } +} + +open class UriMultiSelectOption(name: String, val value: String) : Filter.CheckBox(name) + +open class UriMultiSelectFilter( + name: String, + private val param: String, + private val options: Array<Pair<String, String>>, +) : UriFilter, Filter.Group<UriMultiSelectOption>( + name, + options.map { UriMultiSelectOption(it.first, it.second) }, +) { + override fun addToUri(builder: HttpUrl.Builder) { + state.filter { it.state }.forEach { + builder.addQueryParameter(param, it.value) + } + } +} + +open class UriMultiTriSelectOption(name: String, val value: String) : Filter.TriState(name) + +open class UriMultiTriSelectFilter( + name: String, + private val includeUrlParameter: String, + private val excludeUrlParameter: String, + private val options: Array<Pair<String, String>>, +) : UriFilter, Filter.Group<UriMultiTriSelectOption>( + name, + options.map { UriMultiTriSelectOption(it.first, it.second) }, +) { + override fun addToUri(builder: HttpUrl.Builder) { + state.forEach { + if (it.isIncluded()) { + builder.addQueryParameter(includeUrlParameter, it.value) + } + if (it.isExcluded()) { + builder.addQueryParameter(excludeUrlParameter, it.value) + } + } + } +} + +class SortFilter(default: String = "") : UriPartFilter( + "Sort", + "sort", + arrayOf( + Pair("Best Match", "Best Match"), + Pair("Alphabet", "Alphabet"), + Pair("Popularity", "Popularity"), + Pair("Subscribers", "Subscribers"), + Pair("Recently Added", "Recently Added"), + Pair("Latest Updates", "Latest Updates"), + ), + default, +) + +class SortOrderFilter : UriPartFilter( + "Sort Order", + "order", + arrayOf( + Pair("Ascending", "Ascending"), + Pair("Descending", "Descending"), + ), +) + +class OfficialTranslationFilter : UriPartFilter( + "Official Translation", + "official", + arrayOf( + Pair("Any", "Any"), + Pair("True", "True"), + Pair("False", "False"), + ), +) + +class StatusFilter : UriMultiSelectFilter( + "Series Status", + "included_status", + arrayOf( + Pair("Ongoing", "Ongoing"), + Pair("Complete", "Complete"), + Pair("Hiatus", "Hiatus"), + Pair("Canceled", "Canceled"), + ), +) + +class TypeFilter : UriMultiSelectFilter( + "Series Type", + "included_type", + arrayOf( + Pair("Manga", "Manga"), + Pair("Manhwa", "Manhwa"), + Pair("Manhua", "Manhua"), + Pair("OEL", "OEL"), + ), +) + +class TagFilter : UriMultiTriSelectFilter( + "Tags", + "included_tag", + "excluded_tag", + arrayOf( + Pair("Action", "Action"), + Pair("Adult", "Adult"), + Pair("Adventure", "Adventure"), + Pair("Comedy", "Comedy"), + Pair("Doujinshi", "Doujinshi"), + Pair("Drama", "Drama"), + Pair("Ecchi", "Ecchi"), + Pair("Fantasy", "Fantasy"), + Pair("Gender Bender", "Gender Bender"), + Pair("Harem", "Harem"), + Pair("Hentai", "Hentai"), + Pair("Historical", "Historical"), + Pair("Horror", "Horror"), + Pair("Isekai", "Isekai"), + Pair("Josei", "Josei"), + Pair("Lolicon", "Lolicon"), + Pair("Martial Arts", "Martial Arts"), + Pair("Mature", "Mature"), + Pair("Mecha", "Mecha"), + Pair("Mystery", "Mystery"), + Pair("Psychological", "Psychological"), + Pair("Romance", "Romance"), + Pair("School Life", "School Life"), + Pair("Sci-fi", "Sci-fi"), + Pair("Seinen", "Seinen"), + Pair("Shotacon", "Shotacon"), + Pair("Shoujo", "Shoujo"), + Pair("Shoujo Ai", "Shoujo Ai"), + Pair("Shounen", "Shounen"), + Pair("Shounen Ai", "Shounen Ai"), + Pair("Slice of Life", "Slice of Life"), + Pair("Smut", "Smut"), + Pair("Sports", "Sports"), + Pair("Supernatural", "Supernatural"), + Pair("Tragedy", "Tragedy"), + Pair("Yaoi", "Yaoi"), + Pair("Yuri", "Yuri"), + Pair("Other", "Other"), + ), +) diff --git a/src/en/weebcentral/src/eu/kanade/tachiyomi/extension/en/weebcentral/WeebCentral.kt b/src/en/weebcentral/src/eu/kanade/tachiyomi/extension/en/weebcentral/WeebCentral.kt new file mode 100644 index 000000000..4249e5bfb --- /dev/null +++ b/src/en/weebcentral/src/eu/kanade/tachiyomi/extension/en/weebcentral/WeebCentral.kt @@ -0,0 +1,184 @@ +package eu.kanade.tachiyomi.extension.en.weebcentral + +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.interceptor.rateLimit +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.HttpUrl.Companion.toHttpUrl +import okhttp3.Request +import org.jsoup.nodes.Document +import org.jsoup.nodes.Element +import java.text.ParseException +import java.text.SimpleDateFormat +import java.util.Locale + +class WeebCentral : ParsedHttpSource() { + + override val name = "Weeb Central" + + override val baseUrl = "https://weebcentral.com" + + override val lang = "en" + + override val supportsLatest = true + + override val client = network.cloudflareClient.newBuilder() + .rateLimit(2) + .build() + + override fun headersBuilder() = super.headersBuilder() + .add("Referer", "$baseUrl/") + + private val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.ENGLISH) + + // ============================== Popular =============================== + + override fun popularMangaRequest(page: Int): Request = searchMangaRequest( + page, + "", + defaultFilterList(SortFilter("Popularity")), + ) + + 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, + "", + defaultFilterList(SortFilter("Latest Updates")), + ) + + 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 filterList = filters.ifEmpty { getFilterList() } + val url = "$baseUrl/search".toHttpUrl().newBuilder().apply { + addQueryParameter("text", query) + filterList.filterIsInstance<UriFilter>().forEach { + it.addToUri(this) + } + addQueryParameter("limit", FETCH_LIMIT.toString()) + addQueryParameter("offset", ((page - 1) * FETCH_LIMIT).toString()) + }.build() + + return GET(url, headers) + } + + override fun searchMangaSelector(): String = "#search-results > article:not(#search-more-container)" + + override fun searchMangaFromElement(element: Element): SManga = SManga.create().apply { + thumbnail_url = element.selectFirst("img")!!.attr("abs:src") + with(element.selectFirst("div > a")!!) { + title = text() + setUrlWithoutDomain(attr("abs:href")) + } + } + + override fun searchMangaNextPageSelector(): String = "#search-more-container > button" + + // =============================== Filters ============================== + + override fun getFilterList(): FilterList = defaultFilterList(SortFilter()) + + // =========================== Manga Details ============================ + + override fun mangaDetailsParse(document: Document): SManga = SManga.create().apply { + with(document.select("section[x-data] > section")[0]) { + thumbnail_url = selectFirst("img")!!.attr("abs:src") + author = select("ul > li:has(strong:contains(Author)) > span > a").joinToString { it.text() } + genre = select("ul > li:has(strong:contains(Tag)) > span > a").joinToString { it.text() } + status = selectFirst("ul > li:has(strong:contains(Status)) > a").parseStatus() + } + + with(document.select("section[x-data] > section")[1]) { + title = selectFirst("h1")!!.text() + description = selectFirst("li:has(strong:contains(Description)) > p")?.text() + ?.replace("NOTE: ", "\n\nNOTE: ") + } + } + + private fun Element?.parseStatus(): Int = when (this?.text()?.lowercase()) { + "ongoing" -> SManga.ONGOING + "complete" -> SManga.COMPLETED + "hiatus" -> SManga.ON_HIATUS + "canceled" -> SManga.CANCELLED + else -> SManga.UNKNOWN + } + + // ============================== Chapters ============================== + + override fun chapterListRequest(manga: SManga): Request { + val url = (baseUrl + manga.url).toHttpUrl().newBuilder().apply { + removePathSegment(2) + addPathSegment("full-chapter-list") + }.build() + + return GET(url, headers) + } + + override fun chapterListSelector() = "a" + + override fun chapterFromElement(element: Element): SChapter = SChapter.create().apply { + name = element.selectFirst("span.flex")!!.text() + setUrlWithoutDomain(element.attr("abs:href")) + element.selectFirst("time[datetime]")?.also { + date_upload = it.attr("datetime").parseDate() + } + } + + private fun String.parseDate(): Long { + return try { + dateFormat.parse(this)!!.time + } catch (_: ParseException) { + 0L + } + } + // =============================== Pages ================================ + + override fun pageListParse(document: Document): List<Page> { + return document.select("section[x-data~=scroll] > img").mapIndexed { index, element -> + Page(index, imageUrl = element.attr("abs:src")) + } + } + + override fun imageUrlParse(document: Document) = + throw UnsupportedOperationException() + + 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 defaultFilterList(sortFilter: SortFilter): FilterList = FilterList( + sortFilter, + SortOrderFilter(), + OfficialTranslationFilter(), + StatusFilter(), + TypeFilter(), + TagFilter(), + ) + + companion object { + const val FETCH_LIMIT = 24 + } +}