diff --git a/src/en/likemanga/AndroidManifest.xml b/src/en/likemanga/AndroidManifest.xml new file mode 100644 index 000000000..8072ee00d --- /dev/null +++ b/src/en/likemanga/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/src/en/likemanga/build.gradle b/src/en/likemanga/build.gradle new file mode 100644 index 000000000..a1fe18997 --- /dev/null +++ b/src/en/likemanga/build.gradle @@ -0,0 +1,12 @@ +apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' +apply plugin: 'kotlinx-serialization' + +ext { + extName = 'LikeManga' + pkgNameSuffix = 'en.likemanga' + extClass = '.LikeManga' + extVersionCode = 1 +} + +apply from: "$rootDir/common.gradle" diff --git a/src/en/likemanga/res/mipmap-hdpi/ic_launcher.png b/src/en/likemanga/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..0992d9728 Binary files /dev/null and b/src/en/likemanga/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/en/likemanga/res/mipmap-mdpi/ic_launcher.png b/src/en/likemanga/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..4159c59d5 Binary files /dev/null and b/src/en/likemanga/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/en/likemanga/res/mipmap-xhdpi/ic_launcher.png b/src/en/likemanga/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..f55409dd9 Binary files /dev/null and b/src/en/likemanga/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/en/likemanga/res/mipmap-xxhdpi/ic_launcher.png b/src/en/likemanga/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..7a367aa38 Binary files /dev/null and b/src/en/likemanga/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/en/likemanga/res/mipmap-xxxhdpi/ic_launcher.png b/src/en/likemanga/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..7c485d4aa Binary files /dev/null and b/src/en/likemanga/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/en/likemanga/res/web_hi_res_512.png b/src/en/likemanga/res/web_hi_res_512.png new file mode 100644 index 000000000..c15b6655d Binary files /dev/null and b/src/en/likemanga/res/web_hi_res_512.png differ diff --git a/src/en/likemanga/src/eu/kanade/tachiyomi/extension/en/likemanga/LikeManga.kt b/src/en/likemanga/src/eu/kanade/tachiyomi/extension/en/likemanga/LikeManga.kt new file mode 100644 index 000000000..c98d5e1bc --- /dev/null +++ b/src/en/likemanga/src/eu/kanade/tachiyomi/extension/en/likemanga/LikeManga.kt @@ -0,0 +1,276 @@ +package eu.kanade.tachiyomi.extension.en.likemanga + +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 eu.kanade.tachiyomi.util.asJsoup +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import okhttp3.HttpUrl.Companion.toHttpUrl +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 +import java.text.SimpleDateFormat +import java.util.Locale + +class LikeManga : ParsedHttpSource() { + + override val name = "LikeManga" + + override val lang = "en" + + override val baseUrl = "https://likemanga.io" + + override val supportsLatest = true + + override val client = network.cloudflareClient.newBuilder() + .rateLimit(1, 2) + .build() + + override fun headersBuilder() = super.headersBuilder() + .add("Referer", "$baseUrl/") + + private val json: Json by injectLazy() + + override fun popularMangaRequest(page: Int): Request { + return searchMangaRequest(page, "", FilterList(SortFilter("top-manga"))) + } + + override fun popularMangaParse(response: Response) = searchMangaParse(response) + override fun popularMangaFromElement(element: Element) = searchMangaFromElement(element) + override fun popularMangaSelector() = searchMangaSelector() + override fun popularMangaNextPageSelector() = searchMangaNextPageSelector() + + override fun latestUpdatesRequest(page: Int): Request { + return searchMangaRequest(page, "", FilterList(SortFilter("lastest-chap"))) + } + + override fun latestUpdatesParse(response: Response) = searchMangaParse(response) + override fun latestUpdatesFromElement(element: Element) = searchMangaFromElement(element) + override fun latestUpdatesSelector() = searchMangaSelector() + override fun latestUpdatesNextPageSelector() = searchMangaNextPageSelector() + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + val url = baseUrl.toHttpUrl().newBuilder().apply { + addQueryParameter("act", "searchadvance") + filters.forEach { filter -> + when (filter) { + is GenreFilter -> { + filter.checked?.forEach { + addQueryParameter("f[genres][]", it) + } + } + is ChapterCountFilter -> { + filter.selected?.let { + addQueryParameter("f[min_num_chapter]", it) + } + } + is StatusFilter -> { + filter.selected?.let { + addQueryParameter("f[status]", it) + } + } + is SortFilter -> { + filter.selected?.let { + addQueryParameter("f[sortby]", it) + } + } + else -> {} + } + } + if (query.isNotEmpty()) { + addQueryParameter("f[keyword]", query.trim()) + } + if (page > 1) { + addQueryParameter("pageNum", page.toString()) + } + }.build() + + return GET(url, headers) + } + + private var genresList: List> = emptyList() + + private fun parseGenres(document: Document): List> { + return document.selectFirst("div.search_genres") + ?.select("div.form-check") + .orEmpty() + .mapNotNull { + val label = it.selectFirst("label") + ?.text()?.trim() ?: return@mapNotNull null + + val value = it.selectFirst("input") + ?.attr("value") ?: return@mapNotNull null + + Pair(label, value) + } + } + + override fun getFilterList(): FilterList { + val filters: MutableList> = mutableListOf( + SortFilter(), + StatusFilter(), + ChapterCountFilter(), + ) + + filters += if (genresList.isEmpty()) { + listOf( + Filter.Separator(), + Filter.Header("Press 'reset' to attempt to show Genres"), + ) + } else { + listOf( + GenreFilter("Genre", genresList), + ) + } + + return FilterList(filters) + } + + override fun searchMangaParse(response: Response): MangasPage { + if (genresList.isEmpty()) { + val document = response.peekBody(Long.MAX_VALUE).string() + .let { Jsoup.parse(it, response.request.url.toString()) } + + genresList = parseGenres(document) + } + + return super.searchMangaParse(response) + } + + override fun searchMangaFromElement(element: Element) = SManga.create().apply { + setUrlWithoutDomain(element.selectFirst("a")!!.attr("href")) + thumbnail_url = element.selectFirst("img")?.imgAttr() + title = element.select(".title-manga").text() + } + + override fun searchMangaSelector() = "div.card-body div.card" + override fun searchMangaNextPageSelector() = "ul.pagination a:contains(ยป)" + + override fun mangaDetailsParse(document: Document) = SManga.create().apply { + title = document.select("#title-detail-manga").text() + thumbnail_url = document.selectFirst(".detail-info img")?.imgAttr() + description = document.selectFirst("#summary_shortened")?.text()?.trim() + genre = document.select(".list-info a[href*=/genres/]").joinToString { it.text() } + status = document.selectFirst(".list-info .status p:nth-child(2)")?.text().parseStatus() + author = document.selectFirst(".list-info .author p:nth-child(2)")?.text() + ?.takeUnless { it.trim() == "Updating" } + } + + private fun String?.parseStatus(): Int { + if (this == null) return SManga.UNKNOWN + + return when { + contains("Complete", true) -> SManga.COMPLETED + contains("In process", true) -> SManga.ONGOING + contains("Pause", true) -> SManga.ON_HIATUS + else -> SManga.UNKNOWN + } + } + + override fun chapterListParse(response: Response): List { + val document = response.use { it.asJsoup() } + + val chapters = document.select(chapterListSelector()) + .map(::chapterFromElement) + .toMutableList() + + val lastPage = document.select("div.chapters_pagination a:not(.next)").last() + ?.attr("onclick") + ?.substringAfter("(") + ?.substringBefore(")") + ?.toIntOrNull() + ?: return chapters + + val id = document.select("#title-detail-manga").attr("data-manga") + .toIntOrNull() ?: return chapters + + for (page in 2..lastPage) { + chapters.addAll(fetchAjaxChapterList(id, page)) + } + + return chapters + } + + private fun fetchAjaxChapterList(id: Int, page: Int): List { + val request = ajaxChapterListRequest(id, page) + val response = client.newCall(request).execute() + + if (!response.isSuccessful) { + response.close() + return emptyList() + } + + return ajaxChapterListParse(response) + } + + private fun ajaxChapterListRequest(id: Int, page: Int): Request { + val url = baseUrl.toHttpUrl().newBuilder().apply { + addQueryParameter("act", "ajax") + addQueryParameter("code", "load_list_chapter") + addQueryParameter("manga_id", id.toString()) + addQueryParameter("page_num", page.toString()) + addQueryParameter("chap_id", "0") + addQueryParameter("keyword", "") + }.build() + + return GET(url, headers) + } + + private fun ajaxChapterListParse(response: Response): List { + val responseJson = response.use { json.parseToJsonElement(it.body.string()) }.jsonObject + val htmlString = responseJson["list_chap"]!!.jsonPrimitive.content + val document = Jsoup.parseBodyFragment(htmlString, response.request.url.toString()) + + return document.select(chapterListSelector()) + .map(::chapterFromElement) + } + + override fun chapterFromElement(element: Element) = SChapter.create().apply { + setUrlWithoutDomain(element.selectFirst("a")!!.attr("href")) + name = element.select("a").text() + date_upload = element.selectFirst(".chapter-release-date")?.text().parseDate() + } + + override fun chapterListSelector() = ".wp-manga-chapter" + + private fun String?.parseDate(): Long { + return runCatching { + dateFormat.parse(this!!)!!.time + }.getOrDefault(0L) + } + + override fun pageListParse(document: Document): List { + return document.select(".reading-detail img:not(noscript img)").mapIndexed { i, img -> + Page(i, "", img.imgAttr()) + } + } + + private fun Element.imgAttr(): String? { + return when { + hasAttr("data-cfsrc") -> attr("abs:data-cfsrc") + hasAttr("data-src") -> attr("abs:data-src") + hasAttr("data-lazy-src") -> attr("abs:data-lazy-src") + hasAttr("srcset") -> attr("abs:srcset").substringBefore(" ") + else -> attr("abs:src") + } + } + + override fun imageUrlParse(document: Document) = throw UnsupportedOperationException("Not Used") + + companion object { + val dateFormat by lazy { + SimpleDateFormat("MMMM dd, yyyy", Locale.ENGLISH) + } + } +} diff --git a/src/en/likemanga/src/eu/kanade/tachiyomi/extension/en/likemanga/LikeMangaFilters.kt b/src/en/likemanga/src/eu/kanade/tachiyomi/extension/en/likemanga/LikeMangaFilters.kt new file mode 100644 index 000000000..21946c782 --- /dev/null +++ b/src/en/likemanga/src/eu/kanade/tachiyomi/extension/en/likemanga/LikeMangaFilters.kt @@ -0,0 +1,71 @@ +package eu.kanade.tachiyomi.extension.en.likemanga + +import eu.kanade.tachiyomi.source.model.Filter + +abstract class SelectFilter( + name: String, + private val options: List>, + defaultValue: String? = null, +) : Filter.Select( + name, + options.map { it.first }.toTypedArray(), + options.indexOfFirst { it.second == defaultValue }.takeIf { it != -1 } ?: 0, +) { + val selected get() = options[state].second.takeUnless { it.isEmpty() } +} + +class CheckBoxFilter( + name: String, + val value: String, +) : Filter.CheckBox(name) + +class GenreFilter( + name: String, + genres: List>, +) : Filter.Group( + name, + genres.map { CheckBoxFilter(it.first, it.second) }, +) { + val checked get() = state.filter { it.state }.map { it.value }.takeUnless { it.isEmpty() } +} + +class SortFilter(default: String? = null) : SelectFilter( + "Sort By", + listOf( + Pair("", ""), + Pair("Lasted update", "lastest-chap"), + Pair("Lasted manga", "lastest-manga"), + Pair("Top all", "top-manga"), + Pair("Top month", "top-month"), + Pair("Top week", "top-week"), + Pair("Top day", "top-day"), + Pair("Follow", "follow"), + Pair("Comments", "comment"), + Pair("Number of Chapters", "num-chap"), + ), + default, +) + +class StatusFilter : SelectFilter( + "Status", + listOf( + Pair("All", ""), + Pair("Complete", "Complete"), + Pair("In process", "In process"), + Pair("Pause", "Pause"), + ), +) + +class ChapterCountFilter : SelectFilter( + "Number of Chapters", + listOf( + Pair("", ""), + Pair(">= 0 chapter", "1"), + Pair(">= 50 chapter", "50"), + Pair(">= 100 chapter", "100"), + Pair(">= 200 chapter", "200"), + Pair(">= 300 chapter", "300"), + Pair(">= 400 chapter", "400"), + Pair(">= 500 chapter", "500"), + ), +)