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