diff --git a/src/vi/yurineko/AndroidManifest.xml b/src/vi/yurineko/AndroidManifest.xml
new file mode 100644
index 000000000..2e9289da3
--- /dev/null
+++ b/src/vi/yurineko/AndroidManifest.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/vi/yurineko/build.gradle b/src/vi/yurineko/build.gradle
new file mode 100644
index 000000000..5e76d547a
--- /dev/null
+++ b/src/vi/yurineko/build.gradle
@@ -0,0 +1,13 @@
+apply plugin: 'com.android.application'
+apply plugin: 'kotlin-android'
+apply plugin: 'kotlinx-serialization'
+
+ext {
+ extName = 'YuriNeko'
+ pkgNameSuffix = 'vi.yurineko'
+ extClass = '.YuriNeko'
+ extVersionCode = 1
+ isNsfw = true
+}
+
+apply from: "$rootDir/common.gradle"
diff --git a/src/vi/yurineko/res/mipmap-hdpi/ic_launcher.png b/src/vi/yurineko/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 000000000..c3d989632
Binary files /dev/null and b/src/vi/yurineko/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/src/vi/yurineko/res/mipmap-mdpi/ic_launcher.png b/src/vi/yurineko/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 000000000..ae69ab49f
Binary files /dev/null and b/src/vi/yurineko/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/src/vi/yurineko/res/mipmap-xhdpi/ic_launcher.png b/src/vi/yurineko/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 000000000..e5f4765d1
Binary files /dev/null and b/src/vi/yurineko/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/src/vi/yurineko/res/mipmap-xxhdpi/ic_launcher.png b/src/vi/yurineko/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 000000000..037377b7a
Binary files /dev/null and b/src/vi/yurineko/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/src/vi/yurineko/res/mipmap-xxxhdpi/ic_launcher.png b/src/vi/yurineko/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 000000000..fb0201174
Binary files /dev/null and b/src/vi/yurineko/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/src/vi/yurineko/res/web_hi_res_512.png b/src/vi/yurineko/res/web_hi_res_512.png
new file mode 100644
index 000000000..9023d2c74
Binary files /dev/null and b/src/vi/yurineko/res/web_hi_res_512.png differ
diff --git a/src/vi/yurineko/src/eu/kanade/tachiyomi/extension/vi/yurineko/YuriNeko.kt b/src/vi/yurineko/src/eu/kanade/tachiyomi/extension/vi/yurineko/YuriNeko.kt
new file mode 100644
index 000000000..73b46feec
--- /dev/null
+++ b/src/vi/yurineko/src/eu/kanade/tachiyomi/extension/vi/yurineko/YuriNeko.kt
@@ -0,0 +1,402 @@
+package eu.kanade.tachiyomi.extension.vi.yurineko
+
+import eu.kanade.tachiyomi.extension.vi.yurineko.dto.ErrorResponseDto
+import eu.kanade.tachiyomi.extension.vi.yurineko.dto.MangaDto
+import eu.kanade.tachiyomi.extension.vi.yurineko.dto.MangaListDto
+import eu.kanade.tachiyomi.extension.vi.yurineko.dto.ReadResponseDto
+import eu.kanade.tachiyomi.extension.vi.yurineko.dto.UserDto
+import eu.kanade.tachiyomi.network.GET
+import eu.kanade.tachiyomi.network.asObservableSuccess
+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 kotlinx.serialization.decodeFromString
+import kotlinx.serialization.json.Json
+import okhttp3.CacheControl
+import okhttp3.Headers
+import okhttp3.HttpUrl.Companion.toHttpUrl
+import okhttp3.Interceptor
+import okhttp3.Request
+import okhttp3.Response
+import rx.Observable
+import java.io.IOException
+import java.net.URLDecoder
+import java.util.concurrent.TimeUnit
+
+class YuriNeko : HttpSource() {
+
+ override val name = "YuriNeko"
+
+ override val baseUrl = "https://yurineko.net"
+
+ override val lang = "vi"
+
+ override val supportsLatest = false
+
+ private val apiUrl = "https://api.yurineko.net"
+
+ override val client = network.cloudflareClient.newBuilder()
+ .rateLimit(3, 1, TimeUnit.SECONDS)
+ .addInterceptor { authIntercept(it) }
+ .addInterceptor { chain ->
+ val response = chain.proceed(chain.request())
+
+ if (response.code >= 400 && response.body != null) {
+ val error = response.parseAs()
+ response.close()
+ throw IOException("${error.message}\nĐăng nhập qua WebView và thử lại.")
+ }
+ response
+ }.build()
+
+ override fun headersBuilder() = Headers.Builder().add("Referer", baseUrl)
+
+ private fun authIntercept(chain: Interceptor.Chain): Response {
+ val request = chain.request()
+ val cookies = client.cookieJar.loadForRequest(baseUrl.toHttpUrl())
+ val authCookie = cookies
+ .firstOrNull { it.name == "user" }
+ ?.let { URLDecoder.decode(it.value, "UTF-8") }
+ ?.let { json.decodeFromString(it) }
+ ?: return chain.proceed(request)
+
+ val authRequest = request.newBuilder().apply {
+ addHeader("Authorization", "Bearer ${authCookie.token}")
+ }.build()
+ return chain.proceed(authRequest)
+ }
+
+ override fun popularMangaRequest(page: Int): Request = GET(
+ url = apiUrl.toHttpUrl().newBuilder().apply {
+ addPathSegment("lastest2")
+ addQueryParameter("page", page.toString())
+ }.build().toString(),
+ cache = CacheControl.FORCE_NETWORK
+ )
+
+ override fun popularMangaParse(response: Response): MangasPage {
+ val mangaListDto = response.parseAs()
+ val currentPage = response.request.url.queryParameter("page")!!.toFloat()
+ return mangaListDto.toMangasPage(currentPage)
+ }
+
+ override fun latestUpdatesRequest(page: Int): Request = throw Exception("Not used")
+
+ override fun latestUpdatesParse(response: Response): MangasPage = throw Exception("Not used")
+
+ override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable {
+ return when {
+ query.startsWith(PREFIX_ID_SEARCH) -> {
+ val id = query.removePrefix(PREFIX_ID_SEARCH).trim()
+ if (id.toIntOrNull() == null) {
+ throw Exception("ID tìm kiếm không hợp lệ (phải là một số).")
+ }
+ fetchMangaDetails(
+ SManga.create().apply {
+ url = "/manga/$id"
+ }
+ )
+ .map { MangasPage(listOf(it), false) }
+ }
+ else -> super.fetchSearchManga(page, query, filters)
+ }
+ }
+
+ override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
+ return when {
+ query.startsWith(PREFIX_TAG_SEARCH) ||
+ query.startsWith(PREFIX_COUPLE_SEARCH) ||
+ query.startsWith(PREFIX_DOUJIN_SEARCH) ||
+ query.startsWith(PREFIX_AUTHOR_SEARCH) ||
+ query.startsWith(PREFIX_TEAM_SEARCH) -> {
+ val items = query.split(":")
+ val searchType = items[0]
+ val actualQuery = items[1].trim()
+ if (actualQuery.toIntOrNull() == null) {
+ throw Exception("ID tìm kiếm không hợp lệ (phải là một số).")
+ }
+ GET(
+ apiUrl.toHttpUrl().newBuilder().apply {
+ addPathSegment("searchType")
+ addQueryParameter("type", searchType)
+ addQueryParameter("id", actualQuery)
+ addQueryParameter("page", page.toString())
+ }.build().toString()
+ )
+ }
+ query.isNotEmpty() -> {
+ GET(
+ apiUrl.toHttpUrl().newBuilder().apply {
+ addPathSegment("search")
+ addQueryParameter("query", query)
+ addQueryParameter("page", page.toString())
+ }.build().toString()
+ )
+ }
+ else -> {
+ for (filter in (if (filters.isEmpty()) getFilterList() else filters)) {
+ when (filter) {
+ is UriPartFilter -> if (filter.state != 0) {
+ when (filter.name) {
+ "Tag" -> return GET(
+ apiUrl.toHttpUrl().newBuilder().apply {
+ addPathSegment("searchType")
+ addQueryParameter("type", "tag")
+ addQueryParameter("id", filter.toUriPart())
+ addQueryParameter("page", page.toString())
+ }.build().toString()
+ )
+ else -> continue
+ }
+ }
+ else -> continue
+ }
+ }
+ return popularMangaRequest(page)
+ }
+ }
+ }
+
+ override fun searchMangaParse(response: Response): MangasPage = popularMangaParse(response)
+
+ override fun fetchMangaDetails(manga: SManga): Observable =
+ client.newCall(GET("$apiUrl${manga.url}"))
+ .asObservableSuccess()
+ .map { mangaDetailsParse(it) }
+
+ override fun mangaDetailsRequest(manga: SManga): Request = GET("$baseUrl${manga.url}")
+
+ override fun mangaDetailsParse(response: Response): SManga =
+ response.parseAs().toSManga()
+
+ override fun chapterListRequest(manga: SManga): Request = GET("$apiUrl${manga.url}")
+
+ override fun chapterListParse(response: Response): List {
+ val mangaDto = response.parseAs()
+ val scanlator = mangaDto.team.joinToString(", ") { it.name }
+ return mangaDto.chapters?.map { it.toSChapter(scanlator) } ?: emptyList()
+ }
+
+ override fun pageListRequest(chapter: SChapter): Request = GET("$apiUrl${chapter.url}")
+
+ override fun pageListParse(response: Response): List =
+ response.parseAs().toPageList()
+
+ override fun imageUrlParse(response: Response): String = throw Exception("Not used")
+
+ open class UriPartFilter(displayName: String, private val vals: Array>) :
+ Filter.Select(displayName, vals.map { it.first }.toTypedArray()) {
+ fun toUriPart() = vals[state].second
+ }
+
+ override fun getFilterList() = FilterList(
+ Filter.Header("Lưu ý rằng không thể vừa tìm kiếm vừa lọc bằng tag cùng lúc."),
+ Filter.Header("Tìm kiếm sẽ được ưu tiên."),
+ UriPartFilter("Tag", getGenreList())
+ )
+
+ private fun getGenreList() = arrayOf(
+ Pair("Sao cũng được", "0"),
+ Pair("4-koma", "149"),
+ Pair(">", "306"),
+ Pair("Action", "113"),
+ Pair("Adventure", "114"),
+ Pair("Adult Life", "143"),
+ Pair("Animal Ears", "175"),
+ Pair("Age Gap", "179"),
+ Pair("Anal", "209"),
+ Pair("Ahegao", "211"),
+ Pair("Anime", "214"),
+ Pair("Amnesia", "242"),
+ Pair("Autobiographical", "255"),
+ Pair("Alien", "262"),
+ Pair("Amputee", "277"),
+ Pair("Assassin", "283"),
+ Pair("Angel", "298"),
+ Pair("Abuse", "300"),
+ Pair("Anilingus", "308"),
+ Pair("Blushing", "157"),
+ Pair("Body Swap", "158"),
+ Pair("Bisexual", "176"),
+ Pair("Birthday", "194"),
+ Pair("Big Breasts", "195"),
+ Pair("Butts", "196"),
+ Pair("BDSM", "199"),
+ Pair("Boob Sex", "210"),
+ Pair("Bath", "226"),
+ Pair("Bullying", "241"),
+ Pair("Biting", "270"),
+ Pair("Blackmail", "280"),
+ Pair("Biographical", "285"),
+ Pair("Beach", "289"),
+ Pair("BHTT", "304"),
+ Pair("Comedy", "115"),
+ Pair("College", "145"),
+ Pair("Co-worker", "180"),
+ Pair("Childhood Friends", "182"),
+ Pair("Christmas", "189"),
+ Pair("Creepy", "220"),
+ Pair("Childification", "239"),
+ Pair("Cheating", "267"),
+ Pair("Clones", "271"),
+ Pair("Cross-dressing", "288"),
+ Pair("Chibi", "307"),
+ Pair("Demon", "116"),
+ Pair("Drama", "117"),
+ Pair("Dark Skin", "208"),
+ Pair("Drunk", "219"),
+ Pair("Drugs", "236"),
+ Pair("Disability", "252"),
+ Pair("Delinquent", "258"),
+ Pair("Deity", "265"),
+ Pair("Depressing as fuck", "290"),
+ Pair("Ecchi", "118"),
+ Pair("Excuse me WTF?", "161"),
+ Pair("Exhibitionism", "245"),
+ Pair("Fantasy", "119"),
+ Pair("Full Color", "148"),
+ Pair("FBI Warning!!", "163"),
+ Pair("Futanari", "201"),
+ Pair("Food", "232"),
+ Pair("Feet", "256"),
+ Pair("Furry", "303"),
+ Pair("Game", "120"),
+ Pair("Gender Bender", "121"),
+ Pair("Glasses", "156"),
+ Pair("Guro", "206"),
+ Pair("Ghost", "244"),
+ Pair("Gyaru", "246"),
+ Pair("Harem", "122"),
+ Pair("Historical", "123"),
+ Pair("Horror", "124"),
+ Pair("Hints", "152"),
+ Pair("Het", "160"),
+ Pair("Halloween", "190"),
+ Pair("Hypnosis", "254"),
+ Pair("Height Gap", "281"),
+ Pair("Hardcore", "292"),
+ Pair("Isekai", "144"),
+ Pair("Idol", "169"),
+ Pair("Incest", "187"),
+ Pair("Idiot Couple", "282"),
+ Pair("Introspective", "286"),
+ Pair("Insane Amounts of Sex", "296"),
+ Pair("Kuudere", "235"),
+ Pair("Lỗi: không tìm thấy trai", "153"),
+ Pair("Love Triangle", "183"),
+ Pair("Loli", "197"),
+ Pair("Light Novel", "216"),
+ Pair("Lactation", "260"),
+ Pair("Lots of sex", "269"),
+ Pair("Martial Arts", "125"),
+ Pair("Mecha", "126"),
+ Pair("Military", "127"),
+ Pair("Music", "128"),
+ Pair("Mystery", "129"),
+ Pair("Manhua", "146"),
+ Pair("Manhwa", "147"),
+ Pair("Moe Paradise", "164"),
+ Pair("Mahou Shoujo", "168"),
+ Pair("Maid", "172"),
+ Pair("Monster Girl", "173"),
+ Pair("Marriage", "188"),
+ Pair("Massage", "204"),
+ Pair("Masturbation", "205"),
+ Pair("Mangaka", "227"),
+ Pair("Mermaid", "234"),
+ Pair("Moderate amounts of sex", "268"),
+ Pair("Miko", "301"),
+ Pair("No Text", "150"),
+ Pair("New Year's", "191"),
+ Pair("Netorare", "198"),
+ Pair("NSFW", "229"),
+ Pair("Ninja", "287"),
+ Pair("Non-moe art", "302"),
+ Pair("Office Lady", "174"),
+ Pair("Oneshot", "218"),
+ Pair("Official", "222"),
+ Pair("Orgy", "261"),
+ Pair("Omegaverse", "276"),
+ Pair("Parody", "130"),
+ Pair("Psychological", "131"),
+ Pair("Pay for Gay", "162"),
+ Pair("Polyamory", "185"),
+ Pair("Pocky Game", "212"),
+ Pair("Prostitution", "240"),
+ Pair("Player", "257"),
+ Pair("Prequel", "272"),
+ Pair("Post-Apocalyptic", "273"),
+ Pair("Philosophical", "274"),
+ Pair("R18", "1"),
+ Pair("Romance", "132"),
+ Pair("Reversal", "159"),
+ Pair("Roommates", "181"),
+ Pair("Rape", "203"),
+ Pair("Robot", "264"),
+ Pair("School Life", "133"),
+ Pair("Sci-Fi", "134"),
+ Pair("Slice of Life", "137"),
+ Pair("Sports", "138"),
+ Pair("Supernatural", "139"),
+ Pair("Science Babies", "165"),
+ Pair("Student x Teacher", "166"),
+ Pair("Siscon", "167"),
+ Pair("School Girl", "215"),
+ Pair("Spin-off", "223"),
+ Pair("Subtext", "231"),
+ Pair("Sleeping", "249"),
+ Pair("Sequel", "251"),
+ Pair("Swimsuits", "263"),
+ Pair("Stalking", "266"),
+ Pair("Space", "291"),
+ Pair("Spanking", "299"),
+ Pair("Tragedy", "142"),
+ Pair("Tomboy", "170"),
+ Pair("Tsundere", "177"),
+ Pair("Threesome", "184"),
+ Pair("Twins", "186"),
+ Pair("Thất Tịch", "193"),
+ Pair("Toys", "200"),
+ Pair("Tentacles", "202"),
+ Pair("Tailsex", "237"),
+ Pair("Time Travel", "243"),
+ Pair("Transgender", "284"),
+ Pair("Vampire", "140"),
+ Pair("Violence", "141"),
+ Pair("Valentine", "192"),
+ Pair("Watersports", "278"),
+ Pair("Wholesome", "279"),
+ Pair("Witch", "293"),
+ Pair("Web Novel", "305"),
+ Pair("Yuri", "151"),
+ Pair("Yankee", "171"),
+ Pair("Yandere", "178"),
+ Pair("Yuri Crush", "228"),
+ Pair("Yaoi", "230"),
+ Pair("Zombies", "238"),
+ )
+
+ private val json = Json {
+ isLenient = true
+ ignoreUnknownKeys = true
+ prettyPrint = true
+ }
+
+ private inline fun Response.parseAs(): T = use {
+ json.decodeFromString(body?.string().orEmpty())
+ }
+
+ companion object {
+ const val PREFIX_ID_SEARCH = "id:"
+ const val PREFIX_TAG_SEARCH = "tag:"
+ const val PREFIX_TEAM_SEARCH = "team:"
+ const val PREFIX_AUTHOR_SEARCH = "author:"
+ const val PREFIX_DOUJIN_SEARCH = "origin:"
+ const val PREFIX_COUPLE_SEARCH = "couple:"
+ }
+}
diff --git a/src/vi/yurineko/src/eu/kanade/tachiyomi/extension/vi/yurineko/YuriNekoUrlActivity.kt b/src/vi/yurineko/src/eu/kanade/tachiyomi/extension/vi/yurineko/YuriNekoUrlActivity.kt
new file mode 100644
index 000000000..76ecf4297
--- /dev/null
+++ b/src/vi/yurineko/src/eu/kanade/tachiyomi/extension/vi/yurineko/YuriNekoUrlActivity.kt
@@ -0,0 +1,47 @@
+package eu.kanade.tachiyomi.extension.vi.yurineko
+
+import android.app.Activity
+import android.content.ActivityNotFoundException
+import android.content.Intent
+import android.os.Bundle
+import android.util.Log
+import kotlin.system.exitProcess
+
+class YuriNekoUrlActivity : Activity() {
+ private fun prefixDeterminer(path: String): String? = when (path) {
+ "manga" -> YuriNeko.PREFIX_ID_SEARCH
+ "origin" -> YuriNeko.PREFIX_DOUJIN_SEARCH
+ "author" -> YuriNeko.PREFIX_AUTHOR_SEARCH
+ "tag" -> YuriNeko.PREFIX_TAG_SEARCH
+ "couple" -> YuriNeko.PREFIX_COUPLE_SEARCH
+ "team" -> YuriNeko.PREFIX_TEAM_SEARCH
+ else -> null
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ val pathSegments = intent?.data?.pathSegments
+ if (pathSegments != null &&
+ pathSegments.size > 2 &&
+ prefixDeterminer(pathSegments[1]) != null
+ ) {
+ val id = pathSegments[2]
+ try {
+ startActivity(
+ Intent().apply {
+ action = "eu.kanade.tachiyomi.SEARCH"
+ putExtra("query", "${prefixDeterminer(pathSegments[1])}$id")
+ putExtra("filter", packageName)
+ }
+ )
+ } catch (e: ActivityNotFoundException) {
+ Log.e("YuriNekoUrlActivity", e.toString())
+ }
+ } else {
+ Log.e("YuriNekoUrlActivity", "Could not parse URI from intent $intent")
+ }
+
+ finish()
+ exitProcess(0)
+ }
+}
diff --git a/src/vi/yurineko/src/eu/kanade/tachiyomi/extension/vi/yurineko/dto/ChapterDto.kt b/src/vi/yurineko/src/eu/kanade/tachiyomi/extension/vi/yurineko/dto/ChapterDto.kt
new file mode 100644
index 000000000..b41a2980c
--- /dev/null
+++ b/src/vi/yurineko/src/eu/kanade/tachiyomi/extension/vi/yurineko/dto/ChapterDto.kt
@@ -0,0 +1,54 @@
+package eu.kanade.tachiyomi.extension.vi.yurineko.dto
+
+import eu.kanade.tachiyomi.source.model.Page
+import eu.kanade.tachiyomi.source.model.SChapter
+import kotlinx.serialization.Serializable
+import java.text.SimpleDateFormat
+import java.util.Locale
+import java.util.TimeZone
+
+val DATE_FORMATTER = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply {
+ timeZone = TimeZone.getTimeZone("Asia/Ho_Chi_Minh")
+}
+
+val FLOATING_NUMBER_REGEX = Regex("""([+-]?(?:[0-9]*[.])?[0-9]+)""")
+
+@Serializable
+data class ChapterDto(
+ val id: Int,
+ val name: String,
+ val date: String? = null,
+ val mangaID: Int? = null,
+ val maxID: Int? = null,
+ val likeCount: Int? = null,
+) {
+ fun toSChapter(teams: String): SChapter = SChapter.create().apply {
+ val dto = this@ChapterDto
+ url = "/read/${dto.mangaID}/${dto.id}"
+ name = dto.name
+ if (!dto.date.isNullOrEmpty()) {
+ date_upload = runCatching {
+ DATE_FORMATTER.parse(dto.date)?.time
+ }.getOrNull() ?: 0L
+ }
+
+ val match = FLOATING_NUMBER_REGEX.find(dto.name)
+ chapter_number = if (dto.name.lowercase().startsWith("vol")) {
+ match?.groups?.get(2)
+ } else {
+ match?.groups?.get(1)
+ }?.value?.toFloat() ?: -1f
+ scanlator = teams
+ }
+}
+
+@Serializable
+data class ReadResponseDto(
+ val listChapter: List,
+ val chapterInfo: ChapterDto,
+ val url: List,
+) {
+ fun toPageList(): List = this@ReadResponseDto
+ .url
+ .mapIndexed { index, url -> Page(index, "", url) }
+}
diff --git a/src/vi/yurineko/src/eu/kanade/tachiyomi/extension/vi/yurineko/dto/MangaDto.kt b/src/vi/yurineko/src/eu/kanade/tachiyomi/extension/vi/yurineko/dto/MangaDto.kt
new file mode 100644
index 000000000..8e4ca2b33
--- /dev/null
+++ b/src/vi/yurineko/src/eu/kanade/tachiyomi/extension/vi/yurineko/dto/MangaDto.kt
@@ -0,0 +1,81 @@
+package eu.kanade.tachiyomi.extension.vi.yurineko.dto
+
+import eu.kanade.tachiyomi.source.model.MangasPage
+import eu.kanade.tachiyomi.source.model.SManga
+import kotlinx.serialization.Serializable
+import org.jsoup.Jsoup
+import org.jsoup.select.Evaluator
+import kotlin.math.ceil
+
+@Serializable
+data class MangaDto(
+ val id: Int,
+ val originalName: String,
+ val otherName: String,
+ val description: String,
+ val status: Int,
+ val thumbnail: String,
+ val type: String,
+ val lastUpdate: String,
+ val totalView: Int? = null,
+ val totalFollow: Int? = null,
+ val likeCount: Int? = null,
+ val team: List,
+ val origin: List,
+ val author: List,
+ val tag: List,
+ val couple: List,
+ val lastChapter: ChapterDto? = null,
+ val chapters: List? = null,
+) {
+ fun toSManga(): SManga = SManga.create().apply {
+ val dto = this@MangaDto
+ url = "/manga/${dto.id}"
+ title = dto.originalName
+ author = dto.author.joinToString(", ") { author -> author.name }
+
+ val descElem = Jsoup.parseBodyFragment(dto.description)
+ description = if (descElem.select("p").any()) {
+ Jsoup.parse(dto.description).select("p").joinToString("\n") {
+ it.run {
+ select(Evaluator.Tag("br")).prepend("\\n")
+ this.text().replace("\\n", "\n").replace("\n ", "\n")
+ }
+ }.trim()
+ } else {
+ dto.description
+ }
+
+ if (dto.otherName.isNotEmpty()) {
+ description = "Tên khác: ${dto.otherName}\n\n" + description
+ }
+
+ genre = dto.tag.joinToString(", ") { tag -> tag.name }
+ status = when (dto.status) {
+ 1 -> SManga.UNKNOWN // "Chưa ra mắt" -> Not released
+ 2 -> SManga.COMPLETED
+ 3 -> SManga.UNKNOWN // "Sắp ra mắt" -> Upcoming
+ 4 -> SManga.ONGOING
+ 5 -> SManga.CANCELLED // "Ngừng dịch" -> source not translating it anymomre
+ 6 -> SManga.ON_HIATUS
+ 7 -> SManga.CANCELLED // "Ngừng xuất bản" -> No more publications
+ else -> SManga.UNKNOWN
+ }
+ thumbnail_url = dto.thumbnail
+ initialized = true
+ }
+}
+
+@Serializable
+data class MangaListDto(
+ val result: List,
+ val resultCount: Int,
+) {
+ fun toMangasPage(currentPage: Float = 1f): MangasPage {
+ val dto = this@MangaListDto
+ return MangasPage(
+ dto.result.map { it.toSManga() },
+ currentPage + 1f <= ceil(dto.resultCount.toFloat() / 20f)
+ )
+ }
+}
diff --git a/src/vi/yurineko/src/eu/kanade/tachiyomi/extension/vi/yurineko/dto/MiscDto.kt b/src/vi/yurineko/src/eu/kanade/tachiyomi/extension/vi/yurineko/dto/MiscDto.kt
new file mode 100644
index 000000000..082dbfd93
--- /dev/null
+++ b/src/vi/yurineko/src/eu/kanade/tachiyomi/extension/vi/yurineko/dto/MiscDto.kt
@@ -0,0 +1,22 @@
+package eu.kanade.tachiyomi.extension.vi.yurineko.dto
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class ErrorResponseDto(
+ val message: String? = null,
+)
+
+@Serializable
+data class UserDto(
+ val id: Int,
+ val name: String,
+ val email: String,
+ val avatar: String,
+ val role: Int,
+ val money: Int,
+ val username: String,
+ val isBanned: Int,
+ val isPremium: Int,
+ val token: String,
+)
diff --git a/src/vi/yurineko/src/eu/kanade/tachiyomi/extension/vi/yurineko/dto/TagDto.kt b/src/vi/yurineko/src/eu/kanade/tachiyomi/extension/vi/yurineko/dto/TagDto.kt
new file mode 100644
index 000000000..0ab72f512
--- /dev/null
+++ b/src/vi/yurineko/src/eu/kanade/tachiyomi/extension/vi/yurineko/dto/TagDto.kt
@@ -0,0 +1,11 @@
+package eu.kanade.tachiyomi.extension.vi.yurineko.dto
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class TagDto(
+ val id: Int,
+ val name: String,
+ val url: String,
+ val origin: String? = null,
+)