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 1 commit
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.
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,89 @@
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 = "",
)

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()
}
}

fun assemble(parsed: ParsedResult): String {
return listOf(parsed.part, parsed.chapter, parsed.name)
.filter { it.isNotEmpty() }
.joinToString(separator)
}

// 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(" ")
}
}
199 changes: 199 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,199 @@
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

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)
Altometer marked this conversation as resolved.
Show resolved Hide resolved

override val client = network.client.newBuilder()
Altometer marked this conversation as resolved.
Show resolved Hide resolved
.rateLimitHost(API_URL.toHttpUrl(), 10)
.build()

// ============================== 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")
.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 = manga.url.substringAfterLast('/')
val url = "$API_URL/titles/$mangaId"
return GET(url, headers)
}

override fun mangaDetailsParse(response: Response) = SManga.create().apply {
val mangaDto = response.asClass<MangaDetailsResponse>()
title = mangaDto.engName ?: mangaDto.name
thumbnail_url =
"$IMAGE_STORAGE_URL/${mangaDto.coverImg}?optimizer=image&width=560&quality=70&height=auto"
url = "$baseUrl/titles/${mangaDto.id}"
Altometer marked this conversation as resolved.
Show resolved Hide resolved
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 = manga.url.substringAfterLast('/')
val url = "$API_URL/titles/$mangaId/chapters"
return GET(url, headers)
}

override fun chapterListParse(response: Response): List<SChapter> {
val result = response.asClass<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 {
url = "$baseUrl/titles/${chapterResponseItem.titleId}/${chapterResponseItem.id}"
Altometer marked this conversation as resolved.
Show resolved Hide resolved
name = StringProcessor.format(chapterResponseItem.name)
date_upload = chapterResponseItem.createdAt!!.secToMs()
scanlator = chapterResponseItem.publisher!!.name
}
}
}

// =============================== Pages ================================
override fun pageListRequest(chapter: SChapter): Request {
val chapterId = chapter.url.substringAfterLast('/')
Altometer marked this conversation as resolved.
Show resolved Hide resolved
val url = "$API_URL/chapters/$chapterId"
return GET(url, headers)
}

override fun pageListParse(response: Response): List<Page> {
val data = response.asClass<ChapterResponseItem>()
return data.pages!!.map { page ->
Page(page.id, "", "$IMAGE_STORAGE_URL/${page.imgUrl}")
Altometer marked this conversation as resolved.
Show resolved Hide resolved
}
}

override fun imageUrlParse(response: Response): String = ""

// ============================= Utilities ==============================
private fun parseAsMangaResponseDto(response: Response): MangasPage {
val zenkoMangaListResponse = response.asClass<ZenkoMangaListResponse>()
return makeMangasPage(zenkoMangaListResponse.data, zenkoMangaListResponse.meta.hasNextPage)
}

private fun offsetCounter(page: Int): Int {
var currentOfsset = 0
val offset = if (page == 1) {
currentOfsset = 0 // for page 1 offset must be 0
currentOfsset
} else {
currentOfsset = (page - 1) * 15 // calculate offset for other pages
currentOfsset
}
return offset
Altometer marked this conversation as resolved.
Show resolved Hide resolved
}

private fun makeZenkoMangaRequest(offset: Int, sortBy: String): Request {
val url = "$API_URL/titles".toHttpUrl().newBuilder()
.addQueryParameter("limit", "15")
.addQueryParameter("offset", "$offset")
Altometer marked this conversation as resolved.
Show resolved Hide resolved
.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,
)
}
Altometer marked this conversation as resolved.
Show resolved Hide resolved

private fun makeSManga(mangaDto: MangaDetailsResponse) = SManga.create().apply {
title = mangaDto.engName ?: mangaDto.name
thumbnail_url =
"$IMAGE_STORAGE_URL/${mangaDto.coverImg}?optimizer=image&width=560&quality=70&height=auto"
url = "$baseUrl/titles/${mangaDto.id}"
Altometer marked this conversation as resolved.
Show resolved Hide resolved
status = mangaDto.status.toStatus()
}

private fun String.toStatus(): Int {
Altometer marked this conversation as resolved.
Show resolved Hide resolved
return when (this) {
"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
}

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 {
ignoreUnknownKeys = true
coerceInputValues = true
}
Altometer marked this conversation as resolved.
Show resolved Hide resolved

private inline fun <reified T> Response.asClass(): T = use {
json.decodeFromStream(it.body.byteStream())
}
Altometer marked this conversation as resolved.
Show resolved Hide resolved
}
}
Loading