Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Zenko #7185

Merged
merged 9 commits into from
Jan 23, 2025
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/uk/zenko/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
ext {
extName = 'Zenko'
extClass = '.Zenko'
extVersionCode = 1
isNsfw = true
}

apply from: "$rootDir/common.gradle"
Binary file added src/uk/zenko/res/mipmap-hdpi/ic_launcher.png
Altometer marked this conversation as resolved.
Show resolved Hide resolved
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/uk/zenko/res/mipmap-mdpi/ic_launcher.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/uk/zenko/res/mipmap-xhdpi/ic_launcher.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/uk/zenko/res/mipmap-xxhdpi/ic_launcher.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/uk/zenko/res/mipmap-xxxhdpi/ic_launcher.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
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 idString = if (partNumber > 0) {
"$partNumber${ if (chapter.length == 1) chapter.padStart(2, '0') else chapter}"
} 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(" ")
}
}
204 changes: 204 additions & 0 deletions src/uk/zenko/src/eu/kanade/tachiyomi/extension/uk/zenko/Zenko.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
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/")
Altometer marked this conversation as resolved.
Show resolved Hide resolved
.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<MangaDetailsResponse>()
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<SChapter> {
val result = response.parseAs<List<ChapterResponseItem>>()
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<Page> {
val data = response.parseAs<ChapterResponseItem>()
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<ZenkoMangaListResponse>()
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<MangaDetailsResponse>,
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 {
Altometer marked this conversation as resolved.
Show resolved Hide resolved
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 (
runCatching { this * 1000 }
.getOrNull() ?: 0L
)
Altometer marked this conversation as resolved.
Show resolved Hide resolved
}

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 <reified T> 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()
}
}
Altometer marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package eu.kanade.tachiyomi.extension.uk.zenko.dtos
Altometer marked this conversation as resolved.
Show resolved Hide resolved

import kotlinx.serialization.Serializable

@Serializable
class ZenkoMangaListResponse(
val `data`: List<MangaDetailsResponse>,
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<Genre>? = 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<Page>? = null,
val titleId: Int?,
val publisher: Publisher? = null,
)

@Serializable
class Page(
val id: Int,
val imgUrl: String,
)

@Serializable
class Publisher(
val name: String? = null,
)