diff --git a/src/ar/hentaislayer/build.gradle b/src/ar/hentaislayer/build.gradle new file mode 100644 index 000000000..c6734a648 --- /dev/null +++ b/src/ar/hentaislayer/build.gradle @@ -0,0 +1,8 @@ +ext { + extName = 'Hentai Slayer' + extClass = '.HentaiSlayer' + extVersionCode = 1 + isNsfw = true +} + +apply from: "$rootDir/common.gradle" diff --git a/src/ar/hentaislayer/res/mipmap-hdpi/ic_launcher.png b/src/ar/hentaislayer/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..d82029293 Binary files /dev/null and b/src/ar/hentaislayer/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/ar/hentaislayer/res/mipmap-mdpi/ic_launcher.png b/src/ar/hentaislayer/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..17b5278eb Binary files /dev/null and b/src/ar/hentaislayer/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/ar/hentaislayer/res/mipmap-xhdpi/ic_launcher.png b/src/ar/hentaislayer/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..22787c535 Binary files /dev/null and b/src/ar/hentaislayer/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/ar/hentaislayer/res/mipmap-xxhdpi/ic_launcher.png b/src/ar/hentaislayer/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..083ca374a Binary files /dev/null and b/src/ar/hentaislayer/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/ar/hentaislayer/res/mipmap-xxxhdpi/ic_launcher.png b/src/ar/hentaislayer/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..cbc3969ae Binary files /dev/null and b/src/ar/hentaislayer/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/ar/hentaislayer/src/eu/kanade/tachiyomi/extension/ar/hentaislayer/HentaiSlayer.kt b/src/ar/hentaislayer/src/eu/kanade/tachiyomi/extension/ar/hentaislayer/HentaiSlayer.kt new file mode 100644 index 000000000..00c0bb593 --- /dev/null +++ b/src/ar/hentaislayer/src/eu/kanade/tachiyomi/extension/ar/hentaislayer/HentaiSlayer.kt @@ -0,0 +1,241 @@ +package eu.kanade.tachiyomi.extension.ar.hentaislayer + +import android.app.Application +import android.widget.Toast +import androidx.preference.ListPreference +import androidx.preference.PreferenceScreen +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.interceptor.rateLimit +import eu.kanade.tachiyomi.source.ConfigurableSource +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 uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get +import java.util.Calendar + +class HentaiSlayer : ParsedHttpSource(), ConfigurableSource { + + override val name = "هنتاي سلاير" + + override val baseUrl = "https://hentaislayer.net" + + override val lang = "ar" + + override val supportsLatest = true + + override val client = network.cloudflareClient.newBuilder() + .rateLimit(2) + .build() + + override fun headersBuilder() = super.headersBuilder() + .set("Referer", "$baseUrl/") + .set("Origin", baseUrl) + + private val preferences by lazy { + Injekt.get().getSharedPreferences("source_$id", 0x0000) + } + + // ============================== Popular =============================== + override fun popularMangaRequest(page: Int) = GET("$baseUrl/manga?page=$page", headers) + + override fun popularMangaSelector() = "div > div:has(div#card-real)" + + override fun popularMangaFromElement(element: Element) = SManga.create().apply { + element.selectFirst("div#card-real a")?.run { + setUrlWithoutDomain(absUrl("href")) + selectFirst("figure")?.run { + selectFirst("img.object-cover")?.run { + thumbnail_url = imgAttr() + title = attr("alt") + } + genre = select("span p.drop-shadow-sm").text() + } + } + } + + override fun popularMangaNextPageSelector() = "ul.pagination > li:last-child:not(.pagination-disabled)" + + // =============================== Latest =============================== + override fun latestUpdatesRequest(page: Int) = GET("$baseUrl/latest-${getLatestTypes()}?page=$page", headers) + + override fun latestUpdatesSelector() = popularMangaSelector() + + override fun latestUpdatesFromElement(element: Element) = popularMangaFromElement(element) + + override fun latestUpdatesNextPageSelector() = popularMangaNextPageSelector() + + // =============================== Search =============================== + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + val url = "$baseUrl/manga?title=$query".toHttpUrl().newBuilder() + filters.forEach { filter -> + when (filter) { + is TypeFilter -> url.addQueryParameter("type", filter.toUriPart()) + is StatusFilter -> url.addQueryParameter("status", filter.toUriPart()) + is GenresFilter -> + filter.state + .filter { it.state } + .forEach { url.addQueryParameter("genre[]", it.uriPart) } + else -> {} + } + } + + url.addQueryParameter("page", page.toString()) + return GET(url.build(), headers) + } + + override fun searchMangaSelector() = popularMangaSelector() + + override fun searchMangaFromElement(element: Element) = popularMangaFromElement(element) + + override fun searchMangaNextPageSelector() = popularMangaNextPageSelector() + + // =========================== Manga Details ============================ + override fun mangaDetailsParse(document: Document) = SManga.create().apply { + document.selectFirst("main section")?.run { + selectFirst("img#manga-cover")?.run { + thumbnail_url = imgAttr() + title = attr("alt") + } + selectFirst("section > div:nth-child(1) > div:nth-child(1) > div:nth-child(2) > div:nth-child(2)")?.run { + status = parseStatus(select("a[href*='?status=']").text()) + genre = select("a[href*='?type=']").text() + author = select("p:has(span:contains(المؤلف)) span:nth-child(2)").text() + artist = select("p:has(span:contains(الرسام)) span:nth-child(2)").text() + } + selectFirst("section > div:nth-child(1) > div:nth-child(2)")?.run { + select("h1").text().takeIf { it.isNotEmpty() }?.let { + title = it + } + genre = select("a[href*='?genre=']") + .map { it.text() } + .let { + listOf(genre) + it + } + .joinToString() + select("h2").text().takeIf { it.isNotEmpty() }?.let { + description = "Alternative name: $it\n" + } + } + description += select("#description").text() + } + } + + private fun parseStatus(status: String) = when { + status.contains("مستمر") -> SManga.ONGOING + status.contains("متوقف") -> SManga.CANCELLED + status.contains("مكتمل") -> SManga.COMPLETED + else -> SManga.UNKNOWN + } + + // ============================== Chapters ============================== + override fun chapterListSelector() = "main section #chapters-list a#chapter-item" + + override fun chapterFromElement(element: Element) = SChapter.create().apply { + setUrlWithoutDomain(element.attr("href")) + name = "\u061C" + element.select("#item-title").text() // Add unicode ARABIC LETTER MARK to ensure all titles are right to left + + date_upload = parseRelativeDate(element.select("#item-title + span").text()) ?: 0L + } + + /** + * Parses dates in this form: + * `11 days ago` + */ + private fun parseRelativeDate(date: String): Long? { + val trimmedDate = date.split(" ") + + if (trimmedDate[2] != "ago") return null + + val number = trimmedDate[0].toIntOrNull() ?: return null + val unit = trimmedDate[1].removeSuffix("s") // Remove 's' suffix + + val now = Calendar.getInstance() + + // Map English unit to Java unit + val javaUnit = when (unit) { + "year", "yr" -> Calendar.YEAR + "month" -> Calendar.MONTH + "week", "wk" -> Calendar.WEEK_OF_MONTH + "day" -> Calendar.DAY_OF_MONTH + "hour", "hr" -> Calendar.HOUR + "minute", "min" -> Calendar.MINUTE + "second", "sec" -> Calendar.SECOND + else -> return null + } + + now.add(javaUnit, -number) + + return now.timeInMillis + } + + // =============================== Pages ================================ + override fun pageListParse(document: Document): List { + return document.select("img.chapter-image").mapIndexed { index, item -> + Page(index = index, imageUrl = item.imgAttr()) + } + } + + override fun imageUrlParse(document: Document): String = throw UnsupportedOperationException() + + private fun Element.imgAttr(): String? { + return when { + hasAttr("srcset") -> attr("abs:srcset").substringBefore(" ") + hasAttr("data-cfsrc") -> attr("abs:data-cfsrc") + hasAttr("data-src") -> attr("abs:data-src") + hasAttr("data-lazy-src") -> attr("abs:data-lazy-src") + else -> attr("abs:src") + } + } + + override fun getFilterList() = FilterList( + GenresFilter(), + TypeFilter(), + StatusFilter(), + ) + + // ============================== Settings ============================== + companion object { + private const val LATEST_PREF = "LatestType" + private val LATEST_PREF_ENTRIES get() = arrayOf( + "مانجا", + "مانهوا", + "كوميكس", + ) + private val LATEST_PREF_ENTRY_VALUES get() = arrayOf( + "manga", + "manhwa", + "comics", + ) + private val LATEST_PREF_DEFAULT = LATEST_PREF_ENTRY_VALUES[0] + } + + override fun setupPreferenceScreen(screen: PreferenceScreen) { + ListPreference(screen.context).apply { + key = LATEST_PREF + title = "نوع القائمة الأحدث" + summary = "حدد نوع الإدخالات التي سيتم الاستعلام عنها لأحدث قائمة. الأنواع الأخرى متوفرة في الشائع/التصفح أو البحث" + entries = LATEST_PREF_ENTRIES + entryValues = LATEST_PREF_ENTRY_VALUES + setDefaultValue(LATEST_PREF_DEFAULT) + summary = "%s" + + setOnPreferenceChangeListener { _, _ -> + Toast.makeText( + screen.context, + ".لتطبيق الإعدادات الجديدة Tachiyomi أعد تشغيل", + Toast.LENGTH_LONG, + ).show() + true + } + }.also(screen::addPreference) + } + + private fun getLatestTypes(): String = preferences.getString(LATEST_PREF, LATEST_PREF_DEFAULT)!! +} diff --git a/src/ar/hentaislayer/src/eu/kanade/tachiyomi/extension/ar/hentaislayer/HentaiSlayerFilters.kt b/src/ar/hentaislayer/src/eu/kanade/tachiyomi/extension/ar/hentaislayer/HentaiSlayerFilters.kt new file mode 100644 index 000000000..4025d028b --- /dev/null +++ b/src/ar/hentaislayer/src/eu/kanade/tachiyomi/extension/ar/hentaislayer/HentaiSlayerFilters.kt @@ -0,0 +1,81 @@ +package eu.kanade.tachiyomi.extension.ar.hentaislayer + +import eu.kanade.tachiyomi.source.model.Filter + +class StatusFilter : UriPartFilter( + "الحالة", + arrayOf( + Pair("الكل", ""), + Pair("مستمر", "مستمر"), + Pair("متوقف", "متوقف"), + Pair("مكتمل", "مكتمل"), + ), +) + +class TypeFilter : UriPartFilter( + "النوع", + arrayOf( + Pair("الكل", ""), + Pair("مانجا", "مانجا"), + Pair("مانهوا", "مانهوا"), + Pair("كوميكس", "كوميكس"), + ), +) + +private val genres = listOf( + Genre("أكشن", "أكشن"), + Genre("ألعاب جنسية", "ألعاب جنسية"), + Genre("إذلال", "إذلال"), + Genre("إيلف", "إيلف"), + Genre("ابتزاز", "ابتزاز"), + Genre("استعباد", "استعباد"), + Genre("اغتصاب", "اغتصاب"), + Genre("بدون حجب", "بدون حجب"), + Genre("بشرة سمراء", "بشرة سمراء"), + Genre("تاريخي", "تاريخي"), + Genre("تحكم بالعقل", "تحكم بالعقل"), + Genre("تراب", "تراب"), + Genre("تسوندري", "تسوندري"), + Genre("تصوير", "تصوير"), + Genre("جنس بالقدم", "جنس بالقدم"), + Genre("جنس جماعي", "جنس جماعي"), + Genre("جنس شرجي", "جنس شرجي"), + Genre("حريم", "حريم"), + Genre("حمل", "حمل"), + Genre("خادمة", "خادمة"), + Genre("خيال", "خيال"), + Genre("خيانة", "خيانة"), + Genre("دراغون بول", "دراغون بول"), + Genre("دراما", "دراما"), + Genre("رومانسي", "رومانسي"), + Genre("سحر", "سحر"), + Genre("شوتا", "شوتا"), + Genre("شيطانة", "شيطانة"), + Genre("شيميل", "شيميل"), + Genre("طالبة مدرسة", "طالبة مدرسة"), + Genre("عمة", "عمة"), + Genre("فوتا", "فوتا"), + Genre("لولي", "لولي"), + Genre("محارم", "محارم"), + Genre("مدرسي", "مدرسي"), + Genre("مكان عام", "مكان عام"), + Genre("ملون", "ملون"), + Genre("ميلف", "ميلف"), + Genre("ناروتو", "ناروتو"), + Genre("هجوم العمالقة", "هجوم العمالقة"), + Genre("ون بيس", "ون بيس"), + Genre("ياوي", "ياوي"), + Genre("يوري", "يوري"), +) + +class Genre(val name: String, val uriPart: String) + +class GenreCheckBox(name: String, val uriPart: String) : Filter.CheckBox(name) + +class GenresFilter : + Filter.Group("التصنيفات", genres.map { GenreCheckBox(it.name, it.uriPart) }) + +open class UriPartFilter(displayName: String, private val pairs: Array>) : + Filter.Select(displayName, pairs.map { it.first }.toTypedArray()) { + fun toUriPart() = pairs[state].second +}