diff --git a/src/vi/otruyen/build.gradle b/src/vi/otruyen/build.gradle new file mode 100644 index 000000000..61be3e4da --- /dev/null +++ b/src/vi/otruyen/build.gradle @@ -0,0 +1,7 @@ +ext { + extName = 'OTruyen' + extClass = '.OTruyen' + extVersionCode = 1 +} + +apply from: "$rootDir/common.gradle" diff --git a/src/vi/otruyen/res/mipmap-hdpi/ic_launcher.png b/src/vi/otruyen/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..8fb3883ec Binary files /dev/null and b/src/vi/otruyen/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/vi/otruyen/res/mipmap-mdpi/ic_launcher.png b/src/vi/otruyen/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..302a9c6d7 Binary files /dev/null and b/src/vi/otruyen/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/vi/otruyen/res/mipmap-xhdpi/ic_launcher.png b/src/vi/otruyen/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..d0f2f5a5f Binary files /dev/null and b/src/vi/otruyen/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/vi/otruyen/res/mipmap-xxhdpi/ic_launcher.png b/src/vi/otruyen/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..da435ac14 Binary files /dev/null and b/src/vi/otruyen/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/vi/otruyen/res/mipmap-xxxhdpi/ic_launcher.png b/src/vi/otruyen/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..b70496656 Binary files /dev/null and b/src/vi/otruyen/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/vi/otruyen/src/eu/kanade/tachiyomi/extension/vi/otruyen/Dto.kt b/src/vi/otruyen/src/eu/kanade/tachiyomi/extension/vi/otruyen/Dto.kt new file mode 100644 index 000000000..7ce799d1a --- /dev/null +++ b/src/vi/otruyen/src/eu/kanade/tachiyomi/extension/vi/otruyen/Dto.kt @@ -0,0 +1,153 @@ +package eu.kanade.tachiyomi.extension.vi.otruyen + +import eu.kanade.tachiyomi.source.model.Page +import eu.kanade.tachiyomi.source.model.SChapter +import eu.kanade.tachiyomi.source.model.SManga +import keiyoushi.utils.tryParse +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.jsoup.Jsoup +import java.text.SimpleDateFormat +import java.util.Locale +import kotlin.collections.mapIndexed + +@Serializable +class DataDto( + val data: T, +) + +@Serializable +class ListingData( + val items: List, + val params: ParamsListing, +) + +@Serializable +class ParamsListing( + val pagination: Pagination, +) + +@Serializable +class Pagination( + val totalItems: Int, + val totalItemsPerPage: Int, + val currentPage: Int, +) + +@Serializable +class EntriesData( + private val name: String, + private val slug: String, + @SerialName("thumb_url") private val thumbUrl: String?, + private val category: List = emptyList(), + +) { + fun toSManga(imgUrl: String): SManga = SManga.create().apply { + url = slug + title = name + thumbnail_url = thumbUrl?.let { "$imgUrl/$it" } + genre = category.joinToString { it.name } + } +} + +@Serializable +class Category( + val name: String, +) + +@Serializable +class EntryData( + val item: Entry, +) + +@Serializable +class Entry( + private val name: String, + val slug: String, + @SerialName("origin_name") private val originName: List, + private val content: String, + private val status: String, + @SerialName("thumb_url") private val thumbUrl: String?, + private val author: List, + private val category: List, + val chapters: List, + val updatedAt: String, +) { + fun toSManga(imgUrl: String): SManga = SManga.create().apply { + val entry = this@Entry + author = entry.author.joinToString() + val altNames = originName.filter { it.isNotBlank() } + val descText = Jsoup.parse(content).select("p").joinToString("\n") { it.wholeText() } + description = buildString { + if (altNames.isNotEmpty()) { + append("Tên khác: ${altNames.joinToString()}\n\n") + } + append(descText) + } + genre = category.joinToString { it.name } + title = name + thumbnail_url = thumbUrl?.let { "$imgUrl/$it" } + status = when (entry.status) { + "ongoing" -> SManga.ONGOING + "completed" -> SManga.COMPLETED + "coming_soon" -> SManga.ONGOING + else -> SManga.UNKNOWN + } + } +} + +@Serializable +class ChapterDto( + @SerialName("server_data") val serverData: List, +) + +@Serializable +class ChapterData( + @SerialName("chapter_name") private val chapterName: String, + @SerialName("chapter_title") private val chapterTitle: String? = null, + @SerialName("chapter_api_data") private val chapterApiData: String, +) { + fun toSChapter(date: String, mangaUrl: String): SChapter = SChapter.create().apply { + val chapterId = chapterApiData.substringAfterLast("/") + name = "Chapter " + chapterName + (chapterTitle?.let { " : $it" } ?: "") + date_upload = dateFormat.tryParse(date) // API has no date for chapter → temporarily use updatedAt of entry + chapter_number = chapterName.toFloatOrNull() ?: 0f + url = "$chapterId:$mangaUrl" + } +} + +@Serializable +class PageDto( + @SerialName("domain_cdn") val domainCdn: String, + private val item: PageItem, +) { + fun toPage(): List { + val url = "$domainCdn/${item.chapterPath}/" + return item.chapterImage.mapIndexed { index, image -> + Page(index, imageUrl = url + image.imageFile) + } + } +} + +@Serializable +class PageItem( + @SerialName("chapter_path") val chapterPath: String, + @SerialName("chapter_image") val chapterImage: List, +) + +@Serializable +class PageImage( + @SerialName("image_file") val imageFile: String, +) + +@Serializable +class GenresData( + val items: List, +) + +@Serializable +class GenreItem( + val slug: String, + val name: String, +) +private val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.ROOT) diff --git a/src/vi/otruyen/src/eu/kanade/tachiyomi/extension/vi/otruyen/OTruyen.kt b/src/vi/otruyen/src/eu/kanade/tachiyomi/extension/vi/otruyen/OTruyen.kt new file mode 100644 index 000000000..d71f840fc --- /dev/null +++ b/src/vi/otruyen/src/eu/kanade/tachiyomi/extension/vi/otruyen/OTruyen.kt @@ -0,0 +1,209 @@ +package eu.kanade.tachiyomi.extension.vi.otruyen + +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.await +import eu.kanade.tachiyomi.network.interceptor.rateLimit +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 keiyoushi.utils.parseAs +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import okhttp3.Headers +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import kotlin.collections.flatMap +import kotlin.collections.map + +class OTruyen : HttpSource() { + + override val name: String = "OTruyen" + + override val lang: String = "vi" + + override val supportsLatest: Boolean = true + + private val domainName = "otruyen" + + override val baseUrl: String = "https://$domainName.cc" + + private val domainApi = "${domainName}api.com" + + private val apiUrl = "https://$domainApi/v1/api" + + private val cdnUrl = "https://sv1.${domainName}cdn.com" + + private val imgUrl = "https://img.$domainApi/uploads/comics" + + override val client: OkHttpClient = network.cloudflareClient.newBuilder() + .rateLimit(3) + .build() + + override fun headersBuilder(): Headers.Builder = super.headersBuilder() + .add("Referer", "$baseUrl/") + + override fun latestUpdatesRequest(page: Int): Request { + return GET("$apiUrl/danh-sach/truyen-moi?page=$page", headers) + } + + override fun latestUpdatesParse(response: Response): MangasPage { + val res = response.parseAs>() + val pagination = res.data.params.pagination + val totalPages = (pagination.totalItems + pagination.totalItemsPerPage - 1) / pagination.totalItemsPerPage + val manga = res.data.items.map { it.toSManga(imgUrl) } + val hasNextPage = pagination.currentPage < totalPages + return MangasPage(manga, hasNextPage) + } + + override fun popularMangaRequest(page: Int): Request { + return GET("$apiUrl/danh-sach/hoan-thanh?page=$page", headers) + } + + override fun popularMangaParse(response: Response) = latestUpdatesParse(response) + + override fun mangaDetailsRequest(manga: SManga): Request { + return GET("$apiUrl/truyen-tranh/${manga.url}", headers) + } + + override fun mangaDetailsParse(response: Response): SManga { + val res = response.parseAs>() + return res.data.item.toSManga(imgUrl) + } + + override fun getMangaUrl(manga: SManga): String { + return "$baseUrl/truyen-tranh/${manga.url}" + } + + override fun chapterListRequest(manga: SManga): Request { + return mangaDetailsRequest(manga) + } + + override fun chapterListParse(response: Response): List { + val res = response.parseAs>() + val mangaUrl = res.data.item.slug + val date = res.data.item.updatedAt + return res.data.item.chapters + .flatMap { server -> server.serverData.map { it.toSChapter(date, mangaUrl) } } + .sortedByDescending { it.chapter_number } + } + + override fun getChapterUrl(chapter: SChapter): String { + val mangaUrl = chapter.url.substringAfter(":") + return "$baseUrl/truyen-tranh/$mangaUrl" + } + + override fun imageUrlParse(response: Response): String = throw UnsupportedOperationException() + + override fun pageListRequest(chapter: SChapter): Request { + val chapterId = chapter.url.substringBefore(":") + return GET("$cdnUrl/v1/api/chapter/$chapterId", headers) + } + + override fun pageListParse(response: Response): List { + val res = response.parseAs>() + return res.data.toPage() + } + + override fun searchMangaParse(response: Response) = latestUpdatesParse(response) + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + val (segments, params) = when { + query.isNotBlank() -> { + listOf("tim-kiem") to mapOf("keyword" to query) + } + filters.filterIsInstance().isNotEmpty() -> { + val genre = filters.filterIsInstance().first() + listOf("the-loai", genre.values[genre.state].slug) to emptyMap() + } + filters.filterIsInstance().isEmpty() -> { + val status = filters.filterIsInstance().first() + listOf("danh-sach", status.values[status.state].slug) to emptyMap() + } + else -> { + listOf("danh-sach", "dang-phat-hanh") to emptyMap() + } + } + + val url = apiUrl.toHttpUrl().newBuilder().apply { + segments.forEach { addPathSegment(it) } + addQueryParameter("page", "$page") + params.forEach { (k, v) -> addQueryParameter(k, v) } + }.build() + + return GET(url, headers) + } + + private fun genresRequest(): Request = GET("$apiUrl/the-loai", headers) + + private fun parseGenres(response: Response): List> { + return response.parseAs>().data.items.map { Pair(it.slug, it.name) } + } + + private var genreList: List> = emptyList() + + private var fetchGenresAttempts: Int = 0 + private fun fetchGenres() { + launchIO { + try { + client.newCall(genresRequest()).await() + .use { parseGenres(it) } + .takeIf { it.isNotEmpty() } + ?.also { genreList = it } + } catch (_: Exception) { + } finally { + fetchGenresAttempts++ + } + } + } + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + private fun launchIO(block: suspend () -> Unit) = scope.launch { block() } + + private class GenreList(name: String, pairs: List>) : GenresFilter(name, pairs) + + private class StatusList : Filter.Select( + "Trạng thái", + arrayOf( + Genre("Mới nhất", "truyen-moi"), + Genre("Đang phát hành", "dang-phat-hanh"), + Genre("Hoàn thành", "hoan-thanh"), + Genre("Sắp ra mắt", "sap-ra-mat"), + ), + ) + + private open class GenresFilter(title: String, pairs: List>) : + Filter.Select( + title, + pairs.map { Genre(it.second, it.first) }.toTypedArray(), + ) + + private class Genre(val name: String, val slug: String) { + override fun toString() = name + } + + override fun getFilterList(): FilterList { + fetchGenres() + return if (genreList.isEmpty()) { + FilterList( + Filter.Header("Nhấn 'Làm mới' để hiển thị thể loại"), + Filter.Header("Hiển thị thể loại sẽ ẩn danh sách trạng thái vì không dùng chung được"), + Filter.Header("Không dùng chung được với tìm kiếm bằng tên"), + StatusList(), + ) + } else { + FilterList( + Filter.Header("Không dùng chung được với tìm kiếm bằng tên"), + GenreList("Thể loại", genreList), + ) + } + } +}