Add OTruyen (#11414)

This commit is contained in:
are-are-are 2025-11-04 21:59:12 +07:00 committed by Draff
parent 58fda2ef51
commit 84383a1601
Signed by: Draff
GPG Key ID: E8A89F3211677653
8 changed files with 369 additions and 0 deletions

View File

@ -0,0 +1,7 @@
ext {
extName = 'OTruyen'
extClass = '.OTruyen'
extVersionCode = 1
}
apply from: "$rootDir/common.gradle"

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View File

@ -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<T>(
val data: T,
)
@Serializable
class ListingData(
val items: List<EntriesData>,
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<Category> = 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<String>,
private val content: String,
private val status: String,
@SerialName("thumb_url") private val thumbUrl: String?,
private val author: List<String>,
private val category: List<Category>,
val chapters: List<ChapterDto>,
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<ChapterData>,
)
@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<Page> {
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<PageImage>,
)
@Serializable
class PageImage(
@SerialName("image_file") val imageFile: String,
)
@Serializable
class GenresData(
val items: List<GenreItem>,
)
@Serializable
class GenreItem(
val slug: String,
val name: String,
)
private val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.ROOT)

View File

@ -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<DataDto<ListingData>>()
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<DataDto<EntryData>>()
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<SChapter> {
val res = response.parseAs<DataDto<EntryData>>()
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<Page> {
val res = response.parseAs<DataDto<PageDto>>()
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<GenreList>().isNotEmpty() -> {
val genre = filters.filterIsInstance<GenreList>().first()
listOf("the-loai", genre.values[genre.state].slug) to emptyMap()
}
filters.filterIsInstance<GenreList>().isEmpty() -> {
val status = filters.filterIsInstance<StatusList>().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<Pair<String, String>> {
return response.parseAs<DataDto<GenresData>>().data.items.map { Pair(it.slug, it.name) }
}
private var genreList: List<Pair<String, String>> = 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<Pair<String, String>>) : GenresFilter(name, pairs)
private class StatusList : Filter.Select<Genre>(
"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<Pair<String, String>>) :
Filter.Select<Genre>(
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),
)
}
}
}