diff --git a/lib-multisrc/gmanga/build.gradle.kts b/lib-multisrc/gmanga/build.gradle.kts new file mode 100644 index 000000000..dc076cc37 --- /dev/null +++ b/lib-multisrc/gmanga/build.gradle.kts @@ -0,0 +1,5 @@ +plugins { + id("lib-multisrc") +} + +baseVersionCode = 1 diff --git a/src/ar/gmanga/src/eu/kanade/tachiyomi/extension/ar/gmanga/GmangaCryptoUtils.kt b/lib-multisrc/gmanga/src/eu/kanade/tachiyomi/multisrc/gmanga/CryptoUtils.kt similarity index 81% rename from src/ar/gmanga/src/eu/kanade/tachiyomi/extension/ar/gmanga/GmangaCryptoUtils.kt rename to lib-multisrc/gmanga/src/eu/kanade/tachiyomi/multisrc/gmanga/CryptoUtils.kt index ad20bf9f8..70642e109 100644 --- a/src/ar/gmanga/src/eu/kanade/tachiyomi/extension/ar/gmanga/GmangaCryptoUtils.kt +++ b/lib-multisrc/gmanga/src/eu/kanade/tachiyomi/multisrc/gmanga/CryptoUtils.kt @@ -1,4 +1,4 @@ -package eu.kanade.tachiyomi.extension.ar.gmanga +package eu.kanade.tachiyomi.multisrc.gmanga import android.util.Base64 import java.security.MessageDigest @@ -14,7 +14,7 @@ fun decrypt(responseData: String): String { } private fun String.hexStringToByteArray(): ByteArray { - val len = this.length + val len = length val data = ByteArray(len / 2) var i = 0 while (i < len) { @@ -30,8 +30,8 @@ private fun String.hexStringToByteArray(): ByteArray { private fun String.sha256(): String { return MessageDigest .getInstance("SHA-256") - .digest(this.toByteArray()) - .fold("", { str, it -> str + "%02x".format(it) }) + .digest(toByteArray()) + .fold("") { str, it -> str + "%02x".format(it) } } private fun String.aesDecrypt(secretKey: ByteArray, ivString: String): String { @@ -40,6 +40,6 @@ private fun String.aesDecrypt(secretKey: ByteArray, ivString: String): String { val iv = IvParameterSpec(Base64.decode(ivString.toByteArray(Charsets.UTF_8), Base64.DEFAULT)) c.init(Cipher.DECRYPT_MODE, sk, iv) - val byteStr = Base64.decode(this.toByteArray(Charsets.UTF_8), Base64.DEFAULT) + val byteStr = Base64.decode(toByteArray(Charsets.UTF_8), Base64.DEFAULT) return String(c.doFinal(byteStr)) } diff --git a/lib-multisrc/gmanga/src/eu/kanade/tachiyomi/multisrc/gmanga/Dto.kt b/lib-multisrc/gmanga/src/eu/kanade/tachiyomi/multisrc/gmanga/Dto.kt new file mode 100644 index 000000000..3a0943c62 --- /dev/null +++ b/lib-multisrc/gmanga/src/eu/kanade/tachiyomi/multisrc/gmanga/Dto.kt @@ -0,0 +1,147 @@ +package eu.kanade.tachiyomi.multisrc.gmanga + +import eu.kanade.tachiyomi.source.model.SManga +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +class EncryptedResponse(val data: String) + +@Serializable +class MangaDataAction(val mangaDataAction: T) + +@Serializable +class LatestChaptersDto( + val releases: List, +) + +@Serializable +class LatestReleaseDto( + val manga: BrowseManga, +) + +@Serializable +class SearchMangaDto( + val mangas: List, +) + +@Serializable +class BrowseManga( + private val id: Int, + private val title: String, + private val cover: String, +) { + fun toSManga(createThumbnail: (String, String) -> String) = SManga.create().apply { + url = "/mangas/$id" + title = this@BrowseManga.title + thumbnail_url = createThumbnail(id.toString(), cover) + } +} + +@Serializable +class FiltersDto( + val categoryTypes: List? = null, + val categories: List? = null, +) + +@Serializable +class FilterDto( + val name: String, + val id: Int, +) + +@Serializable +class MangaDetailsDto( + val mangaData: Manga, +) + +@Serializable +class Manga( + private val id: Int, + private val cover: String, + private val title: String, + private val summary: String? = null, + private val artists: List, + private val authors: List, + @SerialName("story_status") private val status: Int, + private val type: TypeDto, + private val categories: List, + @SerialName("translation_status") private val tlStatus: Int, + private val synonyms: String? = null, + @SerialName("arabic_title") private val arTitle: String? = null, + @SerialName("japanese") private val jpTitle: String? = null, + @SerialName("english") private val enTitle: String? = null, +) { + fun toSManga(createThumbnail: (String, String) -> String) = SManga.create().apply { + title = this@Manga.title + thumbnail_url = createThumbnail(id.toString(), cover) + artist = artists.joinToString { it.name } + author = authors.joinToString { it.name } + status = when (this@Manga.status) { + 2 -> SManga.ONGOING + 3 -> SManga.COMPLETED + else -> SManga.UNKNOWN + } + genre = buildList { + add(type.title) + add(type.name) + categories.forEach { add(it.name) } + }.joinToString() + description = buildString { + summary.orEmpty() + .ifEmpty { "لم يتم اضافة قصة بعد" } + .also { append(it) } + + when (tlStatus) { + 0 -> "منتهية" + 1 -> "مستمرة" + 2 -> "متوقفة" + else -> "مجهول" + }.also { + append("\n\n") + append("حالة الترجمة") + append(":\n• ") + append(it) + } + + val titles = listOfNotNull(synonyms, arTitle, jpTitle, enTitle) + if (titles.isNotEmpty()) { + append("\n\n") + append("مسميّات أخرى") + append(":\n• ") + append(titles.joinToString("\n• ")) + } + } + } +} + +@Serializable +class NameDto(val name: String) + +@Serializable +class TypeDto( + val name: String, + val title: String, +) + +@Serializable +class ReaderDto( + val readerDataAction: ReaderData, +) + +@Serializable +class ReaderData( + val readerData: ReaderChapter, +) + +@Serializable +class ReaderChapter( + val release: ReaderPages, +) + +@Serializable +class ReaderPages( + @SerialName("webp_pages") val webpPages: List, + val pages: List, + @SerialName("storage_key") val key: String, +) diff --git a/lib-multisrc/gmanga/src/eu/kanade/tachiyomi/multisrc/gmanga/Filters.kt b/lib-multisrc/gmanga/src/eu/kanade/tachiyomi/multisrc/gmanga/Filters.kt new file mode 100644 index 000000000..0dbc06204 --- /dev/null +++ b/lib-multisrc/gmanga/src/eu/kanade/tachiyomi/multisrc/gmanga/Filters.kt @@ -0,0 +1,94 @@ +package eu.kanade.tachiyomi.multisrc.gmanga + +import eu.kanade.tachiyomi.source.model.Filter +import java.text.ParseException +import java.text.SimpleDateFormat +import java.util.Locale + +class TagFilterData( + private val id: String, + private val name: String, + private val state: Int = Filter.TriState.STATE_IGNORE, +) { + fun toTagFilter() = TagFilter(id, name, state) +} + +class TagFilter( + val id: String, + name: String, + state: Int = STATE_IGNORE, +) : Filter.TriState(name, state) + +abstract class ValidatingTextFilter(name: String) : Filter.Text(name) { + abstract fun isValid(): Boolean +} + +private val DATE_FITLER_FORMAT = SimpleDateFormat("yyyy/MM/dd", Locale.ENGLISH).apply { + isLenient = false +} + +private fun SimpleDateFormat.isValid(date: String): Boolean { + return try { + parse(date) + true + } catch (e: ParseException) { + false + } +} + +class DateFilter(val id: String, name: String) : ValidatingTextFilter("(yyyy/MM/dd) $name)") { + override fun isValid(): Boolean = DATE_FITLER_FORMAT.isValid(state) +} + +class IntFilter(val id: String, name: String) : ValidatingTextFilter(name) { + override fun isValid(): Boolean = state.toIntOrNull() != null +} + +class MangaTypeFilter(types: List) : Filter.Group( + "الأصل", + types.map { it.toTagFilter() }, +) + +class OneShotFilter : Filter.Group( + "ونشوت؟", + listOf( + TagFilter("oneshot", "نعم", TriState.STATE_EXCLUDE), + ), +) + +class StoryStatusFilter(status: List) : Filter.Group( + "حالة القصة", + status.map { it.toTagFilter() }, +) + +class TranslationStatusFilter(tlStatus: List) : Filter.Group( + "حالة الترجمة", + tlStatus.map { it.toTagFilter() }, +) + +class ChapterCountFilter : Filter.Group( + "عدد الفصول", + listOf( + IntFilter("min", "على الأقل"), + IntFilter("max", "على الأكثر"), + ), +) { + val min get() = state.first { it.id == "min" } + val max get() = state.first { it.id == "max" } +} + +class DateRangeFilter : Filter.Group( + "تاريخ النشر", + listOf( + DateFilter("start", "تاريخ النشر"), + DateFilter("end", "تاريخ الإنتهاء"), + ), +) { + val start get() = state.first { it.id == "start" } + val end get() = state.first { it.id == "end" } +} + +class CategoryFilter(categories: List) : Filter.Group( + "التصنيفات", + categories.map { it.toTagFilter() }, +) diff --git a/lib-multisrc/gmanga/src/eu/kanade/tachiyomi/multisrc/gmanga/Gmanga.kt b/lib-multisrc/gmanga/src/eu/kanade/tachiyomi/multisrc/gmanga/Gmanga.kt new file mode 100644 index 000000000..77370ff80 --- /dev/null +++ b/lib-multisrc/gmanga/src/eu/kanade/tachiyomi/multisrc/gmanga/Gmanga.kt @@ -0,0 +1,297 @@ +package eu.kanade.tachiyomi.multisrc.gmanga + +import android.util.Log +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.POST +import eu.kanade.tachiyomi.network.await +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.HttpSource +import eu.kanade.tachiyomi.util.asJsoup +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import uy.kohesive.injekt.injectLazy + +abstract class Gmanga( + override val name: String, + override val baseUrl: String, + final override val lang: String, + protected val cdnUrl: String = baseUrl, +) : HttpSource() { + + override val supportsLatest = true + + protected val json: Json by injectLazy() + + override val client = network.cloudflareClient + + override fun headersBuilder() = super.headersBuilder() + .set("Referer", "$baseUrl/") + + override fun popularMangaRequest(page: Int) = searchMangaRequest(page, "", getFilterList()) + override fun popularMangaParse(response: Response) = searchMangaParse(response) + + override fun latestUpdatesRequest(page: Int): Request { + return GET("$baseUrl/api/releases?page=$page", headers) + } + + override fun latestUpdatesParse(response: Response): MangasPage { + val releases = response.parseAs().releases + + val entries = releases.map { it.manga.toSManga(::createThumbnail) } + .distinctBy { it.url } + + return MangasPage( + entries, + hasNextPage = (releases.size >= 30), + ) + } + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + val filterList = if (filters.isEmpty()) getFilterList() else filters + + val mangaTypeFilter = filterList.findInstance()!! + val oneShotFilter = filterList.findInstance()!! + val storyStatusFilter = filterList.findInstance()!! + val translationStatusFilter = filterList.findInstance()!! + val chapterCountFilter = filterList.findInstance()!! + val dateRangeFilter = filterList.findInstance()!! + val categoryFilter = filterList.findInstance() ?: CategoryFilter(emptyList()) + + val body = SearchPayload( + oneshot = OneShot( + value = oneShotFilter.state.first().run { + when { + isIncluded() -> true + else -> false + } + }, + ), + title = query, + page = page, + mangaTypes = IncludeExclude( + include = mangaTypeFilter.state.filter { it.isIncluded() }.map { it.id }, + exclude = mangaTypeFilter.state.filter { it.isExcluded() }.map { it.id }, + ), + storyStatus = IncludeExclude( + include = storyStatusFilter.state.filter { it.isIncluded() }.map { it.id }, + exclude = storyStatusFilter.state.filter { it.isExcluded() }.map { it.id }, + ), + tlStatus = IncludeExclude( + include = translationStatusFilter.state.filter { it.isIncluded() }.map { it.id }, + exclude = translationStatusFilter.state.filter { it.isExcluded() }.map { it.id }, + ), + categories = IncludeExclude( + // always include null, maybe to avoid shifting index in the backend + include = listOf(null) + categoryFilter.state.filter { it.isIncluded() }.map { it.id }, + exclude = categoryFilter.state.filter { it.isExcluded() }.map { it.id }, + ), + chapters = MinMax( + min = chapterCountFilter.min.run { + when { + state == "" -> "" + isValid() -> state + else -> throw Exception("الحد الأدنى لعدد الفصول غير صالح") + } + }, + max = chapterCountFilter.max.run { + when { + state == "" -> "" + isValid() -> state + else -> throw Exception("الحد الأقصى لعدد الفصول غير صالح") + } + }, + ), + dates = StartEnd( + start = dateRangeFilter.start.run { + when { + state == "" -> "" + isValid() -> state + else -> throw Exception("تاريخ بداية غير صالح") + } + }, + end = dateRangeFilter.end.run { + when { + state == "" -> "" + isValid() -> state + else -> throw Exception("تاريخ نهاية غير صالح") + } + }, + ), + ).let(json::encodeToString).toRequestBody(MEDIA_TYPE) + + return POST("$baseUrl/api/mangas/search", headers, body) + } + + private var categories: List = emptyList() + private var filtersState = FilterState.Unfetched + private var filterAttempts = 0 + + private enum class FilterState { + Fetching, Fetched, Unfetched + } + + private suspend fun fetchFilters() { + if (filtersState == FilterState.Unfetched && filterAttempts < 3) { + filtersState = FilterState.Fetching + filterAttempts++ + + try { + categories = client.newCall(GET("$baseUrl/mangas/", headers)) + .await() + .asJsoup() + .select(".js-react-on-rails-component").html() + .parseAs() + .run { + categories ?: categoryTypes!!.flatMap { it.categories!! } + } + .map { TagFilterData(it.id.toString(), it.name) } + + filtersState = FilterState.Fetched + } catch (e: Exception) { + Log.e(name, e.stackTraceToString()) + filtersState = FilterState.Unfetched + } + } + } + + protected open fun getTypesFilter() = listOf( + TagFilterData("1", "يابانية", Filter.TriState.STATE_INCLUDE), + TagFilterData("2", "كورية", Filter.TriState.STATE_INCLUDE), + TagFilterData("3", "صينية", Filter.TriState.STATE_INCLUDE), + TagFilterData("4", "عربية", Filter.TriState.STATE_INCLUDE), + TagFilterData("5", "كوميك", Filter.TriState.STATE_INCLUDE), + TagFilterData("6", "هواة", Filter.TriState.STATE_INCLUDE), + TagFilterData("7", "إندونيسية", Filter.TriState.STATE_INCLUDE), + TagFilterData("8", "روسية", Filter.TriState.STATE_INCLUDE), + ) + + protected open fun getStatusFilter() = listOf( + TagFilterData("2", "مستمرة"), + TagFilterData("3", "منتهية"), + ) + + protected open fun getTranslationFilter() = listOf( + TagFilterData("0", "منتهية"), + TagFilterData("1", "مستمرة"), + TagFilterData("2", "متوقفة"), + TagFilterData("3", "غير مترجمة", Filter.TriState.STATE_EXCLUDE), + ) + + override fun getFilterList(): FilterList { + CoroutineScope(Dispatchers.IO).launch { fetchFilters() } + + val filters = mutableListOf>( + MangaTypeFilter(getTypesFilter()), + OneShotFilter(), + StoryStatusFilter(getStatusFilter()), + TranslationStatusFilter(getTranslationFilter()), + ChapterCountFilter(), + DateRangeFilter(), + ) + + filters += if (filtersState == FilterState.Fetched) { + listOf( + CategoryFilter(categories), + ) + } else { + listOf( + Filter.Separator(), + Filter.Header("اضغط على\"إعادة تعيين\"لمحاولة تحميل التصنيفات"), + ) + } + + return FilterList(filters) + } + + override fun searchMangaParse(response: Response): MangasPage { + val data = response.decryptAs() + return MangasPage( + data.mangas.map { it.toSManga(::createThumbnail) }, + hasNextPage = data.mangas.size == 50, + ) + } + + override fun mangaDetailsParse(response: Response): SManga { + return response.asJsoup() + .select(".js-react-on-rails-component").html() + .parseAs>() + .mangaDataAction.mangaData + .toSManga(::createThumbnail) + } + + abstract fun chaptersRequest(manga: SManga): Request + abstract fun chaptersParse(response: Response): List + + final override fun chapterListRequest(manga: SManga) = chaptersRequest(manga) + final override fun chapterListParse(response: Response) = chaptersParse(response).sortChapters() + + private fun List.sortChapters() = + sortedWith( + compareBy( + { -it.chapter_number }, + { -it.date_upload }, + ), + ) + + override fun pageListParse(response: Response): List { + val data = response.asJsoup() + .select(".js-react-on-rails-component").html() + .parseAs() + .readerDataAction.readerData.release + + val hasWebP = data.webpPages.isNotEmpty() + + val (pages, directory) = when { + hasWebP -> data.webpPages to "hq_webp" + else -> data.pages to "hq" + } + + return pages.sortedWith(pageSort).mapIndexed { index, pageUri -> + Page( + index = index, + imageUrl = "$cdnUrl/uploads/releases/${data.key}/$directory/$pageUri", + ) + } + } + + private val pageSort = + compareBy({ parseNumber(0, it) ?: Double.MAX_VALUE }, { parseNumber(1, it) }, { parseNumber(2, it) }) + + private fun parseNumber(index: Int, string: String): Double? = + Regex("\\d+").findAll(string).map { it.value }.toList().getOrNull(index)?.toDoubleOrNull() + + protected inline fun Response.decryptAs(): T = + decrypt(parseAs().data).parseAs() + + protected inline fun Response.parseAs(): T = body.string().parseAs() + + protected inline fun String.parseAs(): T = json.decodeFromString(this) + + protected inline fun Iterable<*>.findInstance() = find { it is T } as? T + + protected open fun createThumbnail(mangaId: String, cover: String): String { + val thumbnail = "large_${cover.substringBeforeLast(".")}.webp" + + return "$cdnUrl/uploads/manga/cover/$mangaId/$thumbnail" + } + + override fun imageUrlParse(response: Response): String = + throw UnsupportedOperationException() + + companion object { + private val MEDIA_TYPE = "application/json; charset=utf-8".toMediaTypeOrNull() + } +} diff --git a/lib-multisrc/gmanga/src/eu/kanade/tachiyomi/multisrc/gmanga/PayLoadDto.kt b/lib-multisrc/gmanga/src/eu/kanade/tachiyomi/multisrc/gmanga/PayLoadDto.kt new file mode 100644 index 000000000..9a346ab8b --- /dev/null +++ b/lib-multisrc/gmanga/src/eu/kanade/tachiyomi/multisrc/gmanga/PayLoadDto.kt @@ -0,0 +1,40 @@ +package eu.kanade.tachiyomi.multisrc.gmanga + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +class SearchPayload( + private val oneshot: OneShot, + private val title: String, + private val page: Int, + @SerialName("manga_types") private val mangaTypes: IncludeExclude, + @SerialName("story_status") private val storyStatus: IncludeExclude, + @SerialName("translation_status") val tlStatus: IncludeExclude, + private val categories: IncludeExclude, + private val chapters: MinMax, + private val dates: StartEnd, +) + +@Serializable +class OneShot( + private val value: Boolean, +) + +@Serializable +class IncludeExclude( + private val include: List, + private val exclude: List, +) + +@Serializable +class MinMax( + private val min: String, + private val max: String, +) + +@Serializable +class StartEnd( + private val start: String, + private val end: String, +) diff --git a/src/ar/dilar/build.gradle b/src/ar/dilar/build.gradle new file mode 100644 index 000000000..8d9d35146 --- /dev/null +++ b/src/ar/dilar/build.gradle @@ -0,0 +1,8 @@ +ext { + extName = 'Dilar' + extClass = '.Dilar' + themePkg = 'gmanga' + overrideVersionCode = 0 +} + +apply from: "$rootDir/common.gradle" diff --git a/src/ar/dilar/res/mipmap-hdpi/ic_launcher.png b/src/ar/dilar/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..8a754798b Binary files /dev/null and b/src/ar/dilar/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/ar/dilar/res/mipmap-mdpi/ic_launcher.png b/src/ar/dilar/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..ab92db004 Binary files /dev/null and b/src/ar/dilar/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/ar/dilar/res/mipmap-xhdpi/ic_launcher.png b/src/ar/dilar/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..c42f39585 Binary files /dev/null and b/src/ar/dilar/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/ar/dilar/res/mipmap-xxhdpi/ic_launcher.png b/src/ar/dilar/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..84196fc06 Binary files /dev/null and b/src/ar/dilar/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/ar/dilar/res/mipmap-xxxhdpi/ic_launcher.png b/src/ar/dilar/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..c5333f03e Binary files /dev/null and b/src/ar/dilar/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/ar/dilar/src/eu/kanade/tachiyomi/extension/ar/dilar/Dilar.kt b/src/ar/dilar/src/eu/kanade/tachiyomi/extension/ar/dilar/Dilar.kt new file mode 100644 index 000000000..fe013a50d --- /dev/null +++ b/src/ar/dilar/src/eu/kanade/tachiyomi/extension/ar/dilar/Dilar.kt @@ -0,0 +1,26 @@ +package eu.kanade.tachiyomi.extension.ar.dilar + +import eu.kanade.tachiyomi.multisrc.gmanga.Gmanga +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.source.model.SChapter +import eu.kanade.tachiyomi.source.model.SManga +import okhttp3.Request +import okhttp3.Response + +class Dilar : Gmanga( + "Dilar", + "https://dilar.tube", + "ar", +) { + override fun chaptersRequest(manga: SManga): Request { + val mangaId = manga.url.substringAfterLast("/") + return GET("$baseUrl/api/mangas/$mangaId/releases", headers) + } + + override fun chaptersParse(response: Response): List { + val releases = response.parseAs().releases + .filterNot { it.isMonetized } + + return releases.map { it.toSChapter() } + } +} diff --git a/src/ar/dilar/src/eu/kanade/tachiyomi/extension/ar/dilar/Dto.kt b/src/ar/dilar/src/eu/kanade/tachiyomi/extension/ar/dilar/Dto.kt new file mode 100644 index 000000000..ef2bb5e7b --- /dev/null +++ b/src/ar/dilar/src/eu/kanade/tachiyomi/extension/ar/dilar/Dto.kt @@ -0,0 +1,36 @@ +package eu.kanade.tachiyomi.extension.ar.dilar + +import eu.kanade.tachiyomi.source.model.SChapter +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.float + +@Serializable +class ChapterListDto( + val releases: List, +) + +@Serializable +class ChapterRelease( + private val id: Int, + private val chapter: JsonPrimitive, + private val title: String, + @SerialName("team_name") private val teamName: String, + @SerialName("time_stamp") private val timestamp: Long, + + @SerialName("has_rev_link") private val hasRevLink: Boolean, + @SerialName("support_link") private val supportLink: String, +) { + val isMonetized get() = hasRevLink && supportLink.isNotEmpty() + + fun toSChapter() = SChapter.create().apply { + url = "/r/$id" + chapter_number = chapter.float + date_upload = timestamp * 1000 + scanlator = teamName + + val chapterName = title.let { if (it.trim() != "") " - $it" else "" } + name = "${chapter_number.let { if (it % 1 > 0) it else it.toInt() }}$chapterName" + } +} diff --git a/src/ar/gmanga/build.gradle b/src/ar/gmanga/build.gradle index b03647d68..be9a621e6 100644 --- a/src/ar/gmanga/build.gradle +++ b/src/ar/gmanga/build.gradle @@ -1,7 +1,8 @@ ext { extName = 'GMANGA' extClass = '.Gmanga' - extVersionCode = 13 + themePkg = 'gmanga' + overrideVersionCode = 13 } apply from: "$rootDir/common.gradle" diff --git a/src/ar/gmanga/src/eu/kanade/tachiyomi/extension/ar/gmanga/Dto.kt b/src/ar/gmanga/src/eu/kanade/tachiyomi/extension/ar/gmanga/Dto.kt new file mode 100644 index 000000000..5c2cd71f1 --- /dev/null +++ b/src/ar/gmanga/src/eu/kanade/tachiyomi/extension/ar/gmanga/Dto.kt @@ -0,0 +1,33 @@ +package eu.kanade.tachiyomi.extension.ar.gmanga + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonPrimitive + +@Serializable +class ChapterListResponse( + val releases: List, + val chapterizations: List, + val teams: List, +) + +@Serializable +class ChapterRelease( + val id: Int, + @SerialName("chapterization_id") val chapId: Int, + @SerialName("team_id") val teamId: Int, + val chapter: JsonPrimitive, + @SerialName("time_stamp") val timestamp: Long, +) + +@Serializable +class Chapterization( + val id: Int, + val title: String, +) + +@Serializable +class Team( + val id: Int, + val name: String, +) diff --git a/src/ar/gmanga/src/eu/kanade/tachiyomi/extension/ar/gmanga/Gmanga.kt b/src/ar/gmanga/src/eu/kanade/tachiyomi/extension/ar/gmanga/Gmanga.kt index 838e4bc2a..ad0d19bd9 100644 --- a/src/ar/gmanga/src/eu/kanade/tachiyomi/extension/ar/gmanga/Gmanga.kt +++ b/src/ar/gmanga/src/eu/kanade/tachiyomi/extension/ar/gmanga/Gmanga.kt @@ -1,315 +1,90 @@ package eu.kanade.tachiyomi.extension.ar.gmanga -import androidx.preference.PreferenceScreen -import eu.kanade.tachiyomi.extension.ar.gmanga.GmangaPreferences.Companion.PREF_CHAPTER_LISTING -import eu.kanade.tachiyomi.extension.ar.gmanga.GmangaPreferences.Companion.PREF_CHAPTER_LISTING_SHOW_ALL -import eu.kanade.tachiyomi.extension.ar.gmanga.GmangaPreferences.Companion.PREF_CHAPTER_LISTING_SHOW_POPULAR -import eu.kanade.tachiyomi.extension.ar.gmanga.GmangaPreferences.Companion.PREF_LASTETS_LISTING -import eu.kanade.tachiyomi.extension.ar.gmanga.GmangaPreferences.Companion.PREF_LASTETS_LISTING_SHOW_LASTETS_CHAPTER -import eu.kanade.tachiyomi.extension.ar.gmanga.GmangaPreferences.Companion.PREF_LASTETS_LISTING_SHOW_LASTETS_MANGA -import eu.kanade.tachiyomi.extension.ar.gmanga.dto.TableDto -import eu.kanade.tachiyomi.extension.ar.gmanga.dto.asChapterList +import android.app.Application +import eu.kanade.tachiyomi.multisrc.gmanga.BrowseManga +import eu.kanade.tachiyomi.multisrc.gmanga.Gmanga import eu.kanade.tachiyomi.network.GET -import eu.kanade.tachiyomi.network.POST +import eu.kanade.tachiyomi.network.asObservable 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.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.HttpSource -import eu.kanade.tachiyomi.util.asJsoup -import kotlinx.serialization.decodeFromString -import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.buildJsonArray -import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.float import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive -import okhttp3.Headers -import okhttp3.MediaType.Companion.toMediaTypeOrNull -import okhttp3.OkHttpClient import okhttp3.Request -import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response -import uy.kohesive.injekt.injectLazy -import java.text.SimpleDateFormat -import java.util.Locale +import rx.Observable +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get -class Gmanga : ConfigurableSource, HttpSource() { - - private val domain: String = "gmanga.org" - - override val baseUrl: String = "https://$domain" - - override val lang: String = "ar" - - override val name: String = "GMANGA" - - override val supportsLatest: Boolean = true - - private val json: Json by injectLazy() - - private val preferences = GmangaPreferences(id) - - override val client: OkHttpClient = network.client.newBuilder() +class Gmanga : Gmanga( + "GMANGA", + "https://gmanga.org", + "ar", + "https://media.gmanga.me", +) { + override val client = super.client.newBuilder() .rateLimit(4) .build() - private val parsedDatePattern: SimpleDateFormat = SimpleDateFormat( - "yyyy-MM-dd HH:mm:ss ZZZ zzz", - Locale.ENGLISH, - ) - private val formattedDatePattern: SimpleDateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.ENGLISH) - override fun headersBuilder() = Headers.Builder().apply { - add("User-Agent", USER_AGENT) + init { + // remove obsolete preferences + Injekt.get().getSharedPreferences("source_$id", 0x0000).run { + if (contains("gmanga_chapter_listing")) { + edit().remove("gmanga_chapter_listing").apply() + } + if (contains("gmanga_last_listing")) { + edit().remove("gmanga_last_listing").apply() + } + } } - override fun setupPreferenceScreen(screen: PreferenceScreen) = - preferences.setupPreferenceScreen(screen) - - override fun chapterListRequest(manga: SManga): Request { - val mangaId = manga.url.substringAfterLast("/") - return GET("$baseUrl/api/mangas/$mangaId/releases", headers) - } - - override fun chapterListParse(response: Response): List { - val data = decryptResponse(response) - - val table = json.decodeFromJsonElement(data) - val chapterList = table.asChapterList() - - val releases = when (preferences.getString(PREF_CHAPTER_LISTING)) { - PREF_CHAPTER_LISTING_SHOW_POPULAR -> - chapterList.releases - .groupBy { release -> release.chapterizationId } - .mapNotNull { (_, releases) -> releases.maxByOrNull { it.views } } - PREF_CHAPTER_LISTING_SHOW_ALL -> chapterList.releases - else -> emptyList() + override fun latestUpdatesParse(response: Response): MangasPage { + val decMga = response.decryptAs() + val selectedManga = decMga["rows"]!!.jsonArray[0].jsonObject["rows"]!!.jsonArray + val manags = selectedManga.map { + json.decodeFromJsonElement(it.jsonArray[17]) } - return releases.map { release -> - SChapter.create().apply { - val chapter = chapterList.chapters.first { it.id == release.chapterizationId } - val team = chapterList.teams.firstOrNull { it.id == release.teamId } + val entries = manags.map { it.toSManga(::createThumbnail) } + .distinctBy { it.url } - url = "/r/${release.id}" - chapter_number = chapter.chapter - date_upload = release.timestamp * 1000 + return MangasPage( + entries, + hasNextPage = (manags.size >= 30), + ) + } + + override fun fetchChapterList(manga: SManga): Observable> { + return client.newCall(chapterListRequest(manga)) + .asObservable() // sites returns false 302 code + .map(::chapterListParse) + } + + override fun chaptersRequest(manga: SManga): Request { + val mangaId = manga.url.substringAfterLast("/") + return GET("https://api2.gmanga.me/api/mangas/$mangaId/releases", headers) + } + + override fun chaptersParse(response: Response): List { + val chapterList = response.parseAs() + + return chapterList.releases.map { + SChapter.create().apply { + val chapter = chapterList.chapterizations.first { chap -> chap.id == it.chapId } + val team = chapterList.teams.firstOrNull { team -> team.id == it.teamId } + + url = "/r/${it.id}" + chapter_number = it.chapter.float + date_upload = it.timestamp * 1000 scanlator = team?.name val chapterName = chapter.title.let { if (it.trim() != "") " - $it" else "" } name = "${chapter_number.let { if (it % 1 > 0) it else it.toInt() }}$chapterName" } - }.sortedWith(compareBy({ -it.chapter_number }, { -it.date_upload })) - } - - override fun imageUrlParse(response: Response): String = - throw UnsupportedOperationException() - - override fun latestUpdatesParse(response: Response): MangasPage { - val isLatest = when (preferences.getString(PREF_LASTETS_LISTING)) { - PREF_LASTETS_LISTING_SHOW_LASTETS_MANGA -> true - PREF_LASTETS_LISTING_SHOW_LASTETS_CHAPTER -> false - else -> true } - - val mangas = if (!isLatest) { - val decMga = decryptResponse(response) - val selectedManga = decMga["rows"]!!.jsonArray[0].jsonObject["rows"]!!.jsonArray - buildJsonArray { - for (i in 0 until selectedManga.size) { - add(selectedManga[i].jsonArray[17]) - } - } - } else { - val data = json.decodeFromString( - response.asJsoup().select(".js-react-on-rails-component").html(), - ) - data["mangaDataAction"]!!.jsonObject["newMangas"]!!.jsonArray - } - return MangasPage( - mangas.jsonArray.map { - SManga.create().apply { - url = "/mangas/${it.jsonObject["id"]!!.jsonPrimitive.content}" - title = it.jsonObject["title"]!!.jsonPrimitive.content - val thumbnail = "medium_${ - it.jsonObject["cover"]!!.jsonPrimitive.content.substringBeforeLast(".") - }.webp" - thumbnail_url = - "https://media.gmanga.me/uploads/manga/cover/${it.jsonObject["id"]!!.jsonPrimitive.content}/$thumbnail" - } - }, - (mangas.size >= 30) && !isLatest, - ) - } - - override fun latestUpdatesRequest(page: Int): Request { - val latestUrl = when (preferences.getString(PREF_LASTETS_LISTING)) { - PREF_LASTETS_LISTING_SHOW_LASTETS_MANGA -> "$baseUrl/mangas/latest" - PREF_LASTETS_LISTING_SHOW_LASTETS_CHAPTER -> "https://api.gmanga.me/api/releases?page=$page" - else -> "$baseUrl/mangas/latest" - } - return GET(latestUrl, headers) - } - - override fun mangaDetailsParse(response: Response): SManga { - val altNamePrefix = "مسميّات أخرى" - val translationStatusPrefix = "حالة الترجمة" - val startedDayPrefix = "تاريخ النشر" - val endedDayPrefix = "تاريخ الانتهاء" - val data = json.decodeFromString( - response.asJsoup().select(".js-react-on-rails-component").html(), - ) - val mangaData = data["mangaDataAction"]!!.jsonObject["mangaData"]!!.jsonObject - return SManga.create().apply { - description = - mangaData["summary"]!!.jsonPrimitive.contentOrNull?.ifEmpty { "لم يتم اضافة قصة بعد" } - artist = - mangaData["artists"]!!.jsonArray.joinToString(", ") { it.jsonObject["name"]!!.jsonPrimitive.content } - author = - mangaData["authors"]!!.jsonArray.joinToString(", ") { it.jsonObject["name"]!!.jsonPrimitive.content } - status = parseStatus(mangaData["story_status"].toString()) - genre = listOfNotNull( - mangaData["type"]!!.jsonObject["title"]!!.jsonPrimitive.content, - mangaData["type"]!!.jsonObject["name"]!!.jsonPrimitive.content, - mangaData["categories"]!!.jsonArray.joinToString(", ") { it.jsonObject["name"]!!.jsonPrimitive.content }, - ).joinToString(", ") - - parseTranslationStatus(mangaData["translation_status"].toString()).let { - description = "$description\n\n:$translationStatusPrefix ᗏ \n$it •" - } - var startedDate = - mangaData["s_date"]!!.jsonPrimitive.content.takeIf { it.isBlank().not() } - startedDate = if (startedDate.isNullOrBlank().not()) { - parsedDatePattern.parse(startedDate!!)?.let { formattedDatePattern.format(it) } - } else { - null - } - var endedDay = mangaData["e_date"]!!.jsonPrimitive.content.takeIf { it.isBlank().not() } - endedDay = if (endedDay.isNullOrBlank().not()) { - parsedDatePattern.parse(endedDay!!)?.let { formattedDatePattern.format(it) } - } else { - null - } - - val alternativeName = listOfNotNull( - mangaData["synonyms"]!!.jsonPrimitive.content.takeIf { it.isBlank().not() }, - mangaData["arabic_title"]!!.jsonPrimitive.content.takeIf { it.isBlank().not() }, - mangaData["japanese"]!!.jsonPrimitive.content.takeIf { it.isBlank().not() }, - mangaData["english"]!!.jsonPrimitive.content.takeIf { it.isBlank().not() }, - ).joinToString("\n").trim() - - val additionalInformation = listOfNotNull( - startedDate, - endedDay, - alternativeName, - ) - additionalInformation.forEach { info -> - when (info) { - startedDate -> - description = - "$description\n\n:$startedDayPrefix ᗏ \n$startedDate •" - endedDay -> description = "$description\n\n:$endedDayPrefix ᗏ \n$endedDay •" - alternativeName -> - description = - "$description\n\n:$altNamePrefix ᗏ \n$alternativeName •" - else -> description - } - } - } - } - - private fun parseStatus(status: String?) = when { - status == null -> SManga.UNKNOWN - status.contains("2") -> SManga.ONGOING - status.contains("3") -> SManga.COMPLETED - else -> SManga.UNKNOWN - } - - private fun parseTranslationStatus(status: String?) = when { - status == null -> "مجهول" - status.contains("0") -> "منتهية" - status.contains("1") -> "مستمرة" - status.contains("2") -> "متوقفة" - else -> "مجهول" - } - - override fun pageListParse(response: Response): List { - val url = response.request.url.toString() - val data = json.decodeFromString( - response.asJsoup().select(".js-react-on-rails-component").html(), - ) - val releaseData = - data["readerDataAction"]!!.jsonObject["readerData"]!!.jsonObject["release"]!!.jsonObject - - val hasWebP = releaseData["webp_pages"]!!.jsonArray.size > 0 - return releaseData[if (hasWebP) "webp_pages" else "pages"]!!.jsonArray.map { it.jsonPrimitive.content } - .sortedWith(pageSort) - .mapIndexed { index, pageUri -> - Page( - index, - "$url#page_$index", - "https://media.gmanga.me/uploads/releases/${releaseData["storage_key"]!!.jsonPrimitive.content}/hq${if (hasWebP) "_webp" else ""}/$pageUri", - ) - } - } - - private val pageSort = - compareBy({ parseNumber(0, it) ?: Double.MAX_VALUE }, { parseNumber(1, it) }, { parseNumber(2, it) }) - - private fun parseNumber(index: Int, string: String): Double? = - Regex("\\d+").findAll(string).map { it.value }.toList().getOrNull(index)?.toDoubleOrNull() - - override fun popularMangaParse(response: Response) = searchMangaParse(response) - - override fun popularMangaRequest(page: Int) = searchMangaRequest(page, "", getFilterList()) - - override fun searchMangaParse(response: Response): MangasPage { - val data = decryptResponse(response) - val mangas = data["mangas"]!!.jsonArray - return MangasPage( - mangas.jsonArray.map { - SManga.create().apply { - url = "/mangas/${it.jsonObject["id"]!!.jsonPrimitive.content}" - title = it.jsonObject["title"]!!.jsonPrimitive.content - val thumbnail = "medium_${ - it.jsonObject["cover"]!!.jsonPrimitive.content.substringBeforeLast(".") - }.webp" - thumbnail_url = - "https://media.gmanga.me/uploads/manga/cover/${it.jsonObject["id"]!!.jsonPrimitive.content}/$thumbnail" - } - }, - mangas.size == 50, - ) - } - - private fun decryptResponse(response: Response): JsonObject { - val encryptedData = - json.decodeFromString(response.body.string())["data"]!!.jsonPrimitive.content - val decryptedData = decrypt(encryptedData) - return json.decodeFromString(decryptedData) - } - - override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { - return GmangaFilters.buildSearchPayload( - page, - query, - if (filters.isEmpty()) getFilterList() else filters, - ).let { - val body = it.toString().toRequestBody(MEDIA_TYPE) - POST("$baseUrl/api/mangas/search", headers, body) - } - } - - override fun getFilterList() = GmangaFilters.getFilterList() - - companion object { - private const val USER_AGENT = - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.122 Safari/537.36" - private val MEDIA_TYPE = "application/json; charset=utf-8".toMediaTypeOrNull() } } diff --git a/src/ar/gmanga/src/eu/kanade/tachiyomi/extension/ar/gmanga/GmangaFilters.kt b/src/ar/gmanga/src/eu/kanade/tachiyomi/extension/ar/gmanga/GmangaFilters.kt deleted file mode 100644 index 63983dcc5..000000000 --- a/src/ar/gmanga/src/eu/kanade/tachiyomi/extension/ar/gmanga/GmangaFilters.kt +++ /dev/null @@ -1,291 +0,0 @@ -package eu.kanade.tachiyomi.extension.ar.gmanga - -import android.annotation.SuppressLint -import eu.kanade.tachiyomi.source.model.Filter -import eu.kanade.tachiyomi.source.model.FilterList -import kotlinx.serialization.json.JsonNull -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonObjectBuilder -import kotlinx.serialization.json.add -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.put -import kotlinx.serialization.json.putJsonArray -import kotlinx.serialization.json.putJsonObject -import java.text.ParseException -import java.text.SimpleDateFormat - -class GmangaFilters() { - - companion object { - - fun getFilterList() = FilterList( - MangaTypeFilter(), - OneShotFilter(), - StoryStatusFilter(), - TranslationStatusFilter(), - ChapterCountFilter(), - DateRangeFilter(), - CategoryFilter(), - ) - - fun buildSearchPayload(page: Int, query: String = "", filters: FilterList): JsonObject { - val mangaTypeFilter = filters.findInstance()!! - val oneShotFilter = filters.findInstance()!! - val storyStatusFilter = filters.findInstance()!! - val translationStatusFilter = filters.findInstance()!! - val chapterCountFilter = filters.findInstance()!! - val dateRangeFilter = filters.findInstance()!! - val categoryFilter = filters.findInstance()!! - - return buildJsonObject { - oneShotFilter.state.first().let { - putJsonObject("oneshot") { - when { - it.isIncluded() -> put("value", true) - it.isExcluded() -> put("value", false) - else -> put("value", JsonNull) - } - } - } - - put("title", query) - put("page", page) - putJsonObject("manga_types") { - putJsonArray("include") { - mangaTypeFilter.state.filter { it.isIncluded() }.map { it.id }.forEach { add(it) } - } - - putJsonArray("exclude") { - mangaTypeFilter.state.filter { it.isExcluded() }.map { it.id }.forEach { add(it) } - } - } - putJsonObject("story_status") { - putJsonArray("include") { - storyStatusFilter.state.filter { it.isIncluded() }.map { it.id }.forEach { add(it) } - } - - putJsonArray("exclude") { - storyStatusFilter.state.filter { it.isExcluded() }.map { it.id }.forEach { add(it) } - } - } - putJsonObject("translation_status") { - putJsonArray("include") { - translationStatusFilter.state.filter { it.isIncluded() }.map { it.id }.forEach { add(it) } - } - - putJsonArray("exclude") { - translationStatusFilter.state.filter { it.isExcluded() }.map { it.id }.forEach { add(it) } - } - } - putJsonObject("categories") { - putJsonArray("include") { - add(JsonNull) // always included, maybe to avoid shifting index in the backend - categoryFilter.state.filter { it.isIncluded() }.map { it.id }.forEach { add(it) } - } - - putJsonArray("exclude") { - categoryFilter.state.filter { it.isExcluded() }.map { it.id }.forEach { add(it) } - } - } - putJsonObject("chapters") { - putFromValidatingTextFilter( - chapterCountFilter.state.first { - it.id == FILTER_ID_MIN_CHAPTER_COUNT - }, - "min", - ERROR_INVALID_MIN_CHAPTER_COUNT, - "", - ) - - putFromValidatingTextFilter( - chapterCountFilter.state.first { - it.id == FILTER_ID_MAX_CHAPTER_COUNT - }, - "max", - ERROR_INVALID_MAX_CHAPTER_COUNT, - "", - ) - } - putJsonObject("dates") { - putFromValidatingTextFilter( - dateRangeFilter.state.first { - it.id == FILTER_ID_START_DATE - }, - "start", - ERROR_INVALID_START_DATE, - ) - - putFromValidatingTextFilter( - dateRangeFilter.state.first { - it.id == FILTER_ID_END_DATE - }, - "end", - ERROR_INVALID_END_DATE, - ) - } - } - } - - // filter IDs - private const val FILTER_ID_ONE_SHOT = "oneshot" - private const val FILTER_ID_START_DATE = "start" - private const val FILTER_ID_END_DATE = "end" - private const val FILTER_ID_MIN_CHAPTER_COUNT = "min" - private const val FILTER_ID_MAX_CHAPTER_COUNT = "max" - - // error messages - private const val ERROR_INVALID_START_DATE = "تاريخ بداية غير صالح" - private const val ERROR_INVALID_END_DATE = " تاريخ نهاية غير صالح" - private const val ERROR_INVALID_MIN_CHAPTER_COUNT = "الحد الأدنى لعدد الفصول غير صالح" - private const val ERROR_INVALID_MAX_CHAPTER_COUNT = "الحد الأقصى لعدد الفصول غير صالح" - - private class MangaTypeFilter() : Filter.Group( - "الأصل", - listOf( - TagFilter("1", "يابانية", TriState.STATE_INCLUDE), - TagFilter("2", "كورية", TriState.STATE_INCLUDE), - TagFilter("3", "صينية", TriState.STATE_INCLUDE), - TagFilter("4", "عربية", TriState.STATE_INCLUDE), - TagFilter("5", "كوميك", TriState.STATE_INCLUDE), - TagFilter("6", "هواة", TriState.STATE_INCLUDE), - TagFilter("7", "إندونيسية", TriState.STATE_INCLUDE), - TagFilter("8", "روسية", TriState.STATE_INCLUDE), - ), - ) - - private class OneShotFilter() : Filter.Group( - "ونشوت؟", - listOf( - TagFilter(FILTER_ID_ONE_SHOT, "نعم", TriState.STATE_EXCLUDE), - ), - ) - - private class StoryStatusFilter() : Filter.Group( - "حالة القصة", - listOf( - TagFilter("2", "مستمرة"), - TagFilter("3", "منتهية"), - ), - ) - - private class TranslationStatusFilter() : Filter.Group( - "حالة الترجمة", - listOf( - TagFilter("0", "منتهية"), - TagFilter("1", "مستمرة"), - TagFilter("2", "متوقفة"), - TagFilter("3", "غير مترجمة", TriState.STATE_EXCLUDE), - ), - ) - - private class ChapterCountFilter() : Filter.Group( - "عدد الفصول", - listOf( - IntFilter(FILTER_ID_MIN_CHAPTER_COUNT, "على الأقل"), - IntFilter(FILTER_ID_MAX_CHAPTER_COUNT, "على الأكثر"), - ), - ) - - private class DateRangeFilter() : Filter.Group( - "تاريخ النشر", - listOf( - DateFilter(FILTER_ID_START_DATE, "تاريخ النشر"), - DateFilter(FILTER_ID_END_DATE, "تاريخ الإنتهاء"), - ), - ) - - private class CategoryFilter() : Filter.Group( - "التصنيفات", - listOf( - TagFilter("1", "إثارة"), - TagFilter("2", "أكشن"), - TagFilter("3", "الحياة المدرسية"), - TagFilter("4", "الحياة اليومية"), - TagFilter("5", "آليات"), - TagFilter("6", "تاريخي"), - TagFilter("7", "تراجيدي"), - TagFilter("8", "جوسيه"), - TagFilter("9", "حربي"), - TagFilter("10", "خيال"), - TagFilter("11", "خيال علمي"), - TagFilter("12", "دراما"), - TagFilter("13", "رعب"), - TagFilter("14", "رومانسي"), - TagFilter("15", "رياضة"), - TagFilter("16", "ساموراي"), - TagFilter("17", "سحر"), - TagFilter("18", "سينين"), - TagFilter("19", "شوجو"), - TagFilter("20", "شونين"), - TagFilter("21", "عنف"), - TagFilter("22", "غموض"), - TagFilter("23", "فنون قتال"), - TagFilter("24", "قوى خارقة"), - TagFilter("25", "كوميدي"), - TagFilter("26", "لعبة"), - TagFilter("27", "مسابقة"), - TagFilter("28", "مصاصي الدماء"), - TagFilter("29", "مغامرات"), - TagFilter("30", "موسيقى"), - TagFilter("31", "نفسي"), - TagFilter("32", "نينجا"), - TagFilter("33", "وحوش"), - TagFilter("34", "حريم"), - TagFilter("35", "راشد"), - TagFilter("38", "ويب-تون"), - TagFilter("39", "زمنكاني"), - ), - ) - - private const val DATE_FILTER_PATTERN = "yyyy/MM/dd" - - @SuppressLint("SimpleDateFormat") - private val DATE_FITLER_FORMAT = SimpleDateFormat(DATE_FILTER_PATTERN).apply { - isLenient = false - } - - private fun SimpleDateFormat.isValid(date: String): Boolean { - return try { - this.parse(date) - true - } catch (e: ParseException) { - false - } - } - - private fun JsonObjectBuilder.putFromValidatingTextFilter( - filter: ValidatingTextFilter, - property: String, - invalidErrorMessage: String, - default: String? = null, - ) { - filter.let { - when { - it.state == "" -> if (default == null) { - put(property, JsonNull) - } else { - put(property, default) - } - it.isValid() -> put(property, it.state) - else -> throw Exception(invalidErrorMessage) - } - } - } - - private inline fun Iterable<*>.findInstance() = find { it is T } as? T - - private class TagFilter(val id: String, name: String, state: Int = STATE_IGNORE) : Filter.TriState(name, state) - - private abstract class ValidatingTextFilter(name: String) : Filter.Text(name) { - abstract fun isValid(): Boolean - } - - private class DateFilter(val id: String, name: String) : ValidatingTextFilter("($DATE_FILTER_PATTERN) $name)") { - override fun isValid(): Boolean = DATE_FITLER_FORMAT.isValid(this.state) - } - - private class IntFilter(val id: String, name: String) : ValidatingTextFilter(name) { - override fun isValid(): Boolean = state.toIntOrNull() != null - } - } -} diff --git a/src/ar/gmanga/src/eu/kanade/tachiyomi/extension/ar/gmanga/GmangaPreferences.kt b/src/ar/gmanga/src/eu/kanade/tachiyomi/extension/ar/gmanga/GmangaPreferences.kt deleted file mode 100644 index 1e17d1cea..000000000 --- a/src/ar/gmanga/src/eu/kanade/tachiyomi/extension/ar/gmanga/GmangaPreferences.kt +++ /dev/null @@ -1,81 +0,0 @@ -package eu.kanade.tachiyomi.extension.ar.gmanga - -import android.app.Application -import android.content.SharedPreferences -import androidx.preference.ListPreference -import androidx.preference.PreferenceScreen -import uy.kohesive.injekt.Injekt -import uy.kohesive.injekt.api.get - -class GmangaPreferences(id: Long) { - - private val preferences: SharedPreferences by lazy { - Injekt.get().getSharedPreferences("source_$id", 0x0000) - } - - fun setupPreferenceScreen(screen: PreferenceScreen) { - STRING_PREFERENCES.forEach { - val preference = ListPreference(screen.context).apply { - key = it.key - title = it.title - entries = it.entries() - entryValues = it.entryValues() - summary = "%s" - } - - if (!preferences.contains(it.key)) { - preferences.edit().putString(it.key, it.default().key).apply() - } - - screen.addPreference(preference) - } - } - - fun getString(pref: StringPreference): String { - return preferences.getString(pref.key, pref.default().key)!! - } - - companion object { - - class StringPreferenceOption(val key: String, val title: String) - - class StringPreference( - val key: String, - val title: String, - private val options: List, - private val defaultOptionIndex: Int = 0, - ) { - fun entries(): Array = options.map { it.title }.toTypedArray() - fun entryValues(): Array = options.map { it.key }.toTypedArray() - fun default(): StringPreferenceOption = options[defaultOptionIndex] - } - - // preferences - const val PREF_CHAPTER_LISTING_SHOW_ALL = "gmanga_gmanga_chapter_listing_show_all" - const val PREF_CHAPTER_LISTING_SHOW_POPULAR = "gmanga_chapter_listing_most_viewed" - const val PREF_LASTETS_LISTING_SHOW_LASTETS_CHAPTER = "gmanga_Last_listing_last_chapter_added" - const val PREF_LASTETS_LISTING_SHOW_LASTETS_MANGA = "gmanga_chapter_listing_last_manga_added" - - val PREF_CHAPTER_LISTING = StringPreference( - "gmanga_chapter_listing", - "كيفية عرض الفصل بقائمة الفصول", - listOf( - StringPreferenceOption(PREF_CHAPTER_LISTING_SHOW_POPULAR, "اختيار النسخة الأكثر مشاهدة"), - StringPreferenceOption(PREF_CHAPTER_LISTING_SHOW_ALL, "عرض جميع النسخ"), - ), - ) - val PREF_LASTETS_LISTING = StringPreference( - "gmanga_last_listing", - "كيفية عرض بقائمة الأعمال الجديدة ", - listOf( - StringPreferenceOption(PREF_LASTETS_LISTING_SHOW_LASTETS_CHAPTER, "اختيار آخر الإضافات"), - StringPreferenceOption(PREF_LASTETS_LISTING_SHOW_LASTETS_MANGA, "اختيار لمانجات الجديدة"), - ), - ) - - private val STRING_PREFERENCES = listOf( - PREF_CHAPTER_LISTING, - PREF_LASTETS_LISTING, - ) - } -} diff --git a/src/ar/gmanga/src/eu/kanade/tachiyomi/extension/ar/gmanga/dto/ChapterDto.kt b/src/ar/gmanga/src/eu/kanade/tachiyomi/extension/ar/gmanga/dto/ChapterDto.kt deleted file mode 100644 index 64e536fc4..000000000 --- a/src/ar/gmanga/src/eu/kanade/tachiyomi/extension/ar/gmanga/dto/ChapterDto.kt +++ /dev/null @@ -1,13 +0,0 @@ -package eu.kanade.tachiyomi.extension.ar.gmanga.dto - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -@Serializable -data class ChapterDto( - val id: Int, - val chapter: Float, - val volume: Int, - val title: String, - @SerialName("time_stamp") val timestamp: Long, -) diff --git a/src/ar/gmanga/src/eu/kanade/tachiyomi/extension/ar/gmanga/dto/ChapterListDto.kt b/src/ar/gmanga/src/eu/kanade/tachiyomi/extension/ar/gmanga/dto/ChapterListDto.kt deleted file mode 100644 index 4be58257f..000000000 --- a/src/ar/gmanga/src/eu/kanade/tachiyomi/extension/ar/gmanga/dto/ChapterListDto.kt +++ /dev/null @@ -1,10 +0,0 @@ -package eu.kanade.tachiyomi.extension.ar.gmanga.dto - -import kotlinx.serialization.Serializable - -@Serializable -data class ChapterListDto( - val releases: List, - val teams: List, - val chapters: List, -) diff --git a/src/ar/gmanga/src/eu/kanade/tachiyomi/extension/ar/gmanga/dto/ReleaseDto.kt b/src/ar/gmanga/src/eu/kanade/tachiyomi/extension/ar/gmanga/dto/ReleaseDto.kt deleted file mode 100644 index d200c8a92..000000000 --- a/src/ar/gmanga/src/eu/kanade/tachiyomi/extension/ar/gmanga/dto/ReleaseDto.kt +++ /dev/null @@ -1,15 +0,0 @@ -package eu.kanade.tachiyomi.extension.ar.gmanga.dto - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -@Serializable -data class ReleaseDto( - val id: Int, - @SerialName("created_at") val createdAt: String, - @SerialName("timestamp") val timestamp: Long, - val views: Int, - @SerialName("chapterization_id") val chapterizationId: Int, - @SerialName("team_id") val teamId: Int, - val teams: List, -) diff --git a/src/ar/gmanga/src/eu/kanade/tachiyomi/extension/ar/gmanga/dto/TableDto.kt b/src/ar/gmanga/src/eu/kanade/tachiyomi/extension/ar/gmanga/dto/TableDto.kt deleted file mode 100644 index 5ea91e079..000000000 --- a/src/ar/gmanga/src/eu/kanade/tachiyomi/extension/ar/gmanga/dto/TableDto.kt +++ /dev/null @@ -1,61 +0,0 @@ -package eu.kanade.tachiyomi.extension.ar.gmanga.dto - -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.decodeFromJsonElement -import kotlinx.serialization.json.float -import kotlinx.serialization.json.int -import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonPrimitive -import kotlinx.serialization.json.long -import uy.kohesive.injekt.injectLazy - -@Serializable -data class TableDto( - val cols: List, - val rows: List, - val isCompact: Boolean, - val maxLevel: Int, - val isArray: Boolean? = null, - val isObject: Boolean? = null, -) - -private val json: Json by injectLazy() - -private fun TableDto.get(key: String): TableDto? { - isObject ?: return null - - val index = cols.indexOf(key) - return json.decodeFromJsonElement(rows[index]) -} - -fun TableDto.asChapterList() = ChapterListDto( - // YOLO - get("releases")!!.rows.map { - ReleaseDto( - it.jsonArray[0].jsonPrimitive.int, - it.jsonArray[1].jsonPrimitive.content, - it.jsonArray[2].jsonPrimitive.long, - it.jsonArray[3].jsonPrimitive.int, - it.jsonArray[4].jsonPrimitive.int, - it.jsonArray[5].jsonPrimitive.int, - it.jsonArray[6].jsonArray.map { it.jsonPrimitive.int }, - ) - }, - get("teams")!!.rows.map { - TeamDto( - it.jsonArray[0].jsonPrimitive.int, - it.jsonArray[1].jsonPrimitive.content, - ) - }, - get("chapterizations")!!.rows.map { - ChapterDto( - it.jsonArray[0].jsonPrimitive.int, - it.jsonArray[1].jsonPrimitive.float, - it.jsonArray[2].jsonPrimitive.int, - it.jsonArray[3].jsonPrimitive.content, - it.jsonArray[4].jsonPrimitive.long, - ) - }, -) diff --git a/src/ar/gmanga/src/eu/kanade/tachiyomi/extension/ar/gmanga/dto/TeamDto.kt b/src/ar/gmanga/src/eu/kanade/tachiyomi/extension/ar/gmanga/dto/TeamDto.kt deleted file mode 100644 index 0942c64a9..000000000 --- a/src/ar/gmanga/src/eu/kanade/tachiyomi/extension/ar/gmanga/dto/TeamDto.kt +++ /dev/null @@ -1,9 +0,0 @@ -package eu.kanade.tachiyomi.extension.ar.gmanga.dto - -import kotlinx.serialization.Serializable - -@Serializable -data class TeamDto( - val id: Int, - val name: String, -) diff --git a/src/ar/mangatales/build.gradle b/src/ar/mangatales/build.gradle new file mode 100644 index 000000000..186d336a7 --- /dev/null +++ b/src/ar/mangatales/build.gradle @@ -0,0 +1,8 @@ +ext { + extName = 'Manga Tales' + extClass = '.MangaTales' + themePkg = 'gmanga' + overrideVersionCode = 0 +} + +apply from: "$rootDir/common.gradle" diff --git a/src/ar/mangatales/res/mipmap-hdpi/ic_launcher.png b/src/ar/mangatales/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..8f1af1176 Binary files /dev/null and b/src/ar/mangatales/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/ar/mangatales/res/mipmap-mdpi/ic_launcher.png b/src/ar/mangatales/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..a32b489e0 Binary files /dev/null and b/src/ar/mangatales/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/ar/mangatales/res/mipmap-xhdpi/ic_launcher.png b/src/ar/mangatales/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..9791404eb Binary files /dev/null and b/src/ar/mangatales/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/ar/mangatales/res/mipmap-xxhdpi/ic_launcher.png b/src/ar/mangatales/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..37e8ca70d Binary files /dev/null and b/src/ar/mangatales/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/ar/mangatales/res/mipmap-xxxhdpi/ic_launcher.png b/src/ar/mangatales/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..673aaa775 Binary files /dev/null and b/src/ar/mangatales/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/ar/mangatales/src/eu/kanade/tachiyomi/extension/ar/mangatales/Dto.kt b/src/ar/mangatales/src/eu/kanade/tachiyomi/extension/ar/mangatales/Dto.kt new file mode 100644 index 000000000..3fedac59a --- /dev/null +++ b/src/ar/mangatales/src/eu/kanade/tachiyomi/extension/ar/mangatales/Dto.kt @@ -0,0 +1,67 @@ +package eu.kanade.tachiyomi.extension.ar.mangatales + +import eu.kanade.tachiyomi.source.model.SChapter +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.float +import java.text.SimpleDateFormat +import java.util.Locale + +@Serializable +class ChapterListDto( + val mangaReleases: List, +) + +@Serializable +class ChapterRelease( + private val id: Int, + private val chapter: JsonPrimitive, + private val title: String, + @SerialName("team_name") private val teamName: String, + @SerialName("created_at") private val createdAt: String, +) { + fun toSChapter() = SChapter.create().apply { + url = "/r/$id" + chapter_number = chapter.float + date_upload = try { + dateFormat.parse(createdAt)!!.time + } catch (_: Exception) { + 0L + } + scanlator = teamName + + val chapterName = title.let { if (it.trim() != "") " - $it" else "" } + name = "${chapter_number.let { if (it % 1 > 0) it else it.toInt() }}$chapterName" + } +} + +private val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.ENGLISH) + +@Serializable +class ReaderDto( + val readerDataAction: ReaderData, + val globals: Globals, +) + +@Serializable +class Globals( + val mediaKey: String, +) + +@Serializable +class ReaderData( + val readerData: ReaderChapter, +) + +@Serializable +class ReaderChapter( + val release: ReaderPages, +) + +@Serializable +class ReaderPages( + @SerialName("hq_pages") private val page: String, +) { + val pages get() = page.split("\r\n") +} diff --git a/src/ar/mangatales/src/eu/kanade/tachiyomi/extension/ar/mangatales/MangaTales.kt b/src/ar/mangatales/src/eu/kanade/tachiyomi/extension/ar/mangatales/MangaTales.kt new file mode 100644 index 000000000..22d30806b --- /dev/null +++ b/src/ar/mangatales/src/eu/kanade/tachiyomi/extension/ar/mangatales/MangaTales.kt @@ -0,0 +1,50 @@ +package eu.kanade.tachiyomi.extension.ar.mangatales + +import eu.kanade.tachiyomi.multisrc.gmanga.Gmanga +import eu.kanade.tachiyomi.multisrc.gmanga.TagFilterData +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.source.model.Filter +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.util.asJsoup +import okhttp3.Request +import okhttp3.Response + +class MangaTales : Gmanga( + "Manga Tales", + "https://www.mangatales.com", + "ar", + "https://media.mangatales.com", +) { + override fun createThumbnail(mangaId: String, cover: String): String { + return "$cdnUrl/uploads/manga/cover/$mangaId/large_$cover" + } + + override fun getTypesFilter() = listOf( + TagFilterData("1", "عربية", Filter.TriState.STATE_INCLUDE), + TagFilterData("2", "إنجليزي", Filter.TriState.STATE_INCLUDE), + ) + + override fun chaptersRequest(manga: SManga): Request { + val mangaId = manga.url.substringAfterLast("/") + return GET("$baseUrl/api/mangas/$mangaId", headers) + } + + override fun chaptersParse(response: Response): List { + val releases = response.parseAs().mangaReleases + + return releases.map { it.toSChapter() } + } + + override fun pageListParse(response: Response): List { + val data = response.asJsoup() + .select(".js-react-on-rails-component").html() + .parseAs() + + return data.readerDataAction.readerData.release.pages + .mapIndexed { idx, img -> + Page(idx, imageUrl = "$cdnUrl/uploads/releases/$img?ak=${data.globals.mediaKey}") + } + } +}