From 1d6a48c189b930ea9eaa278819cdfa126fa60e3f Mon Sep 17 00:00:00 2001 From: Secozzi <49240133+Secozzi@users.noreply.github.com> Date: Sun, 19 May 2024 10:32:50 +0000 Subject: [PATCH] move sunshinebutterflyscans away from multisrc (#3092) * move sunshinebutterflyscans away from multisrc * suggestions --- src/en/sunshinebutterflyscans/build.gradle | 8 +- .../en/sunshinebutterflyscans/Dto.kt | 86 ++++++ .../SunshineButterflyScans.kt | 269 +++++++++++++++++- 3 files changed, 358 insertions(+), 5 deletions(-) create mode 100644 src/en/sunshinebutterflyscans/src/eu/kanade/tachiyomi/extension/en/sunshinebutterflyscans/Dto.kt diff --git a/src/en/sunshinebutterflyscans/build.gradle b/src/en/sunshinebutterflyscans/build.gradle index 2504d64d8..c46cdbc74 100644 --- a/src/en/sunshinebutterflyscans/build.gradle +++ b/src/en/sunshinebutterflyscans/build.gradle @@ -1,10 +1,12 @@ ext { extName = 'Sunshine Butterfly Scans' extClass = '.SunshineButterflyScans' - themePkg = 'madara' - baseUrl = 'https://sunshinebutterflyscan.com' - overrideVersionCode = 1 + extVersionCode = 38 isNsfw = true } apply from: "$rootDir/common.gradle" + +dependencies { + implementation(project(':lib:cryptoaes')) +} diff --git a/src/en/sunshinebutterflyscans/src/eu/kanade/tachiyomi/extension/en/sunshinebutterflyscans/Dto.kt b/src/en/sunshinebutterflyscans/src/eu/kanade/tachiyomi/extension/en/sunshinebutterflyscans/Dto.kt new file mode 100644 index 000000000..bcbea3a4b --- /dev/null +++ b/src/en/sunshinebutterflyscans/src/eu/kanade/tachiyomi/extension/en/sunshinebutterflyscans/Dto.kt @@ -0,0 +1,86 @@ +package eu.kanade.tachiyomi.extension.en.sunshinebutterflyscans + +import eu.kanade.tachiyomi.source.model.SChapter +import eu.kanade.tachiyomi.source.model.SManga +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +class EntryDto( + val series: String, + val timestamp: String, + val num: Int, + @SerialName("chname") val chapterName: String, + @SerialName("AlbumID") val albumID: String, + @SerialName("projectname") val projectName: String, + @SerialName("projectdesc") val projectDesc: String, + @SerialName("projectaltname") val altName: String, + @SerialName("projectauthor") val projectAuthor: String, + @SerialName("projectartist") val projectArtist: String, + @SerialName("projectthumb") val projectThumb: String, + @SerialName("projectstatus") val projectStatus: String, + @SerialName("projecttags") val projectTags: String, +) { + fun toSManga(cdnUrl: String): SManga = SManga.create().apply { + title = series + thumbnail_url = cdnUrl + projectThumb + url = "/projects?n=$projectName" + description = buildString { + projectDesc.nonEmpty()?.let { append(it) } + if (altName.isNotEmpty()) { + append("\n\n") + append("Alternative name: ") + append(altName) + } + } + genre = projectTags.nonEmpty()?.replace(",", ", ") + status = projectStatus.toStatus() + author = projectAuthor.nonEmpty() + artist = projectArtist.nonEmpty() + initialized = true + } + + private fun String.toStatus(): Int = when (this) { + "current" -> SManga.ONGOING + "complete" -> SManga.COMPLETED + "dropped" -> SManga.CANCELLED + "licensed" -> SManga.LICENSED + else -> SManga.UNKNOWN + } + + fun toSChapter(): SChapter = SChapter.create().apply { + name = chapterName + chapter_number = num.toFloat() + date_upload = timestamp.nonEmpty()?.toLong()?.times(1000) ?: 0L + url = "/read?series=$projectName&num=$num" + } +} + +@Serializable +class GoogleDriveResponseDto( + val files: List, +) { + @Serializable + class FileDto( + val id: String, + val name: String, + @SerialName("imageMediaMetadata") val metadata: MetadataDto, + ) { + @Serializable + class MetadataDto( + val width: Int, + ) + } +} + +@Serializable +class ImgurResponseDto( + val data: List, +) { + @Serializable + class DataDto( + val link: String, + ) +} + +private fun String.nonEmpty() = this.takeIf { it.isNotEmpty() } diff --git a/src/en/sunshinebutterflyscans/src/eu/kanade/tachiyomi/extension/en/sunshinebutterflyscans/SunshineButterflyScans.kt b/src/en/sunshinebutterflyscans/src/eu/kanade/tachiyomi/extension/en/sunshinebutterflyscans/SunshineButterflyScans.kt index 0cb42b19d..8fb4ed315 100644 --- a/src/en/sunshinebutterflyscans/src/eu/kanade/tachiyomi/extension/en/sunshinebutterflyscans/SunshineButterflyScans.kt +++ b/src/en/sunshinebutterflyscans/src/eu/kanade/tachiyomi/extension/en/sunshinebutterflyscans/SunshineButterflyScans.kt @@ -1,5 +1,270 @@ package eu.kanade.tachiyomi.extension.en.sunshinebutterflyscans -import eu.kanade.tachiyomi.multisrc.madara.Madara +import android.util.Base64 +import eu.kanade.tachiyomi.lib.cryptoaes.CryptoAES +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.interceptor.rateLimit +import eu.kanade.tachiyomi.source.model.Filter +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.decodeFromString +import kotlinx.serialization.json.Json +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.Request +import okhttp3.Response +import rx.Observable +import uy.kohesive.injekt.injectLazy -class SunshineButterflyScans : Madara("Sunshine Butterfly Scans", "https://sunshinebutterflyscan.com", "en") +class SunshineButterflyScans : HttpSource() { + + override val name = "Sunshine Butterfly Scans" + + override val baseUrl = "https://wings.sbs" + private val cdnUrl = "$baseUrl/images/projcoverjpeg/" + + override val lang = "en" + + override val supportsLatest = true + + // Madara -> custom theme + override val versionId = 2 + + override val client = network.cloudflareClient.newBuilder() + .rateLimit(2) + .build() + + override fun headersBuilder() = super.headersBuilder() + .add("Referer", "$baseUrl/") + + private fun apiHeadersBuilder() = headersBuilder().apply { + add("Accept", "*/*") + add("Host", baseUrl.toHttpUrl().host) + } + + private val apiHeaders by lazy { apiHeadersBuilder().build() } + + private val json: Json by injectLazy() + + private val chaptersData by lazy { + client.newCall( + GET("$baseUrl/json/chapters.json", apiHeaders), + ).execute().parseAs>().groupBy { + it.series + }.values.map { it.sortedByDescending { it.num } } + } + + // ============================== Popular =============================== + + override fun fetchPopularManga(page: Int): Observable { + val mangaList = chaptersData.sortedBy { + it.first().series + }.map { + it.first().toSManga(cdnUrl) + } + + return Observable.just(MangasPage(mangaList, false)) + } + + override fun popularMangaRequest(page: Int): Request = + throw UnsupportedOperationException() + + override fun popularMangaParse(response: Response): MangasPage = + throw UnsupportedOperationException() + + // =============================== Latest =============================== + + override fun fetchLatestUpdates(page: Int): Observable { + val mangaList = chaptersData.sortedByDescending { + it.first().timestamp.toLongOrNull() ?: Long.MAX_VALUE + }.map { + it.first().toSManga(cdnUrl) + } + + return Observable.just(MangasPage(mangaList, false)) + } + + override fun latestUpdatesRequest(page: Int): Request = + throw UnsupportedOperationException() + + override fun latestUpdatesParse(response: Response): MangasPage = + throw UnsupportedOperationException() + + // =============================== Search =============================== + + override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable { + val selectedStatus = filters.filterIsInstance().first().toUriPart() + val selectedSort = filters.filterIsInstance().first().getSelection() + + val sortedList = if (selectedSort.first == "Name") { + chaptersData.sortedBy { it.first().series } + } else { + chaptersData.sortedByDescending { + it.first().timestamp.toLongOrNull() ?: Long.MAX_VALUE + } + } + + val filteredList = sortedList + .filter { it.first().series.contains(query, true) } + .filter { it.first().projectStatus.contains(selectedStatus) } + + val reversedList = if (selectedSort.second) { + filteredList.reversed() + } else { + filteredList + } + + val mangaList = reversedList.map { + it.first().toSManga(cdnUrl) + } + + return Observable.just(MangasPage(mangaList, false)) + } + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request = + throw UnsupportedOperationException() + + override fun searchMangaParse(response: Response): MangasPage = + throw UnsupportedOperationException() + + // =============================== Filters ============================== + + override fun getFilterList(): FilterList = FilterList( + StatusFilter(), + SortFilter(), + ) + + open class UriPartFilter(displayName: String, private val vals: Array>) : + Filter.Select(displayName, vals.map { it.first }.toTypedArray()) { + fun toUriPart() = vals[state].second + } + + class StatusFilter : UriPartFilter( + "Status", + arrayOf( + Pair("All", ""), + Pair("Current", "current"), + Pair("Complete", "complete"), + Pair("Dropped", "dropped"), + Pair("Licensed", "licensed"), + ), + ) + + class SortFilter : Filter.Sort( + "Sort by", + VALUES, + Selection(0, false), + ) { + fun getSelection() = Pair(VALUES[state!!.index], state!!.ascending) + + companion object { + private val VALUES = arrayOf("Name", "Last Updated") + } + } + + // =========================== Manga Details ============================ + + override fun fetchMangaDetails(manga: SManga): Observable { + val mangaData = chaptersData.first { + it.first().projectName == manga.url.substringAfter("?n=") + }.first() + + return Observable.just(mangaData.toSManga(cdnUrl)) + } + + override fun getMangaUrl(manga: SManga): String = baseUrl + manga.url + + override fun mangaDetailsRequest(manga: SManga): Request = + throw UnsupportedOperationException() + + override fun mangaDetailsParse(response: Response): SManga = + throw UnsupportedOperationException() + + // ============================== Chapters ============================== + + override fun fetchChapterList(manga: SManga): Observable> { + val selectedManga = chaptersData.first { + it.first().projectName == manga.url.substringAfter("?n=") + } + val chapterList = selectedManga.map { it.toSChapter() } + + return Observable.just(chapterList) + } + + override fun getChapterUrl(chapter: SChapter): String = baseUrl + chapter.url + + override fun chapterListRequest(manga: SManga): Request = + throw UnsupportedOperationException() + + override fun chapterListParse(response: Response): List = + throw UnsupportedOperationException() + + // =============================== Pages ================================ + + override fun pageListRequest(chapter: SChapter): Request { + val chapterDto = chaptersData.flatten().first { + "${it.projectName}&num=${it.num}" == chapter.url.substringAfter("series=") + } + val decrypted = CryptoAES.decrypt(chapterDto.albumID, KEY, IV) + + val url = if (decrypted.length > 10) { + GOOGLE_DRIVE_FIRST + decrypted + GOOGLE_DRIVE_SECOND + } else { + IMGUR_FIRST + decrypted + IMGUR_SECOND + } + val headers = headersBuilder().apply { + set("Host", url.toHttpUrl().host) + add("Origin", baseUrl) + if (decrypted.length <= 10) { + add("Authorization", "Bearer $IMGUR_BEARER") + } + }.build() + + return GET(url, headers) + } + + override fun pageListParse(response: Response): List { + return if (response.request.url.host.contains("googleapis")) { + response.parseAs().files.sortedBy { + it.name + }.mapIndexed { index, file -> + Page(index, imageUrl = "https://lh3.googleusercontent.com/d/${file.id}=w${file.metadata.width}") + } + } else { + response.parseAs().data.mapIndexed { index, data -> + Page(index, imageUrl = data.link) + } + } + } + + override fun imageRequest(page: Page): Request { + val imgHeaders = headersBuilder().apply { + add("Accept", "image/avif,image/webp,*/*") + add("Host", page.imageUrl!!.toHttpUrl().host) + }.build() + + return GET(page.imageUrl!!, imgHeaders) + } + + override fun imageUrlParse(response: Response): String = + throw UnsupportedOperationException() + + // ============================= Utilities ============================== + + private inline fun Response.parseAs(): T { + return json.decodeFromString(body.string()) + } + + companion object { + private const val GOOGLE_DRIVE_FIRST = "https://www.googleapis.com/drive/v3/files?q=\"" + private const val GOOGLE_DRIVE_SECOND = "\"+in+parents&key=AIzaSyDDWjOHN1UPcafkwyJLO7fX1gmVyntIozs&orderBy=name_natural&fields=files(id,name,imageMediaMetadata)&pageSize=250" + private const val IMGUR_FIRST = "https://api.imgur.com/3/album/" + private const val IMGUR_SECOND = "/images" + private val IMGUR_BEARER = "84155230e6a2d98eaea1cee48d97e6ecff0f6c12" + private val KEY = Base64.decode("YX+1nM4KgfaYwNE3/MPcTg==", Base64.DEFAULT) + private val IV = Base64.decode("279GjT2Xu9LZBkI4zLzIAg==", Base64.DEFAULT) + } +}