diff --git a/.run/HeanCmsGenerator.run.xml b/.run/HeanCmsGenerator.run.xml
new file mode 100644
index 000000000..f3b736bdd
--- /dev/null
+++ b/.run/HeanCmsGenerator.run.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/multisrc/overrides/heancms/default/res/mipmap-hdpi/ic_launcher.png b/multisrc/overrides/heancms/default/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 000000000..906d6af2b
Binary files /dev/null and b/multisrc/overrides/heancms/default/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/multisrc/overrides/heancms/default/res/mipmap-mdpi/ic_launcher.png b/multisrc/overrides/heancms/default/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 000000000..702ad7b64
Binary files /dev/null and b/multisrc/overrides/heancms/default/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/multisrc/overrides/heancms/default/res/mipmap-xhdpi/ic_launcher.png b/multisrc/overrides/heancms/default/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 000000000..7c8f5d756
Binary files /dev/null and b/multisrc/overrides/heancms/default/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/multisrc/overrides/heancms/default/res/mipmap-xxhdpi/ic_launcher.png b/multisrc/overrides/heancms/default/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 000000000..cc83692bb
Binary files /dev/null and b/multisrc/overrides/heancms/default/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/multisrc/overrides/heancms/default/res/mipmap-xxxhdpi/ic_launcher.png b/multisrc/overrides/heancms/default/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 000000000..57c4616cc
Binary files /dev/null and b/multisrc/overrides/heancms/default/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/multisrc/overrides/heancms/default/res/web_hi_res_512.png b/multisrc/overrides/heancms/default/res/web_hi_res_512.png
new file mode 100644
index 000000000..42d475ae1
Binary files /dev/null and b/multisrc/overrides/heancms/default/res/web_hi_res_512.png differ
diff --git a/multisrc/overrides/heancms/reaperscans/res/mipmap-hdpi/ic_launcher.png b/multisrc/overrides/heancms/reaperscans/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 000000000..f6c5fc5a7
Binary files /dev/null and b/multisrc/overrides/heancms/reaperscans/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/multisrc/overrides/heancms/reaperscans/res/mipmap-mdpi/ic_launcher.png b/multisrc/overrides/heancms/reaperscans/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 000000000..e7ffd65f3
Binary files /dev/null and b/multisrc/overrides/heancms/reaperscans/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/multisrc/overrides/heancms/reaperscans/res/mipmap-xhdpi/ic_launcher.png b/multisrc/overrides/heancms/reaperscans/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 000000000..d79fd54e9
Binary files /dev/null and b/multisrc/overrides/heancms/reaperscans/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/multisrc/overrides/heancms/reaperscans/res/mipmap-xxhdpi/ic_launcher.png b/multisrc/overrides/heancms/reaperscans/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 000000000..779d9aea3
Binary files /dev/null and b/multisrc/overrides/heancms/reaperscans/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/multisrc/overrides/heancms/reaperscans/res/mipmap-xxxhdpi/ic_launcher.png b/multisrc/overrides/heancms/reaperscans/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 000000000..08a0864d9
Binary files /dev/null and b/multisrc/overrides/heancms/reaperscans/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/multisrc/overrides/heancms/reaperscans/res/web_hi_res_512.png b/multisrc/overrides/heancms/reaperscans/res/web_hi_res_512.png
new file mode 100644
index 000000000..1c6f8c99c
Binary files /dev/null and b/multisrc/overrides/heancms/reaperscans/res/web_hi_res_512.png differ
diff --git a/multisrc/overrides/heancms/reaperscans/src/ReaperScans.kt b/multisrc/overrides/heancms/reaperscans/src/ReaperScans.kt
new file mode 100644
index 000000000..4c324546e
--- /dev/null
+++ b/multisrc/overrides/heancms/reaperscans/src/ReaperScans.kt
@@ -0,0 +1,48 @@
+package eu.kanade.tachiyomi.extension.pt.reaperscans
+
+import eu.kanade.tachiyomi.multisrc.heancms.Genre
+import eu.kanade.tachiyomi.multisrc.heancms.HeanCms
+import eu.kanade.tachiyomi.network.interceptor.rateLimitHost
+import okhttp3.HttpUrl.Companion.toHttpUrl
+import okhttp3.OkHttpClient
+
+class ReaperScans : HeanCms(
+ "Reaper Scans",
+ "https://reaperscans.com.br",
+ "pt-BR"
+) {
+
+ override val client: OkHttpClient = super.client.newBuilder()
+ .rateLimitHost(apiUrl.toHttpUrl(), 1, 2)
+ .build()
+
+ // Site changed from Madara to HeanCms.
+ override val versionId = 2
+
+ override fun getGenreList(): List = listOf(
+ Genre("Artes Marciais", 2),
+ Genre("Aventura", 10),
+ Genre("Ação", 9),
+ Genre("Comédia", 14),
+ Genre("Drama", 15),
+ Genre("Escolar", 7),
+ Genre("Fantasia", 11),
+ Genre("Ficção científica", 16),
+ Genre("Guerra", 17),
+ Genre("Isekai", 18),
+ Genre("Jogo", 12),
+ Genre("Mangá", 24),
+ Genre("Manhua", 23),
+ Genre("Manhwa", 22),
+ Genre("Mecha", 19),
+ Genre("Mistério", 20),
+ Genre("Nacional", 8),
+ Genre("Realidade Virtual", 21),
+ Genre("Retorno", 3),
+ Genre("Romance", 5),
+ Genre("Segunda vida", 4),
+ Genre("Seinen", 1),
+ Genre("Shounen", 13),
+ Genre("Terror", 6)
+ )
+}
diff --git a/multisrc/overrides/heancms/yugenmangas/res/mipmap-hdpi/ic_launcher.png b/multisrc/overrides/heancms/yugenmangas/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 000000000..4ffac3c37
Binary files /dev/null and b/multisrc/overrides/heancms/yugenmangas/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/multisrc/overrides/heancms/yugenmangas/res/mipmap-mdpi/ic_launcher.png b/multisrc/overrides/heancms/yugenmangas/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 000000000..951c8a5b3
Binary files /dev/null and b/multisrc/overrides/heancms/yugenmangas/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/multisrc/overrides/heancms/yugenmangas/res/mipmap-xhdpi/ic_launcher.png b/multisrc/overrides/heancms/yugenmangas/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 000000000..e0c512945
Binary files /dev/null and b/multisrc/overrides/heancms/yugenmangas/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/multisrc/overrides/heancms/yugenmangas/res/mipmap-xxhdpi/ic_launcher.png b/multisrc/overrides/heancms/yugenmangas/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 000000000..e7c0b0c7d
Binary files /dev/null and b/multisrc/overrides/heancms/yugenmangas/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/multisrc/overrides/heancms/yugenmangas/res/mipmap-xxxhdpi/ic_launcher.png b/multisrc/overrides/heancms/yugenmangas/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 000000000..110565c56
Binary files /dev/null and b/multisrc/overrides/heancms/yugenmangas/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/multisrc/overrides/heancms/yugenmangas/res/web_hi_res_512.png b/multisrc/overrides/heancms/yugenmangas/res/web_hi_res_512.png
new file mode 100644
index 000000000..6e3163991
Binary files /dev/null and b/multisrc/overrides/heancms/yugenmangas/res/web_hi_res_512.png differ
diff --git a/multisrc/overrides/heancms/yugenmangas/src/YugenMangas.kt b/multisrc/overrides/heancms/yugenmangas/src/YugenMangas.kt
new file mode 100644
index 000000000..cf01a7677
--- /dev/null
+++ b/multisrc/overrides/heancms/yugenmangas/src/YugenMangas.kt
@@ -0,0 +1,61 @@
+package eu.kanade.tachiyomi.extension.es.yugenmangas
+
+import eu.kanade.tachiyomi.multisrc.heancms.Genre
+import eu.kanade.tachiyomi.multisrc.heancms.HeanCms
+
+class YugenMangas : HeanCms("YugenMangas", "https://yugenmangas.com", "es") {
+
+ // Site changed from Madara to HeanCms.
+ override val versionId = 2
+
+ override fun getGenreList(): List = listOf(
+ Genre("+18", 1),
+ Genre("Acción", 36),
+ Genre("Adulto", 38),
+ Genre("Apocalíptico", 3),
+ Genre("Artes marciales (1)", 16),
+ Genre("Artes marciales (2)", 37),
+ Genre("Aventura", 2),
+ Genre("Boys Love", 4),
+ Genre("Ciencia ficción", 39),
+ Genre("Comedia", 5),
+ Genre("Demonios", 6),
+ Genre("Deporte", 26),
+ Genre("Drama", 7),
+ Genre("Ecchi", 8),
+ Genre("Familia", 9),
+ Genre("Fantasía", 10),
+ Genre("Girls Love", 11),
+ Genre("Gore", 12),
+ Genre("Harem", 13),
+ Genre("Harem inverso", 14),
+ Genre("Histórico", 48),
+ Genre("Horror", 41),
+ Genre("Isekai", 40),
+ Genre("Josei", 15),
+ Genre("Maduro", 42),
+ Genre("Magia", 17),
+ Genre("MangoScan", 35),
+ Genre("Mecha", 18),
+ Genre("Militar", 19),
+ Genre("Misterio", 20),
+ Genre("Psicológico", 21),
+ Genre("Realidad virtual", 46),
+ Genre("Recuentos de la vida", 25),
+ Genre("Reencarnación", 22),
+ Genre("Regresion", 23),
+ Genre("Romance", 24),
+ Genre("Seinen", 27),
+ Genre("Shonen", 28),
+ Genre("Shoujo", 29),
+ Genre("Sistema", 45),
+ Genre("Smut", 30),
+ Genre("Supernatural", 31),
+ Genre("Supervivencia", 32),
+ Genre("Tragedia", 33),
+ Genre("Transmigración", 34),
+ Genre("Vida Escolar", 47),
+ Genre("Yaoi", 43),
+ Genre("Yuri", 44)
+ )
+}
diff --git a/multisrc/overrides/madara/yugenmangas/src/YugenMangasFactory.kt b/multisrc/overrides/madara/yugenmangas/src/YugenMangas.kt
similarity index 67%
rename from multisrc/overrides/madara/yugenmangas/src/YugenMangasFactory.kt
rename to multisrc/overrides/madara/yugenmangas/src/YugenMangas.kt
index f5c1202de..2a0c26b1b 100644
--- a/multisrc/overrides/madara/yugenmangas/src/YugenMangasFactory.kt
+++ b/multisrc/overrides/madara/yugenmangas/src/YugenMangas.kt
@@ -1,11 +1,10 @@
-package eu.kanade.tachiyomi.extension.all.yugenmangas
+package eu.kanade.tachiyomi.extension.pt.yugenmangas
import eu.kanade.tachiyomi.multisrc.madara.Madara
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.interceptor.rateLimit
-import eu.kanade.tachiyomi.source.SourceFactory
import eu.kanade.tachiyomi.source.model.SChapter
-import eu.kanade.tachiyomi.util.asJsoup
+import kotlinx.serialization.decodeFromString
import okhttp3.Headers
import okhttp3.Interceptor
import okhttp3.OkHttpClient
@@ -15,34 +14,8 @@ import java.text.SimpleDateFormat
import java.util.Locale
import java.util.concurrent.TimeUnit
-class YugenMangasFactory : SourceFactory {
- override fun createSources() = listOf(
- YugenMangasEs(),
- YugenMangasBr()
- )
-}
-
-abstract class YugenMangas(
- override val baseUrl: String,
- lang: String,
- dateFormat: SimpleDateFormat = SimpleDateFormat("MMMMM dd, yyyy", Locale.US)
-) : Madara("YugenMangas", baseUrl, lang, dateFormat) {
-
- override fun chapterFromElement(element: Element): SChapter = SChapter.create().apply {
- name = element.selectFirst("p.chapter-manhwa-title")!!.text()
- date_upload = parseChapterDate(element.selectFirst("span.chapter-release-date i")?.text())
-
- val chapterUrl = element.selectFirst("a")!!.attr("abs:href")
- setUrlWithoutDomain(
- chapterUrl.substringBefore("?style=paged") +
- if (!chapterUrl.endsWith(chapterUrlSuffix)) chapterUrlSuffix else ""
- )
- }
-}
-
-class YugenMangasEs : YugenMangas("https://yugenmangas.com", "es")
-
-class YugenMangasBr : YugenMangas(
+class YugenMangas : Madara(
+ "YugenMangas",
"https://yugenmangas.com.br",
"pt-BR",
SimpleDateFormat("MMMMM dd, yyyy", Locale("pt", "BR"))
@@ -64,19 +37,26 @@ class YugenMangasBr : YugenMangas(
override val useNewChapterEndpoint: Boolean = true
+ override fun chapterFromElement(element: Element): SChapter = SChapter.create().apply {
+ name = element.selectFirst("p.chapter-manhwa-title")!!.text()
+ date_upload = parseChapterDate(element.selectFirst("span.chapter-release-date i")?.text())
+
+ val chapterUrl = element.selectFirst("a")!!.attr("abs:href")
+ setUrlWithoutDomain(
+ chapterUrl.substringBefore("?style=paged") +
+ if (!chapterUrl.endsWith(chapterUrlSuffix)) chapterUrlSuffix else ""
+ )
+ }
+
private var userAgent: String? = null
private var checkedUa = false
private fun uaIntercept(chain: Interceptor.Chain): Response {
if (userAgent == null && !checkedUa) {
- val browser = BROWSERS.random()
- val uaResponse = chain.proceed(GET("$UA_DB_URL/$browser"))
+ val uaResponse = chain.proceed(GET(UA_DB_URL))
if (uaResponse.isSuccessful) {
- userAgent = uaResponse.asJsoup()
- .select(".listing-of-useragents span.code")
- .firstOrNull()
- ?.text()
+ userAgent = json.decodeFromString>(uaResponse.body!!.string()).random()
checkedUa = true
}
@@ -95,7 +75,6 @@ class YugenMangasBr : YugenMangas(
}
companion object {
- private val BROWSERS = arrayOf("chrome", "firefox", "edge", "opera", "vivaldi")
- private const val UA_DB_URL = "https://whatismybrowser.com/guides/the-latest-user-agent"
+ private const val UA_DB_URL = "https://tachiyomiorg.github.io/user-agents/user-agents.json"
}
}
diff --git a/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/heancms/HeanCms.kt b/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/heancms/HeanCms.kt
new file mode 100644
index 000000000..59c3a5d9c
--- /dev/null
+++ b/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/heancms/HeanCms.kt
@@ -0,0 +1,289 @@
+package eu.kanade.tachiyomi.multisrc.heancms
+
+import eu.kanade.tachiyomi.network.GET
+import eu.kanade.tachiyomi.network.POST
+import eu.kanade.tachiyomi.network.asObservableSuccess
+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.encodeToString
+import kotlinx.serialization.json.Json
+import okhttp3.Headers
+import okhttp3.HttpUrl.Companion.toHttpUrl
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.RequestBody.Companion.toRequestBody
+import okhttp3.Response
+import rx.Observable
+import uy.kohesive.injekt.injectLazy
+
+abstract class HeanCms(
+ override val name: String,
+ override val baseUrl: String,
+ override val lang: String,
+ protected val apiUrl: String = baseUrl.replace("://", "://api.")
+) : HttpSource() {
+
+ override val supportsLatest = true
+
+ override val client: OkHttpClient = network.cloudflareClient
+
+ protected val json: Json by injectLazy()
+
+ protected val intl by lazy { HeanCmsIntl(lang) }
+
+ private var seriesSlugMap: Map? = null
+
+ override fun headersBuilder(): Headers.Builder = Headers.Builder()
+ .add("Origin", baseUrl)
+ .add("Referer", "$baseUrl/")
+
+ override fun popularMangaRequest(page: Int): Request {
+ val payloadObj = HeanCmsSearchDto(
+ order = "desc",
+ orderBy = "total_views",
+ status = "Ongoing",
+ type = "Comic"
+ )
+
+ val payload = json.encodeToString(payloadObj).toRequestBody(JSON_MEDIA_TYPE)
+
+ val apiHeaders = headersBuilder()
+ .add("Accept", ACCEPT_JSON)
+ .add("Content-Type", payload.contentType().toString())
+ .build()
+
+ return POST("$apiUrl/series/querysearch", apiHeaders, payload)
+ }
+
+ override fun popularMangaParse(response: Response): MangasPage {
+ val mangaList = response.parseAs>()
+ .map { it.toSManga(apiUrl) }
+
+ fetchAllTitles()
+
+ return MangasPage(mangaList, hasNextPage = false)
+ }
+
+ override fun latestUpdatesRequest(page: Int): Request {
+ val payloadObj = HeanCmsSearchDto(
+ order = "desc",
+ orderBy = "latest",
+ status = "Ongoing",
+ type = "Comic"
+ )
+
+ val payload = json.encodeToString(payloadObj).toRequestBody(JSON_MEDIA_TYPE)
+
+ val apiHeaders = headersBuilder()
+ .add("Accept", ACCEPT_JSON)
+ .add("Content-Type", payload.contentType().toString())
+ .build()
+
+ return POST("$apiUrl/series/querysearch", apiHeaders, payload)
+ }
+
+ override fun latestUpdatesParse(response: Response): MangasPage = popularMangaParse(response)
+
+ override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
+ val sortByFilter = filters.firstInstanceOrNull()
+
+ val payloadObj = HeanCmsSearchDto(
+ order = if (sortByFilter?.state?.ascending == true) "asc" else "desc",
+ orderBy = sortByFilter?.selected ?: "total_views",
+ status = filters.firstInstanceOrNull()?.selected?.value ?: "Ongoing",
+ type = "Comic",
+ tagIds = filters.firstInstanceOrNull()?.state
+ ?.filter(Genre::state)
+ ?.map(Genre::id)
+ .orEmpty()
+ )
+
+ val payload = json.encodeToString(payloadObj).toRequestBody(JSON_MEDIA_TYPE)
+
+ val apiHeaders = headersBuilder()
+ .add("Accept", ACCEPT_JSON)
+ .add("Content-Type", payload.contentType().toString())
+ .build()
+
+ val apiUrl = "$apiUrl/series/querysearch".toHttpUrl().newBuilder()
+ .addQueryParameter("q", query)
+ .toString()
+
+ return POST(apiUrl, apiHeaders, payload)
+ }
+
+ override fun searchMangaParse(response: Response): MangasPage {
+ val query = response.request.url.queryParameter("q").orEmpty()
+
+ var mangaList = response.parseAs>()
+ .map { it.toSManga(apiUrl) }
+
+ if (query.isNotBlank()) {
+ mangaList = mangaList.filter { it.title.contains(query, ignoreCase = true) }
+ }
+
+ fetchAllTitles()
+
+ return MangasPage(mangaList, hasNextPage = false)
+ }
+
+ // Workaround to allow "Open in browser" use the real URL.
+ override fun fetchMangaDetails(manga: SManga): Observable {
+ return client.newCall(seriesDetailsRequest(manga))
+ .asObservableSuccess()
+ .map { response ->
+ mangaDetailsParse(response).apply { initialized = true }
+ }
+ }
+
+ private fun seriesDetailsRequest(manga: SManga): Request {
+ val seriesSlug = manga.url
+ .substringAfterLast("/")
+ .replace(TIMESTAMP_REGEX, "")
+
+ val currentSlug = seriesSlugMap?.get(seriesSlug) ?: seriesSlug
+
+ val apiHeaders = headersBuilder()
+ .add("Accept", ACCEPT_JSON)
+ .build()
+
+ return GET("$apiUrl/series/$currentSlug#${manga.status}", apiHeaders)
+ }
+
+ override fun mangaDetailsParse(response: Response): SManga {
+ val result = runCatching { response.parseAs() }
+ val seriesDetails = result.getOrNull()?.toSManga(apiUrl)
+ ?: throw Exception(intl.urlChangedError(name))
+
+ return seriesDetails.apply {
+ status = response.request.url.fragment?.toIntOrNull() ?: SManga.UNKNOWN
+ }
+ }
+
+ override fun chapterListRequest(manga: SManga): Request = seriesDetailsRequest(manga)
+
+ override fun chapterListParse(response: Response): List {
+ val result = response.parseAs()
+ val seriesSlug = response.request.url.pathSegments.last()
+
+ return result.chapters.orEmpty()
+ .map { it.toSChapter(seriesSlug) }
+ .reversed()
+ }
+
+ override fun pageListRequest(chapter: SChapter): Request {
+ val chapterId = chapter.url.substringAfterLast("#")
+
+ val apiHeaders = headersBuilder()
+ .add("Accept", ACCEPT_JSON)
+ .build()
+
+ return GET("$apiUrl/series/chapter/$chapterId", apiHeaders)
+ }
+
+ override fun pageListParse(response: Response): List {
+ return response.parseAs().content?.images.orEmpty()
+ .mapIndexed { i, url -> Page(i, "", "$apiUrl/$url") }
+ }
+
+ override fun fetchImageUrl(page: Page): Observable = Observable.just(page.imageUrl!!)
+
+ override fun imageUrlParse(response: Response): String = ""
+
+ override fun imageRequest(page: Page): Request {
+ val imageHeaders = headersBuilder()
+ .add("Accept", ACCEPT_IMAGE)
+ .build()
+
+ return GET(page.imageUrl!!, imageHeaders)
+ }
+
+ protected open fun getStatusList(): List = listOf(
+ Status(intl.statusOngoing, "Ongoing"),
+ Status(intl.statusOnHiatus, "Hiatus"),
+ Status(intl.statusDropped, "Dropped"),
+ )
+
+ protected open fun getSortProperties(): List = listOf(
+ SortProperty(intl.sortByTitle, "title"),
+ SortProperty(intl.sortByViews, "total_views"),
+ SortProperty(intl.sortByLatest, "latest"),
+ SortProperty(intl.sortByRecentlyAdded, "recently_added"),
+ )
+
+ protected open fun getGenreList(): List = emptyList()
+
+ protected open fun fetchAllTitles() {
+ if (!seriesSlugMap.isNullOrEmpty()) {
+ return
+ }
+
+ val result = runCatching {
+ client.newCall(allTitlesRequest()).execute()
+ .let { parseAllTitles(it) }
+ }
+
+ seriesSlugMap = result.getOrNull()
+ }
+
+ protected open fun allTitlesRequest(): Request {
+ val payloadObj = HeanCmsSearchDto(
+ order = "desc",
+ orderBy = "total_views",
+ status = "",
+ type = "Comic"
+ )
+
+ val payload = json.encodeToString(payloadObj).toRequestBody(JSON_MEDIA_TYPE)
+
+ val apiHeaders = headersBuilder()
+ .add("Accept", ACCEPT_JSON)
+ .add("Content-Type", payload.contentType().toString())
+ .build()
+
+ return POST("$apiUrl/series/querysearch", apiHeaders, payload)
+ }
+
+ protected open fun parseAllTitles(response: Response): Map {
+ return response.parseAs>()
+ .filter { it.type == "Comic" }
+ .associateBy(
+ keySelector = { it.slug.replace(TIMESTAMP_REGEX, "") },
+ valueTransform = HeanCmsSeriesDto::slug
+ )
+ }
+
+ override fun getFilterList(): FilterList {
+ val genres = getGenreList()
+
+ val filters = listOfNotNull(
+ StatusFilter(intl.statusFilterTitle, getStatusList()),
+ SortByFilter(intl.sortByFilterTitle, getSortProperties()),
+ GenreFilter(intl.genreFilterTitle, genres).takeIf { genres.isNotEmpty() }
+ )
+
+ return FilterList(filters)
+ }
+
+ private inline fun Response.parseAs(): T = use {
+ json.decodeFromString(it.body?.string().orEmpty())
+ }
+
+ private inline fun List<*>.firstInstanceOrNull(): R? =
+ filterIsInstance().firstOrNull()
+
+ companion object {
+ private const val ACCEPT_IMAGE = "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8"
+ private const val ACCEPT_JSON = "application/json, text/plain, */*"
+
+ private val JSON_MEDIA_TYPE = "application/json".toMediaType()
+
+ val TIMESTAMP_REGEX = "-\\d+$".toRegex()
+ }
+}
diff --git a/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/heancms/HeanCmsDto.kt b/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/heancms/HeanCmsDto.kt
new file mode 100644
index 000000000..1c6f6d48b
--- /dev/null
+++ b/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/heancms/HeanCmsDto.kt
@@ -0,0 +1,93 @@
+package eu.kanade.tachiyomi.multisrc.heancms
+
+import eu.kanade.tachiyomi.source.model.SChapter
+import eu.kanade.tachiyomi.source.model.SManga
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import org.jsoup.Jsoup
+import java.text.SimpleDateFormat
+import java.util.Locale
+
+@Serializable
+data class HeanCmsSeriesDto(
+ val id: Int,
+ @SerialName("series_slug") val slug: String,
+ @SerialName("series_type") val type: String = "Comic",
+ val author: String? = null,
+ val description: String? = null,
+ val studio: String? = null,
+ val status: String? = null,
+ val thumbnail: String,
+ val title: String,
+ val tags: List? = emptyList(),
+ val chapters: List? = emptyList()
+) {
+
+ fun toSManga(apiUrl: String): SManga = SManga.create().apply {
+ val descriptionBody = this@HeanCmsSeriesDto.description?.let(Jsoup::parseBodyFragment)
+
+ title = this@HeanCmsSeriesDto.title
+ author = this@HeanCmsSeriesDto.author?.trim()
+ artist = this@HeanCmsSeriesDto.studio?.trim()
+ description = descriptionBody?.select("p")
+ ?.joinToString("\n\n") { it.text() }
+ ?.ifEmpty { descriptionBody.text().replace("\n", "\n\n") }
+ genre = tags.orEmpty()
+ .sortedBy(HeanCmsTagDto::name)
+ .joinToString { it.name }
+ thumbnail_url = "$apiUrl/cover/$thumbnail"
+ status = when (this@HeanCmsSeriesDto.status) {
+ "Ongoing" -> SManga.ONGOING
+ "Hiatus" -> SManga.ON_HIATUS
+ "Dropped" -> SManga.CANCELLED
+ "Completed", "Finished" -> SManga.COMPLETED
+ else -> SManga.UNKNOWN
+ }
+ url = "/series/${slug.replace(HeanCms.TIMESTAMP_REGEX, "")}"
+ }
+}
+
+@Serializable
+data class HeanCmsTagDto(val name: String)
+
+@Serializable
+data class HeanCmsChapterDto(
+ val id: Int,
+ @SerialName("chapter_name") val name: String,
+ @SerialName("chapter_slug") val slug: String,
+ val index: String,
+ @SerialName("created_at") val createdAt: String,
+) {
+
+ fun toSChapter(seriesSlug: String): SChapter = SChapter.create().apply {
+ name = this@HeanCmsChapterDto.name.trim()
+ date_upload = runCatching { DATE_FORMAT.parse(createdAt.substringBefore("."))?.time }
+ .getOrNull() ?: 0L
+ url = "/series/$seriesSlug/$slug#$id"
+ }
+
+ companion object {
+ private val DATE_FORMAT by lazy {
+ SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US)
+ }
+ }
+}
+
+@Serializable
+data class HeanCmsReaderDto(
+ val content: HeanCmsReaderContentDto? = null
+)
+
+@Serializable
+data class HeanCmsReaderContentDto(
+ val images: List? = emptyList()
+)
+
+@Serializable
+data class HeanCmsSearchDto(
+ val order: String,
+ @SerialName("order_by") val orderBy: String,
+ @SerialName("series_status") val status: String,
+ @SerialName("series_type") val type: String,
+ @SerialName("tags_ids") val tagIds: List = emptyList()
+)
diff --git a/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/heancms/HeanCmsFilters.kt b/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/heancms/HeanCmsFilters.kt
new file mode 100644
index 000000000..14c3c919b
--- /dev/null
+++ b/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/heancms/HeanCmsFilters.kt
@@ -0,0 +1,32 @@
+package eu.kanade.tachiyomi.multisrc.heancms
+
+import eu.kanade.tachiyomi.source.model.Filter
+
+class Genre(title: String, val id: Int) : Filter.CheckBox(title)
+
+class GenreFilter(title: String, genres: List) : Filter.Group(title, genres)
+
+open class EnhancedSelect(name: String, values: Array) : Filter.Select(name, values) {
+ val selected: T
+ get() = values[state]
+}
+
+data class Status(val name: String, val value: String) {
+ override fun toString(): String = name
+}
+
+class StatusFilter(title: String, statuses: List) :
+ EnhancedSelect(title, statuses.toTypedArray())
+
+data class SortProperty(val name: String, val value: String) {
+ override fun toString(): String = name
+}
+
+class SortByFilter(title: String, private val sortProperties: List) : Filter.Sort(
+ title,
+ sortProperties.map { it.name }.toTypedArray(),
+ Selection(1, ascending = false)
+) {
+ val selected: String
+ get() = sortProperties[state!!.index].value
+}
diff --git a/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/heancms/HeanCmsGenerator.kt b/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/heancms/HeanCmsGenerator.kt
new file mode 100644
index 000000000..20acaeb5f
--- /dev/null
+++ b/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/heancms/HeanCmsGenerator.kt
@@ -0,0 +1,25 @@
+package eu.kanade.tachiyomi.multisrc.heancms
+
+import generator.ThemeSourceData.SingleLang
+import generator.ThemeSourceGenerator
+
+class HeanCmsGenerator : ThemeSourceGenerator {
+
+ override val themePkg = "heancms"
+
+ override val themeClass = "HeanCms"
+
+ override val baseVersionCode: Int = 1
+
+ override val sources = listOf(
+ SingleLang("Reaper Scans", "https://reaperscans.com.br", "pt-BR", overrideVersionCode = 33),
+ SingleLang("YugenMangas", "https://yugenmangas.com", "es", isNsfw = true),
+ )
+
+ companion object {
+ @JvmStatic
+ fun main(args: Array) {
+ HeanCmsGenerator().createAll()
+ }
+ }
+}
diff --git a/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/heancms/HeanCmsIntl.kt b/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/heancms/HeanCmsIntl.kt
new file mode 100644
index 000000000..a63beaf92
--- /dev/null
+++ b/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/heancms/HeanCmsIntl.kt
@@ -0,0 +1,86 @@
+package eu.kanade.tachiyomi.multisrc.heancms
+
+class HeanCmsIntl(lang: String) {
+
+ val availableLang: String = if (lang in AVAILABLE_LANGS) lang else ENGLISH
+
+ val genreFilterTitle: String = when (availableLang) {
+ BRAZILIAN_PORTUGUESE -> "Gêneros"
+ SPANISH -> "Géneros"
+ else -> "Genres"
+ }
+
+ val statusFilterTitle: String = when (availableLang) {
+ BRAZILIAN_PORTUGUESE -> "Estado"
+ SPANISH -> "Estado"
+ else -> "Status"
+ }
+
+ val statusOngoing: String = when (availableLang) {
+ BRAZILIAN_PORTUGUESE -> "Em andamento"
+ SPANISH -> "En curso"
+ else -> "Ongoing"
+ }
+
+ val statusOnHiatus: String = when (availableLang) {
+ BRAZILIAN_PORTUGUESE -> "Em hiato"
+ SPANISH -> "En hiatus"
+ else -> "Ongoing"
+ }
+
+ val statusDropped: String = when (availableLang) {
+ BRAZILIAN_PORTUGUESE -> "Cancelada"
+ SPANISH -> "Abandonada"
+ else -> "Dropped"
+ }
+
+ val sortByFilterTitle: String = when (availableLang) {
+ BRAZILIAN_PORTUGUESE -> "Ordenar por"
+ SPANISH -> "Ordenar por"
+ else -> "Sort by"
+ }
+
+ val sortByTitle: String = when (availableLang) {
+ BRAZILIAN_PORTUGUESE -> "Título"
+ SPANISH -> "Titulo"
+ else -> "Title"
+ }
+
+ val sortByViews: String = when (availableLang) {
+ BRAZILIAN_PORTUGUESE -> "Visualizações"
+ SPANISH -> "Número de vistas"
+ else -> "Views"
+ }
+
+ val sortByLatest: String = when (availableLang) {
+ BRAZILIAN_PORTUGUESE -> "Recentes"
+ SPANISH -> "Recientes"
+ else -> "Latest"
+ }
+
+ val sortByRecentlyAdded: String = when (availableLang) {
+ BRAZILIAN_PORTUGUESE -> "Data de criação"
+ SPANISH -> "Añadido recientemente"
+ else -> "Recently added"
+ }
+
+ fun urlChangedError(sourceName: String): String = when (availableLang) {
+ BRAZILIAN_PORTUGUESE ->
+ "A URL da série mudou. Migre de $sourceName " +
+ "para $sourceName para atualizar a URL."
+ SPANISH ->
+ "La URL de la serie ha cambiado. Migre de $sourceName a " +
+ "$sourceName para actualizar la URL."
+ else ->
+ "The URL of the series has changed. Migrate from $sourceName " +
+ "to $sourceName to update the URL."
+ }
+
+ companion object {
+ const val BRAZILIAN_PORTUGUESE = "pt-BR"
+ const val ENGLISH = "en"
+ const val SPANISH = "es"
+
+ val AVAILABLE_LANGS = arrayOf(BRAZILIAN_PORTUGUESE, ENGLISH, SPANISH)
+ }
+}
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 5cd9e6faf..dfeedb0b0 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
@@ -19,7 +19,6 @@ class MadaraGenerator : ThemeSourceGenerator {
MultiLang("Olympus Scanlation", "https://olympusscanlation.com", listOf("es", "pt-BR")),
MultiLang("Reaper Scans", "https://reaperscans.com", listOf("en", "fr", "id", "tr"), className = "ReaperScansFactory", pkgName = "reaperscans", overrideVersionCode = 7),
MultiLang("Seven King Scanlation", "https://sksubs.net", listOf("es", "en"), isNsfw = true),
- MultiLang("YugenMangas", "https://yugenmangas.com", listOf("es", "pt-BR"), overrideVersionCode = 3),
SingleLang("1st Kiss Manga.love", "https://1stkissmanga.love", "en", className = "FirstKissMangaLove", overrideVersionCode = 1),
SingleLang("1st Kiss Manhua", "https://1stkissmanhua.com", "en", className = "FirstKissManhua", overrideVersionCode = 3),
SingleLang("1st Kiss", "https://1stkissmanga.io", "en", className = "FirstKissManga", pkgName = "firstkissmanga", overrideVersionCode = 7),
@@ -486,6 +485,7 @@ class MadaraGenerator : ThemeSourceGenerator {
SingleLang("YaoiToon", "https://yaoitoon.com", "en", isNsfw = true),
SingleLang("Yetişkin Rüya Manga", "https://yetiskin.ruyamanga.com", "tr", isNsfw = true, className = "YetiskinRuyaManga"),
SingleLang("YonaBar", "https://yonabar.com", "ar", isNsfw = true, overrideVersionCode = 2),
+ SingleLang("YugenMangas", "https://yugenmangas.com.br", "pt-BR"),
SingleLang("Yuri Verso", "https://yuri.live", "pt-BR", overrideVersionCode = 3),
SingleLang("Zinmanga", "https://zinmanga.com", "en", overrideVersionCode = 1),
SingleLang("Zinmanhwa", "https://zinmanhwa.com", "en"),