diff --git a/src/en/alandal/build.gradle b/src/en/alandal/build.gradle new file mode 100644 index 000000000..be0d9f2ca --- /dev/null +++ b/src/en/alandal/build.gradle @@ -0,0 +1,7 @@ +ext { + extName = 'Alandal' + extClass = '.Alandal' + extVersionCode = 1 +} + +apply from: "$rootDir/common.gradle" diff --git a/src/en/alandal/res/mipmap-hdpi/ic_launcher.png b/src/en/alandal/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..490be2cc2 Binary files /dev/null and b/src/en/alandal/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/en/alandal/res/mipmap-mdpi/ic_launcher.png b/src/en/alandal/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..b8944407b Binary files /dev/null and b/src/en/alandal/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/en/alandal/res/mipmap-xhdpi/ic_launcher.png b/src/en/alandal/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..71b87f3d0 Binary files /dev/null and b/src/en/alandal/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/en/alandal/res/mipmap-xxhdpi/ic_launcher.png b/src/en/alandal/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..f256030ae Binary files /dev/null and b/src/en/alandal/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/en/alandal/res/mipmap-xxxhdpi/ic_launcher.png b/src/en/alandal/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..6feb9a003 Binary files /dev/null and b/src/en/alandal/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/en/alandal/src/eu/kanade/tachiyomi/extension/en/alandal/Alandal.kt b/src/en/alandal/src/eu/kanade/tachiyomi/extension/en/alandal/Alandal.kt new file mode 100644 index 000000000..c9560fb88 --- /dev/null +++ b/src/en/alandal/src/eu/kanade/tachiyomi/extension/en/alandal/Alandal.kt @@ -0,0 +1,189 @@ +package eu.kanade.tachiyomi.extension.en.alandal + +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.interceptor.rateLimit +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 kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromStream +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.Request +import okhttp3.Response +import uy.kohesive.injekt.injectLazy + +class Alandal : HttpSource() { + + override val name = "Alandal" + + override val baseUrl = "https://alandal.com" + private val apiUrl = "https://qq.alandal.com/api" + + override val lang = "en" + + override val supportsLatest = true + + override val client = network.cloudflareClient.newBuilder() + .rateLimit(1) + .build() + + override fun headersBuilder() = super.headersBuilder().apply { + add("Referer", "$baseUrl/") + } + + private val apiHeaders by lazy { apiHeadersBuilder.build() } + + private val apiHeadersBuilder = headersBuilder().apply { + add("Accept", "application/json") + add("Host", apiUrl.toHttpUrl().host) + add("Origin", baseUrl) + add("Sec-Fetch-Dest", "empty") + add("Sec-Fetch-Mode", "cors") + add("Sec-Fetch-Site", "same-origin") + } + + private val json: Json by injectLazy() + + // ============================== Popular =============================== + + override fun popularMangaRequest(page: Int): Request = + searchMangaRequest(page, "", FilterList(SortFilter("popular"))) + + override fun popularMangaParse(response: Response): MangasPage = + searchMangaParse(response) + + // =============================== Latest =============================== + + override fun latestUpdatesRequest(page: Int): Request = + searchMangaRequest(page, "", FilterList(SortFilter("new"))) + + override fun latestUpdatesParse(response: Response): MangasPage = + searchMangaParse(response) + + // =============================== Search =============================== + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + val url = apiUrl.toHttpUrl().newBuilder().apply { + addPathSegment("series") + if (query.isNotBlank()) { + addQueryParameter("name", query) + } + addQueryParameter("type", "comic") + + val filterList = filters.ifEmpty { getFilterList() } + filterList.filterIsInstance().forEach { + it.addToUri(this) + } + + addQueryParameter("page", page.toString()) + }.build() + + return GET(url, apiHeaders) + } + + override fun searchMangaParse(response: Response): MangasPage { + val data = response.parseAs>().data.series + val mangaList = data.data.map { it.toSManga() } + val hasNextPage = data.currentPage < data.lastPage + return MangasPage(mangaList, hasNextPage) + } + + // =============================== Filters ============================== + + override fun getFilterList(): FilterList = FilterList( + GenreFilter(), + SortFilter(), + StatusFilter(), + ) + + // =========================== Manga Details ============================ + + override fun getMangaUrl(manga: SManga): String = + baseUrl + manga.url.replace("series/", "series/comic-") + + override fun mangaDetailsRequest(manga: SManga): Request { + val url = apiUrl.toHttpUrl().newBuilder().apply { + addPathSegments(manga.url.substringAfter("/")) + addQueryParameter("type", "comic") + }.build() + + return GET(url, apiHeaders) + } + + override fun mangaDetailsParse(response: Response): SManga = + response.parseAs>().data.series.toSManga() + + // ============================== Chapters ============================== + + override fun getChapterUrl(chapter: SChapter): String { + return baseUrl + chapter.url + .replace("series/", "chapter/comic-") + .replace("chapters/", "") + } + + override fun chapterListRequest(manga: SManga): Request { + val url = "$apiUrl${manga.url}".toHttpUrl().newBuilder().apply { + addPathSegment("chapters") + addQueryParameter("type", "comic") + addQueryParameter("from", "0") + addQueryParameter("to", "999") + }.build() + + return GET(url, apiHeaders) + } + + override fun chapterListParse(response: Response): List { + val slug = response.request.url.newBuilder() + .query(null) + .removePathSegment(0) // Remove /api + .build() + .encodedPath + + return response.parseAs().data.map { + it.toSChapter(slug) + }.reversed() + } + + // =============================== Pages ================================ + + override fun pageListRequest(chapter: SChapter): Request { + if (chapter.name.startsWith("[LOCKED]")) { + throw Exception("Log in and unlock chapter in webview, then refresh chapter list") + } + + val url = "$apiUrl${chapter.url}".toHttpUrl().newBuilder().apply { + addQueryParameter("type", "comic") + addQueryParameter("traveler", "0") + }.build() + + return GET(url, apiHeaders) + } + + override fun pageListParse(response: Response): List { + val data = response.parseAs().data.chapter.chapter + + return data.pages.mapIndexed { index, s -> + Page(index, imageUrl = s) + } + } + + override fun imageUrlParse(response: Response): String = throw UnsupportedOperationException() + + override fun imageRequest(page: Page): Request { + val pageHeaders = headersBuilder().apply { + add("Accept", "image/avif,image/webp,*/*") + add("Host", page.imageUrl!!.toHttpUrl().host) + }.build() + + return GET(page.imageUrl!!, pageHeaders) + } + + // ============================= Utilities ============================== + + private inline fun Response.parseAs(): T = use { + json.decodeFromStream(it.body.byteStream()) + } +} diff --git a/src/en/alandal/src/eu/kanade/tachiyomi/extension/en/alandal/Dto.kt b/src/en/alandal/src/eu/kanade/tachiyomi/extension/en/alandal/Dto.kt new file mode 100644 index 000000000..3fb0eecd7 --- /dev/null +++ b/src/en/alandal/src/eu/kanade/tachiyomi/extension/en/alandal/Dto.kt @@ -0,0 +1,118 @@ +package eu.kanade.tachiyomi.extension.en.alandal + +import eu.kanade.tachiyomi.source.model.SChapter +import eu.kanade.tachiyomi.source.model.SManga +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.jsoup.Jsoup +import java.text.ParseException +import java.text.SimpleDateFormat +import java.util.Locale + +@Serializable +class ResponseDto( + val data: ResultDto, +) { + @Serializable + class ResultDto( + val series: T, + ) +} + +@Serializable +class SearchSeriesDto( + @SerialName("current_page") val currentPage: Int, + @SerialName("last_page") val lastPage: Int, + val data: List, +) { + @Serializable + class SearchEntryDto( + val name: String, + val slug: String, + val cover: String, + ) { + fun toSManga(): SManga = SManga.create().apply { + title = name + url = "/series/$slug" + thumbnail_url = cover + } + } +} + +@Serializable +class MangaDetailsDto( + val name: String, + val summary: String, + val status: NamedObject, + val genres: List, + val creators: List, + val cover: String, +) { + @Serializable + class NamedObject( + val name: String, + val type: String? = null, + ) + + fun toSManga(): SManga = SManga.create().apply { + title = name + thumbnail_url = cover + description = Jsoup.parseBodyFragment(summary).text() + genre = genres.joinToString { it.name } + author = creators.filter { it.type!! == "author" }.joinToString { it.name } + status = this@MangaDetailsDto.status.name.parseStatus() + } + + private fun String.parseStatus(): Int = when (this.lowercase()) { + "ongoing" -> SManga.ONGOING + "completed" -> SManga.COMPLETED + else -> SManga.UNKNOWN + } +} + +@Serializable +class ChapterResponseDto( + val data: List, +) { + @Serializable + class ChapterDto( + val name: String, + @SerialName("published_at") val published: String, + val access: Boolean, + ) { + fun toSChapter(slug: String): SChapter = SChapter.create().apply { + val prefix = if (access) "" else "[LOCKED] " + name = "${prefix}Chapter ${this@ChapterDto.name}" + date_upload = try { + dateFormat.parse(published)!!.time + } catch (_: ParseException) { + 0L + } + url = "$slug/${this@ChapterDto.name}" + } + + companion object { + val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSSSS'Z'", Locale.ENGLISH) + } + } +} + +@Serializable +class PagesResponseDto( + val data: PagesDataDto, +) { + @Serializable + class PagesDataDto( + val chapter: PagesChapterDto, + ) { + @Serializable + class PagesChapterDto( + val chapter: PagesChapterImagesDto, + ) { + @Serializable + class PagesChapterImagesDto( + val pages: List, + ) + } + } +} diff --git a/src/en/alandal/src/eu/kanade/tachiyomi/extension/en/alandal/Filters.kt b/src/en/alandal/src/eu/kanade/tachiyomi/extension/en/alandal/Filters.kt new file mode 100644 index 000000000..fc499936b --- /dev/null +++ b/src/en/alandal/src/eu/kanade/tachiyomi/extension/en/alandal/Filters.kt @@ -0,0 +1,89 @@ +package eu.kanade.tachiyomi.extension.en.alandal + +import eu.kanade.tachiyomi.source.model.Filter +import okhttp3.HttpUrl + +interface UriFilter { + fun addToUri(builder: HttpUrl.Builder) +} + +open class UriPartFilter( + name: String, + private val param: String, + private val vals: Array>, + defaultValue: String? = null, +) : Filter.Select( + name, + vals.map { it.first }.toTypedArray(), + vals.indexOfFirst { it.second == defaultValue }.takeIf { it != -1 } ?: 0, +), + UriFilter { + override fun addToUri(builder: HttpUrl.Builder) { + builder.addQueryParameter(param, vals[state].second) + } +} + +open class UriMultiSelectOption(name: String, val value: String) : Filter.CheckBox(name) + +open class UriMultiSelectFilter( + name: String, + private val param: String, + private val vals: Array>, +) : Filter.Group(name, vals.map { UriMultiSelectOption(it.first, it.second) }), UriFilter { + override fun addToUri(builder: HttpUrl.Builder) { + val checked = state.filter { it.state } + + if (checked.isEmpty()) { + builder.addQueryParameter(param, "-1") + } else { + checked.forEach { + builder.addQueryParameter(param, it.value) + } + } + } +} + +class GenreFilter : UriMultiSelectFilter( + "Genre", + "genres", + arrayOf( + Pair("Action", "1"), + Pair("Fantasy", "2"), + Pair("Regression", "3"), + Pair("Overpowered", "4"), + Pair("Ascension", "5"), + Pair("Revenge", "6"), + Pair("Martial Arts", "7"), + Pair("Magic", "8"), + Pair("Necromancer", "9"), + Pair("Adventure", "10"), + Pair("Tower", "11"), + Pair("Dungeons", "12"), + Pair("Psychological", "13"), + Pair("Isekai", "14"), + ), +) + +class SortFilter(defaultSort: String? = null) : UriPartFilter( + "Sort By", + "sort", + arrayOf( + Pair("Popularity", "popular"), + Pair("Name", "name"), + Pair("Chapters", "chapters"), + Pair("Rating", "Rating"), + Pair("New", "new"), + ), + defaultSort, +) + +class StatusFilter : UriPartFilter( + "Status", + "status", + arrayOf( + Pair("Any", "-1"), + Pair("Ongoing", "1"), + Pair("Coming Soon", "5"), + Pair("Completed", "6"), + ), +)