* Add Yabai

* Reorder code

* Use utils
This commit is contained in:
Fermín Cirella 2025-09-27 09:23:40 -03:00 committed by Draff
parent 41e64ac576
commit fe47d20f67
Signed by: Draff
GPG Key ID: E8A89F3211677653
4 changed files with 453 additions and 0 deletions

View File

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

View File

@ -0,0 +1,69 @@
package eu.kanade.tachiyomi.extension.all.yabai
import eu.kanade.tachiyomi.source.model.Filter
import eu.kanade.tachiyomi.source.model.FilterList
fun getFilters(): FilterList {
return FilterList(
SelectFilter("Category", categories.keys.toList()),
SelectFilter("Language", languages.keys.toList()),
)
}
internal open class SelectFilter(name: String, val vals: List<String>, state: Int = 0) :
Filter.Select<String>(name, vals.map { it }.toTypedArray(), state)
val categories = mapOf(
"All" to "",
"Doujinshi" to 1,
"Manga" to 2,
"Artist CG" to 3,
"Game CG" to 4,
"Western" to 5,
"Non-H" to 6,
"Image Set" to 7,
"Cosplay" to 8,
"Misc" to 9,
"Asian Porn" to 10,
"Private" to 11,
)
val languages = mapOf(
"All" to "",
"Japanese" to "jp",
"English" to "gb",
"Korean" to "kr",
"Russian" to "ru",
"Chinese" to "cn",
"French" to "fr",
"Italian" to "it",
"Spanish" to "es",
"Portuguese" to "pt",
"German" to "de",
"Thai" to "th",
"Arabic" to "sa",
"Turkish" to "tr",
"Hebrew" to "il",
"Tagalog" to "ph",
"Ukrainian" to "ua",
"Bulgarian" to "bg",
"Dutch" to "nl",
"Mongolian" to "mn",
"Vietnamese" to "vn",
"Macedonian" to "mk",
"Polish" to "pl",
"Hungarian" to "hu",
"Norwegian" to "no",
"Indonesian" to "id",
"Lithuanian" to "lt",
"Serbian" to "rs",
"Persian" to "ir",
"Croatian" to "hr",
"Czech" to "cz",
"Slovak" to "sk",
"Romanian" to "ro",
"Finnish" to "fi",
"Greek" to "gr",
"Swedish" to "se",
"Latin" to "va",
)

View File

@ -0,0 +1,219 @@
package eu.kanade.tachiyomi.extension.all.yabai
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.MangasPage
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 keiyoushi.utils.toJsonString
import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import java.io.IOException
import java.text.SimpleDateFormat
import java.util.Locale
import java.util.TimeZone
class Yabai : HttpSource() {
override val name = "Yabai"
override val baseUrl = "https://yabai.si"
override val lang = "all"
override val supportsLatest = false
override val client = network.cloudflareClient.newBuilder()
.addInterceptor(::tokenInterceptor)
.build()
private var popularNextHash: String? = null
private var searchNextHash: String? = null
private var storedToken: String? = null
private fun tokenInterceptor(chain: Interceptor.Chain): Response {
val request = chain.request()
val modifiedRequest = request.newBuilder()
.addHeader("X-Requested-With", "XMLHttpRequest")
.addHeader("X-Inertia", "true")
.addHeader("X-Inertia-Version", "b6320c13b244af5aafcd16668b9b38e4")
if (request.method == "POST") {
modifiedRequest.addHeader("Content-Type", "application/json")
if (request.header("X-XSRF-TOKEN") == null) {
val token = getToken()
val response = chain.proceed(
modifiedRequest
.addHeader("X-XSRF-TOKEN", token)
.build(),
)
if (!response.isSuccessful && response.code == 419) {
response.close()
storedToken = null
val newToken = getToken()
return chain.proceed(
modifiedRequest
.addHeader("X-XSRF-TOKEN", newToken)
.build(),
)
}
return response
}
}
return chain.proceed(modifiedRequest.build())
}
private fun getToken(): String {
if (storedToken.isNullOrEmpty()) {
val request = GET(baseUrl, headers)
val response = client.newCall(request).execute()
var found = false
val headers = response.headers("Set-Cookie")
headers.forEach {
if (it.startsWith("XSRF-TOKEN=")) {
storedToken = it
.split(";")
.first()
.substringAfter("=")
.replace("%3D", "=")
found = true
}
}
if (!found) {
throw IOException("Unable to find CSRF token")
}
}
return storedToken!!
}
override fun getFilterList() = getFilters()
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
if (page == 1) {
searchNextHash = null
}
val queryBody = QueryDto(
qry = query,
cursor = searchNextHash,
).apply {
filters.forEach { filter ->
when (filter) {
is SelectFilter -> {
when (filter.name) {
"Category" -> {
categories[filter.vals[filter.state]]?.let {
cat = it.toString()
}
}
"Language" -> {
languages[filter.vals[filter.state]]?.let {
lng = it
}
}
else -> {}
}
}
else -> {}
}
}
}
return POST(
"$baseUrl/g",
headers,
queryBody.toJsonString().toRequestBody(),
)
}
override fun searchMangaParse(response: Response): MangasPage {
val data = response.parseAs<DataResponse<IndexProps>>()
val galleries = data.props.postList.data.map {
it.toSManga()
}
searchNextHash = data.props.postList.meta.nextCursor
return MangasPage(galleries, searchNextHash != null)
}
override fun popularMangaRequest(page: Int): Request {
if (page == 1) {
popularNextHash = null
}
val queryBody = QueryDto(
cursor = popularNextHash,
)
return POST(
"$baseUrl/g",
headers,
queryBody.toJsonString().toRequestBody(),
)
}
override fun popularMangaParse(response: Response): MangasPage {
val data = response.parseAs<DataResponse<IndexProps>>()
val galleries = data.props.postList.data.map {
it.toSManga()
}
popularNextHash = data.props.postList.meta.nextCursor
return MangasPage(galleries, popularNextHash != null)
}
override fun latestUpdatesRequest(page: Int) = throw UnsupportedOperationException()
override fun latestUpdatesParse(response: Response) = throw UnsupportedOperationException()
override fun mangaDetailsRequest(manga: SManga) = GET(
"$baseUrl${manga.url}",
headers,
)
override fun mangaDetailsParse(response: Response) =
response.parseAs<DataResponse<DetailProps>>().props.post.data.toSManga()
override fun chapterListRequest(manga: SManga) = mangaDetailsRequest(manga)
override fun chapterListParse(response: Response) =
listOf(response.parseAs<DataResponse<DetailProps>>().props.post.data.toSChapter())
override fun pageListRequest(chapter: SChapter) = GET(
"$baseUrl${chapter.url}/read",
headers,
)
override fun pageListParse(response: Response) =
response.parseAs<DataResponse<ReaderProps>>().props.pages.data.list.toPages()
override fun imageUrlParse(response: Response) = throw UnsupportedOperationException()
companion object {
val createdAtFormat = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.ENGLISH).apply {
timeZone = TimeZone.getTimeZone("UTC")
}
}
}

View File

@ -0,0 +1,157 @@
package eu.kanade.tachiyomi.extension.all.yabai
import eu.kanade.tachiyomi.extension.all.yabai.Yabai.Companion.createdAtFormat
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import okhttp3.internal.format
import java.util.Date
@Serializable
class QueryDto(
var cat: String = "",
var lng: String = "",
private val qry: String = "",
private val tag: String = "[]",
private val cursor: String?,
)
@Serializable
class DataResponse<T>(
val props: T,
)
@Serializable
class IndexProps(
@SerialName("post_list")
val postList: PostList,
)
@Serializable
class PostList(
val data: List<GalleryItem>,
val meta: Meta,
)
@Serializable
class GalleryItem(
private val slug: String,
private val name: String,
private val cover: String,
) {
fun toSManga() = SManga.create().apply {
title = name
url = format("/g/$slug")
thumbnail_url = cover
status = SManga.COMPLETED
}
}
@Serializable
class Meta(
@SerialName("next_cursor")
val nextCursor: String?,
)
@Serializable
class DetailProps(
val post: Post,
)
@Serializable
class Post(
val data: Gallery,
)
@Serializable
class Gallery(
private val slug: String,
private val name: String,
private val cover: String,
private val tags: Map<String, List<Tag>>?,
private val date: PostDate,
) {
fun toSManga() = SManga.create().apply {
title = name
url = format("/g/$slug")
thumbnail_url = cover
author = tags
?.filterKeys { it == "Group" }
?.flatMap { it.value }
?.joinToString { it.name }
artist = tags
?.filterKeys { it == "Artist" }
?.flatMap { it.value }
?.joinToString { it.name }
genre = tags
?.filterKeys { it != "Group" && it != "Artist" }
?.flatMap { it.value }
?.joinToString { it.fullName ?: it.name }
status = SManga.COMPLETED
}
fun toSChapter() = SChapter.create().apply {
name = "Chapter"
url = format("/g/$slug")
date_upload = try {
date.toDate()!!.time
} catch (e: Exception) {
0L
}
}
}
@Serializable
class Tag(
val name: String,
@SerialName("full_name")
val fullName: String? = null,
)
@Serializable
class PostDate(
private val default: String,
) {
fun toDate(): Date? = createdAtFormat.parse(default)
}
@Serializable
class ReaderProps(
val pages: Pages,
)
@Serializable
class Pages(
val data: PagesData,
)
@Serializable
class PagesData(
val list: PagesList,
)
@Serializable
class PagesList(
private val root: String,
private val code: Int,
private val head: List<String>,
private val hash: List<String>,
private val rand: List<String>,
private val type: List<String>,
) {
fun toPages() = head
.mapIndexed { index, pageNumber -> Triple(pageNumber, index, index) }
.sortedBy { it.first.toInt() }
.mapIndexed { sortedIndex, (pageNumber, originalIndex, _) ->
Page(
sortedIndex,
imageUrl = format(
"$root/$code/${
pageNumber.padStart(4, '0')
}-${hash[originalIndex]}-${rand[originalIndex]}.${type[originalIndex]}",
),
)
}
}