Gmanga multisrc: Add Dilar & MangaTales (#1767)

* gmanga multisrc

* search payload and filters refactor

* ratelimit

* distinct

* dynamic filters

* dilar

* gmanga multisrc: latest

* gmanga multisrc: search & filter

* gmanga multisrc: chapters & pages

* small cleanup

* remove obsolete preferences

* small cleanup & arabic tl

deepl

* Dilar: filter paid chapters

* GManga: use unencrypted alt api for chapters

* abstract away sort of chapters and pages

* remove chapters logic from multisrc class since all three have different logic

* remove `this`
This commit is contained in:
AwkwardPeak7 2024-03-12 16:19:43 +05:00 committed by GitHub
parent cd7a6beca0
commit 81ce7f3d5c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 878 additions and 771 deletions

View File

@ -0,0 +1,5 @@
plugins {
id("lib-multisrc")
}
baseVersionCode = 1

View File

@ -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))
}

View File

@ -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<T>(val mangaDataAction: T)
@Serializable
class LatestChaptersDto(
val releases: List<LatestReleaseDto>,
)
@Serializable
class LatestReleaseDto(
val manga: BrowseManga,
)
@Serializable
class SearchMangaDto(
val mangas: List<BrowseManga>,
)
@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<FiltersDto>? = null,
val categories: List<FilterDto>? = 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<NameDto>,
private val authors: List<NameDto>,
@SerialName("story_status") private val status: Int,
private val type: TypeDto,
private val categories: List<NameDto>,
@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<String>,
val pages: List<String>,
@SerialName("storage_key") val key: String,
)

View File

@ -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<TagFilterData>) : Filter.Group<TagFilter>(
"الأصل",
types.map { it.toTagFilter() },
)
class OneShotFilter : Filter.Group<TagFilter>(
"ونشوت؟",
listOf(
TagFilter("oneshot", "نعم", TriState.STATE_EXCLUDE),
),
)
class StoryStatusFilter(status: List<TagFilterData>) : Filter.Group<TagFilter>(
"حالة القصة",
status.map { it.toTagFilter() },
)
class TranslationStatusFilter(tlStatus: List<TagFilterData>) : Filter.Group<TagFilter>(
"حالة الترجمة",
tlStatus.map { it.toTagFilter() },
)
class ChapterCountFilter : Filter.Group<IntFilter>(
"عدد الفصول",
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<DateFilter>(
"تاريخ النشر",
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<TagFilterData>) : Filter.Group<TagFilter>(
"التصنيفات",
categories.map { it.toTagFilter() },
)

View File

@ -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<LatestChaptersDto>().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<MangaTypeFilter>()!!
val oneShotFilter = filterList.findInstance<OneShotFilter>()!!
val storyStatusFilter = filterList.findInstance<StoryStatusFilter>()!!
val translationStatusFilter = filterList.findInstance<TranslationStatusFilter>()!!
val chapterCountFilter = filterList.findInstance<ChapterCountFilter>()!!
val dateRangeFilter = filterList.findInstance<DateRangeFilter>()!!
val categoryFilter = filterList.findInstance<CategoryFilter>() ?: 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<TagFilterData> = 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<FiltersDto>()
.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<Filter<*>>(
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<SearchMangaDto>()
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<MangaDetailsDto>>()
.mangaDataAction.mangaData
.toSManga(::createThumbnail)
}
abstract fun chaptersRequest(manga: SManga): Request
abstract fun chaptersParse(response: Response): List<SChapter>
final override fun chapterListRequest(manga: SManga) = chaptersRequest(manga)
final override fun chapterListParse(response: Response) = chaptersParse(response).sortChapters()
private fun List<SChapter>.sortChapters() =
sortedWith(
compareBy(
{ -it.chapter_number },
{ -it.date_upload },
),
)
override fun pageListParse(response: Response): List<Page> {
val data = response.asJsoup()
.select(".js-react-on-rails-component").html()
.parseAs<ReaderDto>()
.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<String>({ 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 <reified T> Response.decryptAs(): T =
decrypt(parseAs<EncryptedResponse>().data).parseAs()
protected inline fun <reified T> Response.parseAs(): T = body.string().parseAs()
protected inline fun <reified T> String.parseAs(): T = json.decodeFromString(this)
protected inline fun <reified T> 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()
}
}

View File

@ -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<String?>,
private val exclude: List<String?>,
)
@Serializable
class MinMax(
private val min: String,
private val max: String,
)
@Serializable
class StartEnd(
private val start: String,
private val end: String,
)

View File

@ -0,0 +1,8 @@
ext {
extName = 'Dilar'
extClass = '.Dilar'
themePkg = 'gmanga'
overrideVersionCode = 0
}
apply from: "$rootDir/common.gradle"

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

View File

@ -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<SChapter> {
val releases = response.parseAs<ChapterListDto>().releases
.filterNot { it.isMonetized }
return releases.map { it.toSChapter() }
}
}

View File

@ -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<ChapterRelease>,
)
@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"
}
}

View File

@ -1,7 +1,8 @@
ext {
extName = 'GMANGA'
extClass = '.Gmanga'
extVersionCode = 13
themePkg = 'gmanga'
overrideVersionCode = 13
}
apply from: "$rootDir/common.gradle"

View File

@ -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<ChapterRelease>,
val chapterizations: List<Chapterization>,
val teams: List<Team>,
)
@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,
)

View File

@ -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<Application>().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<SChapter> {
val data = decryptResponse(response)
val table = json.decodeFromJsonElement<TableDto>(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<JsonObject>()
val selectedManga = decMga["rows"]!!.jsonArray[0].jsonObject["rows"]!!.jsonArray
val manags = selectedManga.map {
json.decodeFromJsonElement<BrowseManga>(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<List<SChapter>> {
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<SChapter> {
val chapterList = response.parseAs<ChapterListResponse>()
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<JsonObject>(
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<JsonObject>(
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<Page> {
val url = response.request.url.toString()
val data = json.decodeFromString<JsonObject>(
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<String>({ 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<JsonObject>(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()
}
}

View File

@ -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<MangaTypeFilter>()!!
val oneShotFilter = filters.findInstance<OneShotFilter>()!!
val storyStatusFilter = filters.findInstance<StoryStatusFilter>()!!
val translationStatusFilter = filters.findInstance<TranslationStatusFilter>()!!
val chapterCountFilter = filters.findInstance<ChapterCountFilter>()!!
val dateRangeFilter = filters.findInstance<DateRangeFilter>()!!
val categoryFilter = filters.findInstance<CategoryFilter>()!!
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<TagFilter>(
"الأصل",
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<TagFilter>(
"ونشوت؟",
listOf(
TagFilter(FILTER_ID_ONE_SHOT, "نعم", TriState.STATE_EXCLUDE),
),
)
private class StoryStatusFilter() : Filter.Group<TagFilter>(
"حالة القصة",
listOf(
TagFilter("2", "مستمرة"),
TagFilter("3", "منتهية"),
),
)
private class TranslationStatusFilter() : Filter.Group<TagFilter>(
"حالة الترجمة",
listOf(
TagFilter("0", "منتهية"),
TagFilter("1", "مستمرة"),
TagFilter("2", "متوقفة"),
TagFilter("3", "غير مترجمة", TriState.STATE_EXCLUDE),
),
)
private class ChapterCountFilter() : Filter.Group<IntFilter>(
"عدد الفصول",
listOf(
IntFilter(FILTER_ID_MIN_CHAPTER_COUNT, "على الأقل"),
IntFilter(FILTER_ID_MAX_CHAPTER_COUNT, "على الأكثر"),
),
)
private class DateRangeFilter() : Filter.Group<DateFilter>(
"تاريخ النشر",
listOf(
DateFilter(FILTER_ID_START_DATE, "تاريخ النشر"),
DateFilter(FILTER_ID_END_DATE, "تاريخ الإنتهاء"),
),
)
private class CategoryFilter() : Filter.Group<TagFilter>(
"التصنيفات",
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 <reified T> 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
}
}
}

View File

@ -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<Application>().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<StringPreferenceOption>,
private val defaultOptionIndex: Int = 0,
) {
fun entries(): Array<String> = options.map { it.title }.toTypedArray()
fun entryValues(): Array<String> = 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,
)
}
}

View File

@ -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,
)

View File

@ -1,10 +0,0 @@
package eu.kanade.tachiyomi.extension.ar.gmanga.dto
import kotlinx.serialization.Serializable
@Serializable
data class ChapterListDto(
val releases: List<ReleaseDto>,
val teams: List<TeamDto>,
val chapters: List<ChapterDto>,
)

View File

@ -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<Int>,
)

View File

@ -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<String>,
val rows: List<JsonElement>,
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,
)
},
)

View File

@ -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,
)

View File

@ -0,0 +1,8 @@
ext {
extName = 'Manga Tales'
extClass = '.MangaTales'
themePkg = 'gmanga'
overrideVersionCode = 0
}
apply from: "$rootDir/common.gradle"

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@ -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<ChapterRelease>,
)
@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")
}

View File

@ -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<SChapter> {
val releases = response.parseAs<ChapterListDto>().mangaReleases
return releases.map { it.toSChapter() }
}
override fun pageListParse(response: Response): List<Page> {
val data = response.asJsoup()
.select(".js-react-on-rails-component").html()
.parseAs<ReaderDto>()
return data.readerDataAction.readerData.release.pages
.mapIndexed { idx, img ->
Page(idx, imageUrl = "$cdnUrl/uploads/releases/$img?ak=${data.globals.mediaKey}")
}
}
}