diff --git a/src/uk/zenko/build.gradle b/src/uk/zenko/build.gradle new file mode 100644 index 000000000..d7011d73f --- /dev/null +++ b/src/uk/zenko/build.gradle @@ -0,0 +1,8 @@ +ext { + extName = 'Zenko' + extClass = '.Zenko' + extVersionCode = 1 + isNsfw = true +} + +apply from: "$rootDir/common.gradle" diff --git a/src/uk/zenko/res/mipmap-hdpi/ic_launcher.png b/src/uk/zenko/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..151f7cfab Binary files /dev/null and b/src/uk/zenko/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/uk/zenko/res/mipmap-mdpi/ic_launcher.png b/src/uk/zenko/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..86e717122 Binary files /dev/null and b/src/uk/zenko/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/uk/zenko/res/mipmap-xhdpi/ic_launcher.png b/src/uk/zenko/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..aefb80cd4 Binary files /dev/null and b/src/uk/zenko/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/uk/zenko/res/mipmap-xxhdpi/ic_launcher.png b/src/uk/zenko/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..e49715dc2 Binary files /dev/null and b/src/uk/zenko/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/uk/zenko/res/mipmap-xxxhdpi/ic_launcher.png b/src/uk/zenko/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..80032a52d Binary files /dev/null and b/src/uk/zenko/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/uk/zenko/src/eu/kanade/tachiyomi/extension/uk/zenko/StringProcessor.kt b/src/uk/zenko/src/eu/kanade/tachiyomi/extension/uk/zenko/StringProcessor.kt new file mode 100644 index 000000000..3edb88930 --- /dev/null +++ b/src/uk/zenko/src/eu/kanade/tachiyomi/extension/uk/zenko/StringProcessor.kt @@ -0,0 +1,95 @@ +package eu.kanade.tachiyomi.extension.uk.zenko + +import android.util.Log + +object StringProcessor { + private const val SEPARATOR = "@#%&;№%#&**#!@" + + data class ParsedResult( + val part: String = "", + val chapter: String = "", + val name: String = "", + ) + + private fun parse(input: String?): ParsedResult { + if (input.isNullOrEmpty()) { + return ParsedResult() + } + + val parts = input.split(SEPARATOR) + return when (parts.size) { + 3 -> { + val (part, chapter, name) = parts + ParsedResult(part, chapter, name) + } + + 2 -> { + val (part, chapter) = parts + ParsedResult(part, chapter) + } + + 1 -> { + val (name) = parts + ParsedResult(name = name) + } + + else -> ParsedResult() + } + } + + // gen ID by rule: part + chapter + // example + // 1 + 0 = 100 + // 1 + 1 = 101 + // 0 + 10 = 010 + // 1 + 99 = 199 + // 1 + 100.5 = 1100.5 + fun generateId(input: String?): Double { + if (input.isNullOrEmpty()) { + return -1.0 + } + val (part, chapter) = parse(input) + + val partNumber = part.toIntOrNull() ?: 0 + val chapterNumber = chapter.toDoubleOrNull() ?: 0 + + val formattedChapter = if (chapter.contains('.')) { + chapter.split('.').joinToString(".") { part -> + if (part == chapter.split('.').first() && part.length == 1) { + part.padStart(2, '0') + } else { + part + } + } + } else { + if (chapter.length == 1) chapter.padStart(2, '0') else chapter + } + + val idString = if (partNumber > 0) { + "$partNumber$formattedChapter" + } else { + "$chapterNumber" + } + + return try { + idString.toDouble() + } catch (e: NumberFormatException) { + Log.d("ZENKO", "Invalid ID format: $idString") + -1.0 + } + } + + fun format(input: String?): String { + val (part, chapter, name) = parse(input) + val chapterLabel = if (chapter.isNotEmpty()) { + "Розділ $chapter${if (name.isNotEmpty()) ":" else ""}" + } else { + "" + } + return listOf( + if (part.isNotEmpty()) "Том $part" else "", + chapterLabel, + name, + ).filter { it.isNotEmpty() }.joinToString(" ") + } +} diff --git a/src/uk/zenko/src/eu/kanade/tachiyomi/extension/uk/zenko/Zenko.kt b/src/uk/zenko/src/eu/kanade/tachiyomi/extension/uk/zenko/Zenko.kt new file mode 100644 index 000000000..5dd337063 --- /dev/null +++ b/src/uk/zenko/src/eu/kanade/tachiyomi/extension/uk/zenko/Zenko.kt @@ -0,0 +1,201 @@ +package eu.kanade.tachiyomi.extension.uk.zenko + +import eu.kanade.tachiyomi.extension.uk.zenko.dtos.ChapterResponseItem +import eu.kanade.tachiyomi.extension.uk.zenko.dtos.MangaDetailsResponse +import eu.kanade.tachiyomi.extension.uk.zenko.dtos.ZenkoMangaListResponse +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.interceptor.rateLimitHost +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 Zenko : HttpSource() { + override val name = "Zenko" + override val baseUrl = "https://zenko.online" + override val lang = "uk" + override val supportsLatest = true + + override fun headersBuilder() = super.headersBuilder() + .add("Origin", "$baseUrl") + .add("Referer", "$baseUrl/") + + override val client = network.cloudflareClient.newBuilder() + .rateLimitHost(API_URL.toHttpUrl(), 10) + .build() + + override fun getMangaUrl(manga: SManga): String { + return "$baseUrl${manga.url}" + } + + override fun getChapterUrl(chapter: SChapter): String { + return "$baseUrl${chapter.url}" + } + + // ============================== Popular =============================== + override fun popularMangaRequest(page: Int): Request { + val offset = offsetCounter(page) + return makeZenkoMangaRequest(offset, "viewsCount") + } + + override fun popularMangaParse(response: Response) = parseAsMangaResponseDto(response) + + // =============================== Latest =============================== + override fun latestUpdatesRequest(page: Int): Request { + val offset = offsetCounter(page) + return makeZenkoMangaRequest(offset, "lastChapterCreatedAt") + } + + override fun latestUpdatesParse(response: Response) = parseAsMangaResponseDto(response) + + // =============================== Search =============================== + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + if (query.length >= 2) { + val offset = offsetCounter(page) + val url = "$API_URL/titles".toHttpUrl().newBuilder() + .addQueryParameter("limit", "15") + .addQueryParameter("offset", offset.toString()) + .addQueryParameter("name", query) + .build() + return GET(url, headers) + } else { + throw UnsupportedOperationException("Запит має містити щонайменше 2 символи / The query must contain at least 2 characters") + } + } + + override fun searchMangaParse(response: Response) = parseAsMangaResponseDto(response) + + // =========================== Manga Details ============================ + override fun mangaDetailsRequest(manga: SManga): Request { + val mangaId = "$baseUrl${manga.url}".toHttpUrl().pathSegments.last() + val url = "$API_URL/titles/$mangaId" + return GET(url, headers) + } + + override fun mangaDetailsParse(response: Response) = SManga.create().apply { + val mangaDto = response.parseAs() + setUrlWithoutDomain("/titles/${mangaDto.id}") + title = mangaDto.engName ?: mangaDto.name + thumbnail_url = buildImageUrl(mangaDto.coverImg) + description = "${mangaDto.name}\n${mangaDto.description}" + genre = mangaDto.genres!!.joinToString { it.name } + author = mangaDto.author!!.username + status = mangaDto.status.toStatus() + } + + // ============================== Chapters ============================== + override fun chapterListRequest(manga: SManga): Request { + val mangaId = "$baseUrl${manga.url}".toHttpUrl().pathSegments.last() + val url = "$API_URL/titles/$mangaId/chapters" + return GET(url, headers) + } + + override fun chapterListParse(response: Response): List { + val result = response.parseAs>() + return result.sortedByDescending { item -> + val id = StringProcessor.generateId(item.name) + if (id > 0) id else item.id.toDouble() + }.map { chapterResponseItem -> + SChapter.create().apply { + setUrlWithoutDomain("/titles/${chapterResponseItem.titleId}/${chapterResponseItem.id}") + name = StringProcessor.format(chapterResponseItem.name) + date_upload = chapterResponseItem.createdAt!!.secToMs() + scanlator = chapterResponseItem.publisher!!.name + } + } + } + + // =============================== Pages ================================ + override fun pageListRequest(chapter: SChapter): Request { + val chapterId = "$baseUrl${chapter.url}".toHttpUrl().pathSegments.last() + val url = "$API_URL/chapters/$chapterId" + return GET(url, headers) + } + + override fun pageListParse(response: Response): List { + val data = response.parseAs() + return data.pages!!.map { page -> + Page(page.id, imageUrl = "$IMAGE_STORAGE_URL/${page.imgUrl}") + } + } + + override fun imageUrlParse(response: Response): String = "" + + // ============================= Utilities ============================== + private fun parseAsMangaResponseDto(response: Response): MangasPage { + val zenkoMangaListResponse = response.parseAs() + return makeMangasPage(zenkoMangaListResponse.data, zenkoMangaListResponse.meta.hasNextPage) + } + + private fun offsetCounter(page: Int) = (page - 1) * 15 + + private fun makeZenkoMangaRequest(offset: Int, sortBy: String): Request { + val url = "$API_URL/titles".toHttpUrl().newBuilder() + .addQueryParameter("limit", "15") + .addQueryParameter("offset", offset.toString()) + .addQueryParameter("sortBy", sortBy) + .addQueryParameter("order", "DESC") + .build() + return GET(url, headers) + } + + private fun makeMangasPage( + mangaList: List, + hasNextPage: Boolean = false, + ): MangasPage { + return MangasPage( + mangaList.map(::makeSManga), + hasNextPage, + ) + } + + private fun makeSManga(mangaDto: MangaDetailsResponse) = SManga.create().apply { + setUrlWithoutDomain("/titles/${mangaDto.id}") + title = mangaDto.engName ?: mangaDto.name + thumbnail_url = buildImageUrl(mangaDto.coverImg) + status = mangaDto.status.toStatus() + } + + private fun String.toStatus(): Int { + val status = this.lowercase() + return when (status) { + "ongoing" -> SManga.ONGOING + "finished" -> SManga.COMPLETED + "paused" -> SManga.ON_HIATUS + else -> SManga.UNKNOWN + } + } + + private fun Long.secToMs(): Long { + return this * 1000 + } + + private fun buildImageUrl(imageId: String): String { + val url = "$IMAGE_STORAGE_URL/$imageId".toHttpUrl().newBuilder() + .addQueryParameter("optimizer", "image") + .addQueryParameter("width", "560") + .addQueryParameter("quality", "70") + .addQueryParameter("height", "auto") + .build() + return url.toString() + } + + private inline fun Response.parseAs(): T = use { + json.decodeFromStream(it.body.byteStream()) + } + + companion object { + private const val API_URL = "https://zenko-api.onrender.com" + private const val IMAGE_STORAGE_URL = "https://zenko.b-cdn.net" + + private val json: Json by injectLazy() + } +} diff --git a/src/uk/zenko/src/eu/kanade/tachiyomi/extension/uk/zenko/dtos/ZenkoMangaDto.kt b/src/uk/zenko/src/eu/kanade/tachiyomi/extension/uk/zenko/dtos/ZenkoMangaDto.kt new file mode 100644 index 000000000..564127e31 --- /dev/null +++ b/src/uk/zenko/src/eu/kanade/tachiyomi/extension/uk/zenko/dtos/ZenkoMangaDto.kt @@ -0,0 +1,57 @@ +package eu.kanade.tachiyomi.extension.uk.zenko.dtos + +import kotlinx.serialization.Serializable + +@Serializable +class ZenkoMangaListResponse( + val `data`: List, + val meta: Meta, +) + +@Serializable +class Meta( + val hasNextPage: Boolean, +) + +@Serializable +class MangaDetailsResponse( + val author: Author? = null, + val coverImg: String, + val description: String, + val engName: String? = null, + val genres: List? = null, + val id: Int, + val name: String, + val status: String, +) + +@Serializable +class Genre( + val name: String, +) + +@Serializable +class Author( + val username: String? = null, +) + +@Serializable +class ChapterResponseItem( + val createdAt: Long? = null, + val id: Int, + val name: String?, + val pages: List? = null, + val titleId: Int?, + val publisher: Publisher? = null, +) + +@Serializable +class Page( + val id: Int, + val imgUrl: String, +) + +@Serializable +class Publisher( + val name: String? = null, +)