diff --git a/multisrc/overrides/madara/disasterscans/src/DisasterScans.kt b/multisrc/overrides/madara/disasterscans/src/DisasterScans.kt
deleted file mode 100644
index 9a20cc782..000000000
--- a/multisrc/overrides/madara/disasterscans/src/DisasterScans.kt
+++ /dev/null
@@ -1,23 +0,0 @@
-package eu.kanade.tachiyomi.extension.en.disasterscans
-
-import eu.kanade.tachiyomi.multisrc.madara.Madara
-import eu.kanade.tachiyomi.source.model.SManga
-import org.jsoup.nodes.Document
-
-class DisasterScans : Madara("Disaster Scans", "https://disasterscans.com", "en") {
- override val popularMangaUrlSelector = "div.post-title a:last-child"
-
- override fun mangaDetailsParse(document: Document): SManga {
- val manga = super.mangaDetailsParse(document)
-
- with(document) {
- select("div.post-title h1").first()?.let {
- manga.title = it.ownText()
- }
- }
-
- return manga
- }
-
- override val useNewChapterEndpoint: Boolean = true
-}
diff --git a/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/madara/MadaraGenerator.kt b/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/madara/MadaraGenerator.kt
index 87a12d96f..142d2a732 100644
--- a/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/madara/MadaraGenerator.kt
+++ b/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/madara/MadaraGenerator.kt
@@ -78,7 +78,6 @@ class MadaraGenerator : ThemeSourceGenerator {
SingleLang("Dark Scans", "https://darkscans.com", "en"),
SingleLang("Decadence Scans", "https://reader.decadencescans.com", "en", isNsfw = true, overrideVersionCode = 2),
SingleLang("DiamondFansub", "https://diamondfansub.com", "tr", overrideVersionCode = 1),
- SingleLang("Disaster Scans", "https://disasterscans.com", "en", overrideVersionCode = 2),
SingleLang("DokkoManga", "https://dokkomanga.com", "es", overrideVersionCode = 1),
SingleLang("Doodmanga", "https://www.doodmanga.com", "th"),
SingleLang("DoujinHentai", "https://doujinhentai.net", "es", isNsfw = true, overrideVersionCode = 1),
diff --git a/src/en/disasterscans/AndroidManifest.xml b/src/en/disasterscans/AndroidManifest.xml
new file mode 100644
index 000000000..dd672cb93
--- /dev/null
+++ b/src/en/disasterscans/AndroidManifest.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/en/disasterscans/build.gradle b/src/en/disasterscans/build.gradle
new file mode 100644
index 000000000..1b4da244f
--- /dev/null
+++ b/src/en/disasterscans/build.gradle
@@ -0,0 +1,12 @@
+apply plugin: 'com.android.application'
+apply plugin: 'kotlin-android'
+apply plugin: 'kotlinx-serialization'
+
+ext {
+ extName = 'Disaster Scans'
+ pkgNameSuffix = 'en.disasterscans'
+ extClass = '.DisasterScans'
+ extVersionCode = 32
+}
+
+apply from: "$rootDir/common.gradle"
diff --git a/multisrc/overrides/madara/disasterscans/res/mipmap-hdpi/ic_launcher.png b/src/en/disasterscans/res/mipmap-hdpi/ic_launcher.png
similarity index 100%
rename from multisrc/overrides/madara/disasterscans/res/mipmap-hdpi/ic_launcher.png
rename to src/en/disasterscans/res/mipmap-hdpi/ic_launcher.png
diff --git a/multisrc/overrides/madara/disasterscans/res/mipmap-mdpi/ic_launcher.png b/src/en/disasterscans/res/mipmap-mdpi/ic_launcher.png
similarity index 100%
rename from multisrc/overrides/madara/disasterscans/res/mipmap-mdpi/ic_launcher.png
rename to src/en/disasterscans/res/mipmap-mdpi/ic_launcher.png
diff --git a/multisrc/overrides/madara/disasterscans/res/mipmap-xhdpi/ic_launcher.png b/src/en/disasterscans/res/mipmap-xhdpi/ic_launcher.png
similarity index 100%
rename from multisrc/overrides/madara/disasterscans/res/mipmap-xhdpi/ic_launcher.png
rename to src/en/disasterscans/res/mipmap-xhdpi/ic_launcher.png
diff --git a/multisrc/overrides/madara/disasterscans/res/mipmap-xxhdpi/ic_launcher.png b/src/en/disasterscans/res/mipmap-xxhdpi/ic_launcher.png
similarity index 100%
rename from multisrc/overrides/madara/disasterscans/res/mipmap-xxhdpi/ic_launcher.png
rename to src/en/disasterscans/res/mipmap-xxhdpi/ic_launcher.png
diff --git a/multisrc/overrides/madara/disasterscans/res/mipmap-xxxhdpi/ic_launcher.png b/src/en/disasterscans/res/mipmap-xxxhdpi/ic_launcher.png
similarity index 100%
rename from multisrc/overrides/madara/disasterscans/res/mipmap-xxxhdpi/ic_launcher.png
rename to src/en/disasterscans/res/mipmap-xxxhdpi/ic_launcher.png
diff --git a/multisrc/overrides/madara/disasterscans/res/web_hi_res_512.png b/src/en/disasterscans/res/web_hi_res_512.png
similarity index 100%
rename from multisrc/overrides/madara/disasterscans/res/web_hi_res_512.png
rename to src/en/disasterscans/res/web_hi_res_512.png
diff --git a/src/en/disasterscans/src/eu/kanade/tachiyomi/extension/en/disasterscans/DisasterScans.kt b/src/en/disasterscans/src/eu/kanade/tachiyomi/extension/en/disasterscans/DisasterScans.kt
new file mode 100644
index 000000000..5ae784f62
--- /dev/null
+++ b/src/en/disasterscans/src/eu/kanade/tachiyomi/extension/en/disasterscans/DisasterScans.kt
@@ -0,0 +1,209 @@
+package eu.kanade.tachiyomi.extension.en.disasterscans
+
+import android.app.Application
+import android.content.SharedPreferences
+import eu.kanade.tachiyomi.network.GET
+import eu.kanade.tachiyomi.network.asObservableSuccess
+import eu.kanade.tachiyomi.network.interceptor.rateLimit
+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 eu.kanade.tachiyomi.util.asJsoup
+import kotlinx.serialization.decodeFromString
+import kotlinx.serialization.json.Json
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.Response
+import org.jsoup.nodes.Document
+import rx.Observable
+import uy.kohesive.injekt.Injekt
+import uy.kohesive.injekt.api.get
+import uy.kohesive.injekt.injectLazy
+import java.text.SimpleDateFormat
+import java.util.Locale
+
+class DisasterScans : HttpSource() {
+
+ override val name = "Disaster Scans"
+
+ override val lang = "en"
+
+ override val versionId = 2
+
+ override val baseUrl = "https://disasterscans.com"
+
+ private val apiUrl = "https://api.disasterscans.com"
+
+ override val supportsLatest = false
+
+ override val client: OkHttpClient = network.cloudflareClient.newBuilder()
+ .addInterceptor { chain ->
+ val request = chain.request()
+ val url = request.url
+ if (url.fragment == "thumbnail") {
+ val cdnUrl = preferences.getCdnUrl()
+ val requestUrl = url.toString().substringBefore("=") + "="
+ if (cdnUrl != requestUrl) {
+ val fileId = url.queryParameterValues("fileId").first()
+ return@addInterceptor chain.proceed(
+ request.newBuilder()
+ .url("$cdnUrl$fileId")
+ .build(),
+ )
+ }
+ }
+ return@addInterceptor chain.proceed(request)
+ }
+ .rateLimit(1)
+ .build()
+
+ private val json: Json by injectLazy()
+
+ private val preferences: SharedPreferences by lazy {
+ Injekt.get().getSharedPreferences("source_$id", 0x0000)
+ }
+
+ override fun popularMangaRequest(page: Int): Request {
+ return GET("$apiUrl/comics/search/comics", headers)
+ }
+
+ override fun popularMangaParse(response: Response): MangasPage {
+ val comics = response.parseAs>()
+
+ val cdnUrl = preferences.getCdnUrl()
+
+ return MangasPage(comics.map { it.toSManga(cdnUrl) }, false)
+ }
+
+ override fun fetchSearchManga(
+ page: Int,
+ query: String,
+ filters: FilterList,
+ ): Observable {
+ return if (query.startsWith(PREFIX_SLUG)) {
+ val url = "/comics/${query.substringAfter(PREFIX_SLUG)}"
+ val manga = SManga.create().apply { this.url = url }
+ client.newCall(mangaDetailsRequest(manga))
+ .asObservableSuccess()
+ .map { mangaDetailsParse(it).apply { this.url = url } }
+ .map { MangasPage(listOf(it), false) }
+ } else {
+ client.newCall(searchMangaRequest(page, query, filters))
+ .asObservableSuccess()
+ .map { searchMangaParse(it, query) }
+ }
+ }
+
+ override fun searchMangaRequest(page: Int, query: String, filters: FilterList) = popularMangaRequest(page)
+
+ private fun searchMangaParse(response: Response, query: String): MangasPage {
+ val comics = response.parseAs>()
+
+ val cdnUrl = preferences.getCdnUrl()
+
+ return comics
+ .filter { it.ComicTitle.contains(query, true) }
+ .map { it.toSManga(cdnUrl) }
+ .let { MangasPage(it, false) }
+ }
+
+ override fun mangaDetailsRequest(manga: SManga): Request {
+ return GET("$apiUrl${manga.url}", headers)
+ }
+
+ override fun mangaDetailsParse(response: Response): SManga {
+ val comic = response.parseAs()
+
+ return comic.toSManga(json, preferences.getCdnUrl())
+ }
+
+ override fun getMangaUrl(manga: SManga) = "$baseUrl${manga.url}"
+
+ override fun chapterListRequest(manga: SManga): Request {
+ val url = "$apiUrl${manga.url.replace("comics", "chapters")}"
+ return GET(url, headers)
+ }
+
+ override fun chapterListParse(response: Response): List {
+ val chapters = response.parseAs>()
+
+ val mangaUrl = response.request.url.toString()
+ .substringAfter(apiUrl)
+ .replace("chapters", "comics")
+
+ return chapters.map { it.toSChapter(mangaUrl) }
+ }
+
+ override fun pageListParse(response: Response): List {
+ val document = response.asJsoup()
+
+ val chapterPages = document.select("#__NEXT_DATA__").html()
+ .parseAs>()
+ .props.pageProps.chapter.pages
+
+ val pages = chapterPages.parseAs>()
+
+ val cdnUrl = updatedCdnUrl(document)
+
+ return pages.mapIndexed { idx, image ->
+ Page(idx, "", "$cdnUrl$image")
+ }
+ }
+
+ private fun updatedCdnUrl(document: Document): String {
+ val cdnUrlFromPage = document.selectFirst("main div.maxWidth img")
+ ?.attr("src")
+ ?.substringBefore("?")
+ ?.let { "$it?fileId=" }
+
+ return preferences.getCdnUrl()
+ .let {
+ if (it != cdnUrlFromPage && cdnUrlFromPage != null) {
+ preferences.putCdnUrl(cdnUrlFromPage)
+ cdnUrlFromPage
+ } else {
+ it
+ }
+ }
+ }
+
+ private inline fun String.parseAs(): T =
+ json.decodeFromString(this)
+
+ private inline fun Response.parseAs(): T =
+ body.string().parseAs()
+
+ private fun SharedPreferences.getCdnUrl(): String {
+ return getString(cdnPref, fallbackCdnUrl) ?: fallbackCdnUrl
+ }
+
+ private fun SharedPreferences.putCdnUrl(url: String) {
+ edit().putString(cdnPref, url).commit()
+ }
+
+ companion object {
+ private const val fallbackCdnUrl = "https://f005.backblazeb2.com/b2api/v1/b2_download_file_by_id?fileId="
+ private const val cdnPref = "cdn_pref"
+ val titleSpecialCharactersRegex = "[^a-z0-9]+".toRegex()
+ val trailingHyphenRegex = "-+$".toRegex()
+ val dateFormat by lazy {
+ SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.ENGLISH)
+ }
+ const val PREFIX_SLUG = "slug:"
+ }
+
+ override fun searchMangaParse(response: Response) =
+ throw UnsupportedOperationException("Not Used")
+
+ override fun imageUrlParse(response: Response) =
+ throw UnsupportedOperationException("Not Used")
+
+ override fun latestUpdatesParse(response: Response) =
+ throw UnsupportedOperationException("Not Implemented")
+
+ override fun latestUpdatesRequest(page: Int) =
+ throw UnsupportedOperationException("Not Implemented")
+}
diff --git a/src/en/disasterscans/src/eu/kanade/tachiyomi/extension/en/disasterscans/DisasterScansDto.kt b/src/en/disasterscans/src/eu/kanade/tachiyomi/extension/en/disasterscans/DisasterScansDto.kt
new file mode 100644
index 000000000..490674e42
--- /dev/null
+++ b/src/en/disasterscans/src/eu/kanade/tachiyomi/extension/en/disasterscans/DisasterScansDto.kt
@@ -0,0 +1,96 @@
+package eu.kanade.tachiyomi.extension.en.disasterscans
+
+import eu.kanade.tachiyomi.source.model.SChapter
+import eu.kanade.tachiyomi.source.model.SManga
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.decodeFromString
+import kotlinx.serialization.json.Json
+
+@Serializable
+data class ApiSearchComic(
+ val id: String,
+ val ComicTitle: String,
+ val CoverImage: String,
+) {
+ fun toSManga(cdnUrl: String) = SManga.create().apply {
+ title = ComicTitle
+ thumbnail_url = "$cdnUrl$CoverImage#thumbnail"
+ url = "/comics/$id-${ComicTitle.titleToSlug()}"
+ }
+}
+
+@Serializable
+data class ApiComic(
+ val id: String,
+ val ComicTitle: String,
+ val Description: String,
+ val CoverImage: String,
+ val Status: String,
+ val Genres: String,
+ val Author: String,
+ val Artist: String,
+) {
+ fun toSManga(json: Json, cdnUrl: String) = SManga.create().apply {
+ title = ComicTitle
+ thumbnail_url = "$cdnUrl$CoverImage#thumbnail"
+ url = "/comics/$id-${ComicTitle.titleToSlug()}"
+ description = Description
+ author = Author
+ artist = Artist
+ genre = json.decodeFromString>(Genres).joinToString()
+ status = Status.parseStatus()
+ }
+}
+
+@Serializable
+data class ApiChapter(
+ val chapterID: Int,
+ val chapterNumber: String,
+ val ChapterName: String,
+ val chapterDate: String,
+) {
+ fun toSChapter(mangaUrl: String) = SChapter.create().apply {
+ url = "$mangaUrl/$chapterID-chapter-$chapterNumber"
+ chapter_number = chapterNumber.toFloat()
+ name = "Chapter $chapterNumber"
+ if (ChapterName.isNotEmpty()) {
+ name += ": $ChapterName"
+ }
+ date_upload = chapterDate.parseDate()
+ }
+}
+
+@Serializable
+data class NextData(
+ val props: Props,
+) {
+ @Serializable
+ data class Props(val pageProps: T)
+}
+
+@Serializable
+data class ApiChapterPages(
+ val chapter: ApiPages,
+) {
+ @Serializable
+ data class ApiPages(val pages: String)
+}
+
+private fun String.titleToSlug() = this.trim()
+ .lowercase()
+ .replace(DisasterScans.titleSpecialCharactersRegex, "-")
+ .replace(DisasterScans.trailingHyphenRegex, "")
+
+private fun String.parseDate(): Long {
+ return runCatching {
+ DisasterScans.dateFormat.parse(this)!!.time
+ }.getOrDefault(0L)
+}
+
+private fun String.parseStatus(): Int {
+ return when {
+ contains("ongoing", true) -> SManga.ONGOING
+ contains("completed", true) -> SManga.COMPLETED
+ else -> SManga.UNKNOWN
+ }
+}
diff --git a/src/en/disasterscans/src/eu/kanade/tachiyomi/extension/en/disasterscans/DisasterScansUrlActivity.kt b/src/en/disasterscans/src/eu/kanade/tachiyomi/extension/en/disasterscans/DisasterScansUrlActivity.kt
new file mode 100644
index 000000000..63fdd53a6
--- /dev/null
+++ b/src/en/disasterscans/src/eu/kanade/tachiyomi/extension/en/disasterscans/DisasterScansUrlActivity.kt
@@ -0,0 +1,34 @@
+package eu.kanade.tachiyomi.extension.en.disasterscans
+
+import android.app.Activity
+import android.content.ActivityNotFoundException
+import android.content.Intent
+import android.os.Bundle
+import android.util.Log
+import kotlin.system.exitProcess
+
+class DisasterScansUrlActivity : Activity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ val pathSegments = intent?.data?.pathSegments
+ if (pathSegments != null && pathSegments.size > 1) {
+ val slug = pathSegments[1]
+ val mainIntent = Intent().apply {
+ action = "eu.kanade.tachiyomi.SEARCH"
+ putExtra("query", "${DisasterScans.PREFIX_SLUG}$slug")
+ putExtra("filter", packageName)
+ }
+
+ try {
+ startActivity(mainIntent)
+ } catch (e: ActivityNotFoundException) {
+ Log.e("DisasterScansUrl", e.toString())
+ }
+ } else {
+ Log.e("DisasterScansUrl", "could not parse uri from intent $intent")
+ }
+
+ finish()
+ exitProcess(0)
+ }
+}