move sunshinebutterflyscans away from multisrc (#3092)
* move sunshinebutterflyscans away from multisrc * suggestions
This commit is contained in:
parent
9440fc3b1a
commit
1d6a48c189
@ -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'))
|
||||
}
|
||||
|
@ -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<FileDto>,
|
||||
) {
|
||||
@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<DataDto>,
|
||||
) {
|
||||
@Serializable
|
||||
class DataDto(
|
||||
val link: String,
|
||||
)
|
||||
}
|
||||
|
||||
private fun String.nonEmpty() = this.takeIf { it.isNotEmpty() }
|
@ -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<List<EntryDto>>().groupBy {
|
||||
it.series
|
||||
}.values.map { it.sortedByDescending { it.num } }
|
||||
}
|
||||
|
||||
// ============================== Popular ===============================
|
||||
|
||||
override fun fetchPopularManga(page: Int): Observable<MangasPage> {
|
||||
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<MangasPage> {
|
||||
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<MangasPage> {
|
||||
val selectedStatus = filters.filterIsInstance<StatusFilter>().first().toUriPart()
|
||||
val selectedSort = filters.filterIsInstance<SortFilter>().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<Pair<String, String>>) :
|
||||
Filter.Select<String>(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<SManga> {
|
||||
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<List<SChapter>> {
|
||||
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<SChapter> =
|
||||
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<Page> {
|
||||
return if (response.request.url.host.contains("googleapis")) {
|
||||
response.parseAs<GoogleDriveResponseDto>().files.sortedBy {
|
||||
it.name
|
||||
}.mapIndexed { index, file ->
|
||||
Page(index, imageUrl = "https://lh3.googleusercontent.com/d/${file.id}=w${file.metadata.width}")
|
||||
}
|
||||
} else {
|
||||
response.parseAs<ImgurResponseDto>().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 <reified T> 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)
|
||||
}
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user