diff --git a/src/en/mangasect/AndroidManifest.xml b/src/en/mangasect/AndroidManifest.xml new file mode 100644 index 000000000..8072ee00d --- /dev/null +++ b/src/en/mangasect/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/src/en/mangasect/build.gradle b/src/en/mangasect/build.gradle new file mode 100644 index 000000000..86f4f6844 --- /dev/null +++ b/src/en/mangasect/build.gradle @@ -0,0 +1,12 @@ +apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' +apply plugin: 'kotlinx-serialization' + +ext { + extName = 'Manga Sect' + pkgNameSuffix = 'en.mangasect' + extClass = '.MangaSect' + extVersionCode = 1 +} + +apply from: "$rootDir/common.gradle" diff --git a/src/en/mangasect/res/mipmap-hdpi/ic_launcher.png b/src/en/mangasect/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..9989b486f Binary files /dev/null and b/src/en/mangasect/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/en/mangasect/res/mipmap-mdpi/ic_launcher.png b/src/en/mangasect/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..3b2068f01 Binary files /dev/null and b/src/en/mangasect/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/en/mangasect/res/mipmap-xhdpi/ic_launcher.png b/src/en/mangasect/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..1d8703e0c Binary files /dev/null and b/src/en/mangasect/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/en/mangasect/res/mipmap-xxhdpi/ic_launcher.png b/src/en/mangasect/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..d3cb384ad Binary files /dev/null and b/src/en/mangasect/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/en/mangasect/res/mipmap-xxxhdpi/ic_launcher.png b/src/en/mangasect/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..4595b48fc Binary files /dev/null and b/src/en/mangasect/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/en/mangasect/res/web_hi_res_512.png b/src/en/mangasect/res/web_hi_res_512.png new file mode 100644 index 000000000..99671ccea Binary files /dev/null and b/src/en/mangasect/res/web_hi_res_512.png differ diff --git a/src/en/mangasect/src/eu/kanade/tachiyomi/extension/en/mangasect/MangaSect.kt b/src/en/mangasect/src/eu/kanade/tachiyomi/extension/en/mangasect/MangaSect.kt new file mode 100644 index 000000000..c58c9be02 --- /dev/null +++ b/src/en/mangasect/src/eu/kanade/tachiyomi/extension/en/mangasect/MangaSect.kt @@ -0,0 +1,238 @@ +package eu.kanade.tachiyomi.extension.en.mangasect + +import eu.kanade.tachiyomi.network.GET +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.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.ParsedHttpSource +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import org.jsoup.Jsoup +import org.jsoup.nodes.Document +import org.jsoup.nodes.Element +import uy.kohesive.injekt.injectLazy + +class MangaSect : ParsedHttpSource() { + + override val name = "Manga Sect" + + override val baseUrl = "https://mangasect.com" + + override val lang = "en" + + override val supportsLatest = true + + private val json: Json by injectLazy() + + override val client: OkHttpClient = network.cloudflareClient.newBuilder() + .rateLimit(1) + .build() + + override fun headersBuilder() = super.headersBuilder() + .add("Referer", "$baseUrl/") + + // Popular + + override fun popularMangaRequest(page: Int): Request = GET("$baseUrl/ranking/week/$page", headers) + + override fun popularMangaSelector(): String = "div#main div.grid > div" + + override fun popularMangaFromElement(element: Element): SManga = SManga.create().apply { + thumbnail_url = element.selectFirst("img")?.imgAttr() + element.selectFirst(".text-center a")!!.run { + title = text().trim() + setUrlWithoutDomain(attr("href")) + } + } + + override fun popularMangaNextPageSelector(): String = ".blog-pager > span.pagecurrent + span" + + // Latest + + override fun latestUpdatesRequest(page: Int): Request = + GET("$baseUrl/all-manga/$page/?sort=1", headers) + + override fun latestUpdatesParse(response: Response): MangasPage = popularMangaParse(response) + + override fun latestUpdatesSelector(): String = + throw UnsupportedOperationException("Not used") + + override fun latestUpdatesFromElement(element: Element): SManga = + throw UnsupportedOperationException("Not used") + + override fun latestUpdatesNextPageSelector(): String = + throw UnsupportedOperationException("Not used") + + // Search + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + val url = baseUrl.toHttpUrl().newBuilder().apply { + if (query.isNotBlank()) { + addPathSegment("search") + addQueryParameter("keyword", query) + } else { + addPathSegment("filter") + filters.forEach { filter -> + when (filter) { + is GenreFilter -> { + if (filter.checked.isNotEmpty()) { + addQueryParameter("genres", filter.checked.joinToString(",")) + } + } + is StatusFilter -> { + if (filter.selected.isNotBlank()) { + addQueryParameter("status", filter.selected) + } + } + is SortFilter -> { + addQueryParameter("sort", filter.selected) + } + is ChapterCountFilter -> { + addQueryParameter("chapter_count", filter.selected) + } + is GenderFilter -> { + addQueryParameter("sex", filter.selected) + } + else -> {} + } + } + } + + addPathSegment(page.toString()) + addPathSegment("") + } + + return GET(url.toString(), headers) + } + + override fun searchMangaParse(response: Response): MangasPage = popularMangaParse(response) + + override fun searchMangaSelector(): String = + throw UnsupportedOperationException("Not used") + + override fun searchMangaFromElement(element: Element): SManga = + throw UnsupportedOperationException("Not used") + + override fun searchMangaNextPageSelector(): String = + throw UnsupportedOperationException("Not used") + + // Filters + + override fun getFilterList(): FilterList = FilterList( + Filter.Header("Ignored when using text search"), + Filter.Separator(), + GenreFilter(), + ChapterCountFilter(), + GenderFilter(), + StatusFilter(), + SortFilter(), + ) + + // Details + + override fun mangaDetailsParse(document: Document): SManga = SManga.create().apply { + description = document.selectFirst("div#syn-target")?.text() + thumbnail_url = document.selectFirst(".a1 > figure img")?.imgAttr() + title = document.selectFirst(".a2 header h1")?.text()?.trim() ?: "N/A" + genre = document.select(".a2 div > a[rel='tag'].label").joinToString(", ") { it.text() } + + document.selectFirst(".a1 > aside")?.run { + author = select("div:contains(Authors) > span a") + .joinToString(", ") { it.text().trim() } + .takeUnless { it.isBlank() || it.equals("Updating", true) } + status = selectFirst("div:contains(Status) > span")?.text().let(::parseStatus) + } + } + + private fun parseStatus(status: String?): Int = when { + status.equals("ongoing", true) -> SManga.ONGOING + status.equals("completed", true) -> SManga.COMPLETED + status.equals("on-hold", true) -> SManga.ON_HIATUS + status.equals("canceled", true) -> SManga.CANCELLED + else -> SManga.UNKNOWN + } + + // Chapters + + override fun chapterListSelector() = "ul > li.chapter" + + override fun chapterFromElement(element: Element): SChapter = SChapter.create().apply { + element.selectFirst("time[datetime]")?.also { + date_upload = it.attr("datetime").toLongOrNull()?.let { it * 1000L } ?: 0L + } + element.selectFirst("a")!!.run { + text().trim().also { + name = it + chapter_number = it.substringAfter("hapter ").toFloatOrNull() ?: 0F + } + setUrlWithoutDomain(attr("href")) + } + } + + // Pages + + override fun pageListRequest(chapter: SChapter): Request { + val pageHeaders = headersBuilder().apply { + add("Accept", "application/json, text/javascript, */*; q=0.01") + add("Host", baseUrl.toHttpUrl().host) + add("Referer", baseUrl + chapter.url) + add("X-Requested-With", "XMLHttpRequest") + }.build() + + val id = chapter.url.split("/").last() + return GET("$baseUrl/ajax/image/list/chap/$id", pageHeaders) + } + + @Serializable + data class PageListResponseDto(val html: String) + + override fun pageListParse(response: Response): List { + val data = response.parseAs().html + return pageListParse( + Jsoup.parseBodyFragment( + data, + response.request.header("Referer")!!, + ), + ) + } + + override fun pageListParse(document: Document): List { + return document.select("div.separator").map { page -> + val index = page.attr("data-index").toInt() + val url = page.selectFirst("a")!!.attr("abs:href") + Page(index, document.location(), 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 + + // From mangathemesia + 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") + } + + private inline fun Response.parseAs(): T { + return json.decodeFromString(body.string()) + } +} diff --git a/src/en/mangasect/src/eu/kanade/tachiyomi/extension/en/mangasect/MangaSectFilters.kt b/src/en/mangasect/src/eu/kanade/tachiyomi/extension/en/mangasect/MangaSectFilters.kt new file mode 100644 index 000000000..92fbaa4fc --- /dev/null +++ b/src/en/mangasect/src/eu/kanade/tachiyomi/extension/en/mangasect/MangaSectFilters.kt @@ -0,0 +1,167 @@ +package eu.kanade.tachiyomi.extension.en.mangasect + +import eu.kanade.tachiyomi.source.model.Filter + +abstract class SelectFilter( + name: String, + private val options: List>, +) : Filter.Select( + name, + options.map { it.first }.toTypedArray(), +) { + val selected get() = options[state].second +} + +class CheckBoxFilter( + name: String, + val value: String, +) : Filter.CheckBox(name) + +class ChapterCountFilter : SelectFilter("Chapter count", chapterCount) { + companion object { + private val chapterCount = listOf( + Pair(">= 0", "0"), + Pair(">= 10", "10"), + Pair(">= 30", "30"), + Pair(">= 50", "50"), + Pair(">= 100", "100"), + Pair(">= 200", "200"), + Pair(">= 300", "300"), + Pair(">= 400", "400"), + Pair(">= 500", "500"), + ) + } +} + +class GenderFilter : SelectFilter("Manga Gender", gender) { + companion object { + private val gender = listOf( + Pair("All", "All"), + Pair("Boy", "Boy"), + Pair("Girl", "Girl"), + ) + } +} + +class StatusFilter : SelectFilter("Status", status) { + companion object { + private val status = listOf( + Pair("All", ""), + Pair("Completed", "completed"), + Pair("OnGoing", "on-going"), + Pair("On-Hold", "on-hold"), + Pair("Canceled", "canceled"), + ) + } +} + +class SortFilter : SelectFilter("Sort", sort) { + companion object { + private val sort = listOf( + Pair("Default", "default"), + Pair("Latest Updated", "latest-updated"), + Pair("Most Viewed", "most-viewd"), + Pair("Score", "score"), + Pair("Name A-Z", "az"), + Pair("Name Z-A", "za"), + Pair("Newest", "new"), + Pair("Oldest", "old"), + ) + } +} + +class GenreFilter : Filter.Group( + "Genre", + genres.map { CheckBoxFilter(it.first, it.second) }, +) { + val checked get() = state.filter { it.state }.map { it.value } + + companion object { + private val genres = listOf( + Pair("Action", "29"), + Pair("Adaptation", "66"), + Pair("Adult", "108"), + Pair("Adventure", "33"), + Pair("Aliens", "2326"), + Pair("Animals", "199"), + Pair("Comedy", "35"), + Pair("Comic", "109"), + Pair("Cooking", "26"), + Pair("Crime", "274"), + Pair("Delinquents", "234"), + Pair("Demons", "136"), + Pair("Drama", "39"), + Pair("Dungeons", "204"), + Pair("Ecchi", "54"), + Pair("Fantasy", "30"), + Pair("Full Color", "27"), + Pair("Genderswap", "1441"), + Pair("Genius MC", "209"), + Pair("Ghosts", "1527"), + Pair("Gore", "1678"), + Pair("Harem", "43"), + Pair("Historical", "49"), + Pair("Horror", "69"), + Pair("Incest", "1189"), + Pair("Isekai", "40"), + Pair("Loli", "198"), + Pair("Long Strip", "233"), + Pair("Magic", "212"), + Pair("Magical Girls", "1676"), + Pair("Manhua", "58"), + Pair("Manhwa", "80"), + Pair("Martial Arts", "32"), + Pair("Mature", "34"), + Pair("Mecha", "70"), + Pair("Medical", "2113"), + Pair("Military", "1531"), + Pair("Monster", "218"), + Pair("Monster Girls", "201"), + Pair("Monsters", "63"), + Pair("Murim", "208"), + Pair("Music", "412"), + Pair("Mystery", "31"), + Pair("One shot", "155"), + Pair("Overpowered", "206"), + Pair("Police", "275"), + Pair("Post-Apocalyptic", "197"), + Pair("Psychological", "36"), + Pair("Rebirth", "1435"), + Pair("Recarnation", "67"), + Pair("Regression", "205"), + Pair("Reincarnation", "64"), + Pair("Return", "1454"), + Pair("Returner", "211"), + Pair("Revenge", "219"), + Pair("Romance", "37"), + Pair("School Life", "44"), + Pair("Sci fi", "42"), + Pair("Sci-fi", "216"), + Pair("Seinen", "52"), + Pair("Sexual Violence", "2325"), + Pair("Shota", "2327"), + Pair("Shoujo", "92"), + Pair("Shounen", "38"), + Pair("Shounen ai", "103"), + Pair("Slice of Life", "68"), + Pair("Super power", "213"), + Pair("Superhero", "1630"), + Pair("Supernatural", "41"), + Pair("Survival", "463"), + Pair("System", "203"), + Pair("Thriller", "462"), + Pair("Time travel", "65"), + Pair("tower", "207"), + Pair("Tragedy", "51"), + Pair("Transmigration", "217"), + Pair("Uncategorized", "55"), + Pair("Vampires", "200"), + Pair("Video Games", "1606"), + Pair("Virtual Reality", "757"), + Pair("Web comic", "98"), + Pair("Webtoons", "77"), + Pair("Wuxia", "202"), + Pair("Zombies", "464"), + ) + } +}