diff --git a/src/all/yabai/build.gradle b/src/all/yabai/build.gradle new file mode 100644 index 000000000..3b6a059cc --- /dev/null +++ b/src/all/yabai/build.gradle @@ -0,0 +1,8 @@ +ext { + extName = 'Yabai' + extClass = '.Yabai' + extVersionCode = 1 + isNsfw = true +} + +apply from: "$rootDir/common.gradle" diff --git a/src/all/yabai/src/eu/kanade/tachiyomi/extension/all/yabai/Filters.kt b/src/all/yabai/src/eu/kanade/tachiyomi/extension/all/yabai/Filters.kt new file mode 100644 index 000000000..8113bdf88 --- /dev/null +++ b/src/all/yabai/src/eu/kanade/tachiyomi/extension/all/yabai/Filters.kt @@ -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, state: Int = 0) : + Filter.Select(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", +) diff --git a/src/all/yabai/src/eu/kanade/tachiyomi/extension/all/yabai/Yabai.kt b/src/all/yabai/src/eu/kanade/tachiyomi/extension/all/yabai/Yabai.kt new file mode 100644 index 000000000..d4009b365 --- /dev/null +++ b/src/all/yabai/src/eu/kanade/tachiyomi/extension/all/yabai/Yabai.kt @@ -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>() + + 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>() + + 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>().props.post.data.toSManga() + + override fun chapterListRequest(manga: SManga) = mangaDetailsRequest(manga) + + override fun chapterListParse(response: Response) = + listOf(response.parseAs>().props.post.data.toSChapter()) + + override fun pageListRequest(chapter: SChapter) = GET( + "$baseUrl${chapter.url}/read", + headers, + ) + + override fun pageListParse(response: Response) = + response.parseAs>().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") + } + } +} diff --git a/src/all/yabai/src/eu/kanade/tachiyomi/extension/all/yabai/YabaiDto.kt b/src/all/yabai/src/eu/kanade/tachiyomi/extension/all/yabai/YabaiDto.kt new file mode 100644 index 000000000..c7b060c7b --- /dev/null +++ b/src/all/yabai/src/eu/kanade/tachiyomi/extension/all/yabai/YabaiDto.kt @@ -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( + val props: T, +) + +@Serializable +class IndexProps( + @SerialName("post_list") + val postList: PostList, +) + +@Serializable +class PostList( + val data: List, + 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>?, + 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, + private val hash: List, + private val rand: List, + private val type: List, +) { + 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]}", + ), + ) + } +}