diff --git a/src/ar/mangacloud/build.gradle.kts b/src/ar/mangacloud/build.gradle.kts new file mode 100644 index 00000000000..4dc0e85d57e --- /dev/null +++ b/src/ar/mangacloud/build.gradle.kts @@ -0,0 +1,17 @@ +import io.github.keiyoushi.gradle.api.ContentWarning + +plugins { + alias(kei.plugins.extension) +} + +keiyoushi { + name = "MangaCloud" + versionCode = 1 + contentWarning = ContentWarning.SAFE + libVersion = "1.6" + + source { + lang = "ar" + baseUrl = "https://mangacloud.online" + } +} diff --git a/src/ar/mangacloud/res/mipmap-hdpi/ic_launcher.png b/src/ar/mangacloud/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 00000000000..ee1d29f8677 Binary files /dev/null and b/src/ar/mangacloud/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/ar/mangacloud/res/mipmap-mdpi/ic_launcher.png b/src/ar/mangacloud/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 00000000000..d01ac914ec0 Binary files /dev/null and b/src/ar/mangacloud/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/ar/mangacloud/res/mipmap-xhdpi/ic_launcher.png b/src/ar/mangacloud/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 00000000000..9c1f1c013b1 Binary files /dev/null and b/src/ar/mangacloud/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/ar/mangacloud/res/mipmap-xxhdpi/ic_launcher.png b/src/ar/mangacloud/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 00000000000..3507d6d8d1a Binary files /dev/null and b/src/ar/mangacloud/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/ar/mangacloud/res/mipmap-xxxhdpi/ic_launcher.png b/src/ar/mangacloud/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 00000000000..0166cb7ff44 Binary files /dev/null and b/src/ar/mangacloud/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/ar/mangacloud/src/eu/kanade/tachiyomi/extension/ar/mangacloud/Dto.kt b/src/ar/mangacloud/src/eu/kanade/tachiyomi/extension/ar/mangacloud/Dto.kt new file mode 100644 index 00000000000..e6a3305e8f0 --- /dev/null +++ b/src/ar/mangacloud/src/eu/kanade/tachiyomi/extension/ar/mangacloud/Dto.kt @@ -0,0 +1,126 @@ +package eu.kanade.tachiyomi.extension.ar.mangacloud + +import eu.kanade.tachiyomi.source.model.Page +import eu.kanade.tachiyomi.source.model.SChapter +import eu.kanade.tachiyomi.source.model.SManga +import keiyoushi.utils.parseAs +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import okhttp3.Response + +@Serializable +data class FirestoreListResponse( + val documents: List? = null, + val nextPageToken: String? = null, +) + +@Serializable +data class FirestoreDocument( + val name: String? = null, + val fields: JsonObject? = null, +) + +data class MangaData( + val smanga: SManga, + val altTitles: List = emptyList(), + val genres: List = emptyList(), + val statusString: String = "", +) + +data class MangaListResult( + val mangas: List, + val nextPageToken: String?, +) + +object FirestoreParser { + + fun parseList(response: Response): MangaListResult { + val result = response.parseAs() + val mangas = result.documents?.mapNotNull { doc -> + val fields = doc.fields ?: return@mapNotNull null + val mangaId = doc.name?.substringAfterLast("/") ?: return@mapNotNull null + MangaData( + smanga = SManga.create().apply { + url = mangaId + title = fields.string("title") + thumbnail_url = fields.string("coverImage") + description = fields.string("description") + author = fields.string("author") + artist = fields.string("artist") + genre = fields.stringArray("genres").joinToString(", ") + status = when (fields.string("status")) { + "مستمر" -> SManga.ONGOING + "مكتمل" -> SManga.COMPLETED + "متروك", "متروكة" -> SManga.ON_HIATUS + "معلق" -> SManga.ON_HIATUS + else -> SManga.UNKNOWN + } + }, + altTitles = fields.stringArray("alternativeTitles"), + genres = fields.stringArray("genres"), + statusString = when (fields.string("status")) { + "مستمر" -> "ongoing" + "مكتمل" -> "completed" + else -> "" + }, + ) + } ?: emptyList() + return MangaListResult(mangas, result.nextPageToken) + } + + fun parseDetails(response: Response): SManga { + val doc = response.parseAs() + val fields = doc.fields!! + val mangaId = doc.name!!.substringAfterLast("/") + check(mangaId.isNotEmpty()) { "mangaId must not be empty" } + return SManga.create().apply { + url = mangaId + title = fields.string("title") + thumbnail_url = fields.string("coverImage") + description = fields.string("description") + author = fields.string("author") + artist = fields.string("artist") + genre = fields.stringArray("genres").joinToString(", ") + status = when (fields.string("status")) { + "مستمر" -> SManga.ONGOING + "مكتمل" -> SManga.COMPLETED + "متروك" -> SManga.ON_HIATUS + else -> SManga.UNKNOWN + } + } + } + + fun parseChapters(response: Response): List { + val result = response.parseAs() + return result.documents?.mapNotNull { doc -> + val docName = doc.name ?: return@mapNotNull null + val chapterNum = docName.substringAfterLast("/") + val num = chapterNum.toIntOrNull() ?: 0 + SChapter.create().apply { + url = docName.substringAfterLast("cloudmangas/") + name = "الفصل $num" + chapter_number = num.toFloat() + } + }?.sortedByDescending { it.chapter_number } ?: emptyList() + } + + fun parsePages(response: Response): List { + val doc = response.parseAs() + val fields = doc.fields ?: return emptyList() + val pagesArray = fields["pages"]?.jsonObject?.get("arrayValue")?.jsonObject + ?.get("values")?.jsonArray ?: return emptyList() + return pagesArray.mapIndexed { index, value -> + Page(index, imageUrl = value.jsonObject["stringValue"]?.jsonPrimitive?.contentOrNull ?: "") + } + } +} + +private fun JsonObject.string(key: String): String = this[key]?.jsonObject?.get("stringValue")?.jsonPrimitive?.contentOrNull ?: "" + +private fun JsonObject.stringArray(key: String): List = this[key]?.jsonObject?.get("arrayValue")?.jsonObject?.get("values")?.jsonArray + ?.map { it.jsonObject["stringValue"]?.jsonPrimitive?.contentOrNull ?: "" } + ?: emptyList() diff --git a/src/ar/mangacloud/src/eu/kanade/tachiyomi/extension/ar/mangacloud/Filters.kt b/src/ar/mangacloud/src/eu/kanade/tachiyomi/extension/ar/mangacloud/Filters.kt new file mode 100644 index 00000000000..11d5fc9922a --- /dev/null +++ b/src/ar/mangacloud/src/eu/kanade/tachiyomi/extension/ar/mangacloud/Filters.kt @@ -0,0 +1,265 @@ +package eu.kanade.tachiyomi.extension.ar.mangacloud + +import eu.kanade.tachiyomi.source.model.Filter + +internal class SortFilter(name: String, private val pairs: Array>) : Filter.Select(name, pairs.map { it.first }.toTypedArray()) { + val selected get() = pairs[state].second +} + +internal class GenreFilter(name: String, private val pairs: Array>) : Filter.Select(name, pairs.map { it.first }.toTypedArray()) { + val selected get() = pairs[state].second +} + +internal class StatusFilter(name: String, private val pairs: Array>) : Filter.Select(name, pairs.map { it.first }.toTypedArray()) { + val selected get() = pairs[state].second +} + +internal val sortingList = arrayOf( + Pair("الأشهر", "popular"), + Pair("آخر التحديثات", "updated"), + Pair("إضافات جديدة", "new"), +) + +internal val statusList = arrayOf( + Pair("الكل", ""), + Pair("مستمر", "ongoing"), + Pair("مكتمل", "completed"), +) + +internal val genreList = arrayOf( + Pair("الكل", ""), + Pair("إدارة المناطق", "إدارة المناطق"), + Pair("إعادة احياء", "إعادة احياء"), + Pair("إعادة بحث", "إعادة بحث"), + Pair("إنتقام", "إنتقام"), + Pair("إيسيكاي", "إيسيكاي"), + Pair("ابراج", "ابراج"), + Pair("اثاره", "اثاره"), + Pair("اتصال بالعالم الخارجي", "اتصال بالعالم الخارجي"), + Pair("اجناس", "اجناس"), + Pair("احتجاج", "احتجاج"), + Pair("اختفاء", "اختفاء"), + Pair("ارتقاء", "ارتقاء"), + Pair("ارواح", "ارواح"), + Pair("ازياء", "ازياء"), + Pair("اساطير", "اساطير"), + Pair("اساطيز", "اساطيز"), + Pair("اسبوعى", "اسبوعى"), + Pair("استراتيجية", "استراتيجية"), + Pair("اشباح", "اشباح"), + Pair("اضطهاد", "اضطهاد"), + Pair("اظطهاد", "اظطهاد"), + Pair("اعادة احياء", "اعادة احياء"), + Pair("اعمار", "اعمار"), + Pair("اعمال", "اعمال"), + Pair("اقتصاد", "اقتصاد"), + Pair("اكاديميه", "اكاديميه"), + Pair("اكشن", "اكشن"), + Pair("الات", "الات"), + Pair("الالوان الممتلئه", "الالوان الممتلئه"), + Pair("البقاء علي قيد الحياه", "البقاء علي قيد الحياه"), + Pair("الجانب المظلم من الحياه", "الجانب المظلم من الحياه"), + Pair("الحريم العكسي", "الحريم العكسي"), + Pair("الحياة المدرسيه", "الحياة المدرسيه"), + Pair("الحياة اليومية", "الحياة اليومية"), + Pair("الحيوانات الأليفة", "الحيوانات الأليفة"), + Pair("الخيال العلمي", "الخيال العلمي"), + Pair("السفر عبر الزمن", "السفر عبر الزمن"), + Pair("العاب", "العاب"), + Pair("العاب الكترونية", "العاب الكترونية"), + Pair("العاب تقليدية", "العاب تقليدية"), + Pair("العاب رعب", "العاب رعب"), + Pair("العاب فيديو", "العاب فيديو"), + Pair("العصور الوسطى", "العصور الوسطى"), + Pair("الغموض", "الغموض"), + Pair("الفتاة الوحش", "الفتاة الوحش"), + Pair("الفنون القتالية", "الفنون القتالية"), + Pair("الفنون العسكرية", "الفنون العسكرية"), + Pair("المخالفون للقانون", "المخالفون للقانون"), + Pair("النجاة", "النجاة"), + Pair("الهة", "الهة"), + Pair("الهه", "الهه"), + Pair("الواقع الافتراضي", "الواقع الافتراضي"), + Pair("اليات", "اليات"), + Pair("امرأة شريرة", "امرأة شريرة"), + Pair("اميرة", "اميرة"), + Pair("انتقال", "انتقال"), + Pair("انمى", "انمى"), + Pair("ايتشى", "ايتشى"), + Pair("ايسكاى", "ايسكاى"), + Pair("ايشي", "ايشي"), + Pair("بالغ", "بالغ"), + Pair("بزنس", "بزنس"), + Pair("بطل خارق", "بطل خارق"), + Pair("بطل غير اعتيادي", "بطل غير اعتيادي"), + Pair("بطل غير اعتيادى", "بطل غير اعتيادى"), + Pair("بطل مجنون", "بطل مجنون"), + Pair("بطل وحش", "بطل وحش"), + Pair("بعد الكارثه", "بعد الكارثه"), + Pair("بوليسي", "بوليسي"), + Pair("تاريخ", "تاريخ"), + Pair("تاريخى", "تاريخى"), + Pair("تجاره", "تجاره"), + Pair("تجسيد", "تجسيد"), + Pair("تحديث", "تحديث"), + Pair("تحري", "تحري"), + Pair("تحقيق", "تحقيق"), + Pair("تحقيقات", "تحقيقات"), + Pair("تخطيط", "تخطيط"), + Pair("تدريب", "تدريب"), + Pair("تراجع", "تراجع"), + Pair("تراجيدي", "تراجيدي"), + Pair("ترويض", "ترويض"), + Pair("ترويض وحوش", "ترويض وحوش"), + Pair("تشويق", "تشويق"), + Pair("تلوين رسم", "تلوين رسم"), + Pair("تلوين رسمي", "تلوين رسمي"), + Pair("تلوين هواة", "تلوين هواة"), + Pair("تملك", "تملك"), + Pair("تناسخ", "تناسخ"), + Pair("تناسخ الارواح", "تناسخ الارواح"), + Pair("تنانين", "تنانين"), + Pair("تنايخ", "تنايخ"), + Pair("ثأر", "ثأر"), + Pair("جانحون", "جانحون"), + Pair("جريمة", "جريمة"), + Pair("جريمه", "جريمه"), + Pair("جندر اسواب", "جندر اسواب"), + Pair("جندر بندير", "جندر بندير"), + Pair("جوسي", "جوسي"), + Pair("جوسين", "جوسين"), + Pair("جوسيه", "جوسيه"), + Pair("حائز علي جائزة", "حائز علي جائزة"), + Pair("حديث", "حديث"), + Pair("حرب", "حرب"), + Pair("حربي", "حربي"), + Pair("حريم", "حريم"), + Pair("حريم عكسي", "حريم عكسي"), + Pair("حصرية", "حصرية"), + Pair("حياة مدرسية", "حياة مدرسية"), + Pair("حياة يومية", "حياة يومية"), + Pair("حيوانات", "حيوانات"), + Pair("حيوانات اليفه", "حيوانات اليفه"), + Pair("خارق", "خارق"), + Pair("خارق للطبيعه", "خارق للطبيعه"), + Pair("خيار", "خيار"), + Pair("خيال", "خيال"), + Pair("خيال علمي", "خيال علمي"), + Pair("خيالي", "خيالي"), + Pair("داخل اللعبه", "داخل اللعبه"), + Pair("داخل روايه", "داخل روايه"), + Pair("دراما", "دراما"), + Pair("دماء", "دماء"), + Pair("دموى", "دموى"), + Pair("ذكريات من عالم آخر", "ذكريات من عالم آخر"), + Pair("راشد", "راشد"), + Pair("رعاية اطفال", "رعاية اطفال"), + Pair("رعب", "رعب"), + Pair("روايات عربية", "روايات عربية"), + Pair("روايه", "روايه"), + Pair("رومانسي", "رومانسي"), + Pair("رياضه", "رياضه"), + Pair("رياضي", "رياضي"), + Pair("زراعة", "زراعة"), + Pair("زمكاني", "زمكاني"), + Pair("زمنكاني", "زمنكاني"), + Pair("زنزانات", "زنزانات"), + Pair("زواج مدبر", "زواج مدبر"), + Pair("زومبي", "زومبي"), + Pair("ساموراي", "ساموراي"), + Pair("ساموري", "ساموري"), + Pair("سايكوباث", "سايكوباث"), + Pair("سحر", "سحر"), + Pair("سيرة ذاتية", "سيرة ذاتية"), + Pair("سياسي", "سياسي"), + Pair("سينين", "سينين"), + Pair("شرطة", "شرطة"), + Pair("شركه", "شركه"), + Pair("شريحة من الحياة", "شريحة من الحياة"), + Pair("شرير", "شرير"), + Pair("شوجو", "شوجو"), + Pair("شونين", "شونين"), + Pair("شياطين", "شياطين"), + Pair("شينين", "شينين"), + Pair("صقل", "صقل"), + Pair("طبخ", "طبخ"), + Pair("طبي", "طبي"), + Pair("طرد الارواح الشريره", "طرد الارواح الشريره"), + Pair("عائلي", "عائلي"), + Pair("عالم مختلف", "عالم مختلف"), + Pair("عامل مكتبي", "عامل مكتبي"), + Pair("عسكري", "عسكري"), + Pair("عسكريه", "عسكريه"), + Pair("عصر حديث", "عصر حديث"), + Pair("عصور وسطى", "عصور وسطى"), + Pair("علم نفس", "علم نفس"), + Pair("علمي", "علمي"), + Pair("عنف", "عنف"), + Pair("عنف جنسي", "عنف جنسي"), + Pair("عوالم", "عوالم"), + Pair("عوده بالزمن", "عوده بالزمن"), + Pair("غموض", "غموض"), + Pair("فانتازا", "فانتازا"), + Pair("فانتازيا", "فانتازيا"), + Pair("فانتسي", "فانتسي"), + Pair("فلسفه", "فلسفه"), + Pair("فنتازيا", "فنتازيا"), + Pair("فنون الدفاع عن النفس", "فنون الدفاع عن النفس"), + Pair("فنون قتاليه", "فنون قتاليه"), + Pair("فوق الطبيعه", "فوق الطبيعه"), + Pair("فيكتوري", "فيكتوري"), + Pair("قتال", "قتال"), + Pair("قتالات", "قتالات"), + Pair("قوة خارقة", "قوة خارقة"), + Pair("كائنات فضائية", "كائنات فضائية"), + Pair("كل الاعمار", "كل الاعمار"), + Pair("كوميديا", "كوميديا"), + Pair("كوميدى", "كوميدى"), + Pair("كوميك", "كوميك"), + Pair("كوما-4", "كوما-4"), + Pair("لعبه", "لعبه"), + Pair("مأساوي", "مأساوي"), + Pair("مؤامرات", "مؤامرات"), + Pair("ما بعد نهاية العالم", "ما بعد نهاية العالم"), + Pair("مأساة", "مأساة"), + Pair("مافيا", "مافيا"), + Pair("مانا", "مانا"), + Pair("مانجا", "مانجا"), + Pair("مانجا ملونه", "مانجا ملونه"), + Pair("مانها", "مانها"), + Pair("مانهوا", "مانهوا"), + Pair("متحول", "متحول"), + Pair("مجموعة قصص", "مجموعة قصص"), + Pair("مدرسه", "مدرسه"), + Pair("مدرسي", "مدرسي"), + Pair("مستذئب", "مستذئب"), + Pair("مصاصي الدماء", "مصاصي الدماء"), + Pair("معالج", "معالج"), + Pair("مغامرة", "مغامرة"), + Pair("مغني", "مغني"), + Pair("مقتبسة", "مقتبسة"), + Pair("مقطع طولي", "مقطع طولي"), + Pair("ملائكة", "ملائكة"), + Pair("ملونه", "ملونه"), + Pair("ممالك", "ممالك"), + Pair("موريم", "موريم"), + Pair("موسيقى", "موسيقى"), + Pair("ميكا", "ميكا"), + Pair("ناضج", "ناضج"), + Pair("نبالة", "نبالة"), + Pair("نبلاء", "نبلاء"), + Pair("نظام", "نظام"), + Pair("نفسي", "نفسي"), + Pair("نهاية العالم", "نهاية العالم"), + Pair("نينجا", "نينجا"), + Pair("هندسة", "هندسة"), + Pair("هواه", "هواه"), + Pair("هوس", "هوس"), + Pair("واقع افتراضي", "واقع افتراضي"), + Pair("واقعى", "واقعى"), + Pair("ويبتون", "ويبتون"), + Pair("وحوش", "وحوش"), + Pair("ون شوت", "ون شوت"), + Pair("ويب تون", "ويب تون"), + Pair("يقاء", "يقاء"), +) diff --git a/src/ar/mangacloud/src/eu/kanade/tachiyomi/extension/ar/mangacloud/MangaCloud.kt b/src/ar/mangacloud/src/eu/kanade/tachiyomi/extension/ar/mangacloud/MangaCloud.kt new file mode 100644 index 00000000000..d21f19d6e33 --- /dev/null +++ b/src/ar/mangacloud/src/eu/kanade/tachiyomi/extension/ar/mangacloud/MangaCloud.kt @@ -0,0 +1,226 @@ +package eu.kanade.tachiyomi.extension.ar.mangacloud + +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.model.SMangaUpdate +import keiyoushi.annotation.Source +import keiyoushi.network.get +import keiyoushi.source.KeiSource +import keiyoushi.utils.firstInstanceOrNull +import keiyoushi.utils.parseAs +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl + +@Source +abstract class MangaCloud : KeiSource() { + + // ============================== Popular ============================== + + override suspend fun getPopularManga(page: Int): MangasPage { + val url = FIRESTORE_URL.toHttpUrl().newBuilder() + .addQueryParameter("pageSize", PAGE_SIZE.toString()) + .addQueryParameter("key", API_KEY) + .apply { + val token = lastPageToken + if (token != null) addQueryParameter("pageToken", token) + } + .build() + val response = client.get(url) + val json = FirestoreParser.parseList(response) + lastPageToken = json.nextPageToken + return MangasPage(json.mangas.map { it.smanga }, json.nextPageToken != null) + } + + // ============================== Latest =============================== + + override suspend fun getLatestUpdates(page: Int): MangasPage { + val url = FIRESTORE_URL.toHttpUrl().newBuilder() + .addQueryParameter("pageSize", PAGE_SIZE.toString()) + .addQueryParameter("key", API_KEY) + .addQueryParameter("orderBy", "updatedAt desc") + .build() + val response = client.get(url) + val json = FirestoreParser.parseList(response) + return MangasPage(json.mangas.map { it.smanga }, false) + } + + // ============================== Search =============================== + + override suspend fun getSearchMangaList(page: Int, query: String, filters: FilterList): MangasPage { + val sort = filters.firstInstanceOrNull()?.selected.orEmpty() + val genre = filters.firstInstanceOrNull()?.selected.orEmpty() + val status = filters.firstInstanceOrNull()?.selected.orEmpty() + + val url = FIRESTORE_URL.toHttpUrl().newBuilder() + .addQueryParameter("pageSize", "300") + .addQueryParameter("key", API_KEY) + .apply { + when (sort) { + "new" -> addQueryParameter("orderBy", "createdAt desc") + "updated" -> addQueryParameter("orderBy", "updatedAt desc") + } + } + .build() + + val response = client.get(url) + val json = FirestoreParser.parseList(response) + val allMangas = json.mangas.toMutableList() + + var nextPageToken = json.nextPageToken + while (nextPageToken != null) { + val nextUrl = FIRESTORE_URL.toHttpUrl().newBuilder() + .addQueryParameter("pageSize", "300") + .addQueryParameter("key", API_KEY) + .addQueryParameter("pageToken", nextPageToken) + .build() + val nextResponse = client.get(nextUrl) + val nextJson = FirestoreParser.parseList(nextResponse) + allMangas.addAll(nextJson.mangas) + nextPageToken = nextJson.nextPageToken + } + + val filtered = allMangas.filter { data -> + val matchesQuery = if (query.isNotBlank()) { + val q = query.lowercase() + data.smanga.title.lowercase().contains(q) || + data.altTitles.any { it.lowercase().contains(q) } || + data.genres.any { it.lowercase().contains(q) } || + data.smanga.description?.lowercase()?.contains(q) == true + } else { + true + } + val matchesGenre = if (genre.isNotBlank()) { + data.genres.any { it.lowercase().contains(genre.lowercase()) } + } else { + true + } + val matchesStatus = if (status.isNotBlank()) { + data.statusString == status + } else { + true + } + matchesQuery && matchesGenre && matchesStatus + } + return MangasPage(filtered.map { it.smanga }, false) + } + + // ============================== Details ============================== + + override suspend fun getMangaByUrl(url: HttpUrl): SManga? { + val mangaId = url.pathSegments.lastOrNull() ?: return null + val firestoreUrl = "$FIRESTORE_URL/$mangaId?key=$API_KEY".toHttpUrl() + val response = client.get(firestoreUrl) + return FirestoreParser.parseDetails(response) + } + + override suspend fun fetchMangaUpdate( + manga: SManga, + chapters: List, + fetchDetails: Boolean, + fetchChapters: Boolean, + ): SMangaUpdate = coroutineScope { + val mangaDetailDeferred = if (fetchDetails) { + async { + val url = "$FIRESTORE_URL/${manga.url}?key=$API_KEY".toHttpUrl() + val response = client.get(url) + FirestoreParser.parseDetails(response) + } + } else { + null + } + + val chapterListDeferred = if (fetchChapters) { + async { fetchAllChapters(manga.url) } + } else { + null + } + + val mangaDetail = mangaDetailDeferred?.await() ?: manga + val chapterList = chapterListDeferred?.await() ?: chapters + + SMangaUpdate(mangaDetail, chapterList) + } + + private suspend fun fetchAllChapters(mangaId: String): List { + val allChapters = mutableListOf() + var nextPageToken: String? = null + + do { + val urlBuilder = "$FIRESTORE_URL/$mangaId/chapters".toHttpUrl().newBuilder() + .addQueryParameter("pageSize", "1000") + .addQueryParameter("key", API_KEY) + .addQueryParameter("orderBy", "index") + if (nextPageToken != null) { + urlBuilder.addQueryParameter("pageToken", nextPageToken) + } + val response = client.get(urlBuilder.build()) + val result = response.parseAs() + val chapters = result.documents?.mapNotNull { doc -> + val docName = doc.name ?: return@mapNotNull null + val chapterNum = docName.substringAfterLast("/") + val num = chapterNum.toIntOrNull() ?: 0 + SChapter.create().apply { + url = docName.substringAfterLast("cloudmangas/") + name = "الفصل $num" + chapter_number = num.toFloat() + } + } ?: emptyList() + allChapters.addAll(chapters) + nextPageToken = result.nextPageToken + } while (nextPageToken != null) + + allChapters.sortByDescending { + it.chapter_number?.toString()?.toFloatOrNull() ?: 0f + } + + return allChapters + } + + // ============================== Pages =============================== + + override suspend fun getPageList(chapter: SChapter): List { + val url = "${FIRESTORE_URL}/${chapter.url}?key=$API_KEY".toHttpUrl() + val response = client.get(url) + return FirestoreParser.parsePages(response) + } + + // ============================== URLs ================================ + + override fun getMangaUrl(manga: SManga): String = "$baseUrl/manga/${manga.url}" + + override fun getChapterUrl(chapter: SChapter): String { + val parts = chapter.url.split("/") + if (parts.size >= 2) { + return "$baseUrl/manga/${parts[0]}/${parts[1]}" + } + return "$baseUrl/manga/${chapter.url}" + } + + // ============================== Filters ============================= + + override fun getFilterList(data: kotlinx.serialization.json.JsonElement?) = FilterList( + SortFilter("ترتيب حسب", sortingList), + StatusFilter("الحالة", statusList), + GenreFilter("التصنيف", genreList), + ) + + // ============================== Related ============================= + + override val supportsRelatedMangas get() = false + override suspend fun fetchRelatedMangaList(manga: SManga): List = emptyList() + + // ============================== State =============================== + + private var lastPageToken: String? = null + + companion object { + private const val API_KEY = "AIzaSyAvdmgz_r_d89Eo8JBs9vAjUAJR451bMYU" + private const val FIRESTORE_URL = "https://firestore.googleapis.com/v1/projects/ninja-brew-crew-4d79c/databases/(default)/documents/cloudmangas" + private const val PAGE_SIZE = 20 + } +}