diff --git a/src/pt/mangavibe/AndroidManifest.xml b/src/pt/mangavibe/AndroidManifest.xml
new file mode 100644
index 000000000..30deb7f79
--- /dev/null
+++ b/src/pt/mangavibe/AndroidManifest.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/src/pt/mangavibe/build.gradle b/src/pt/mangavibe/build.gradle
new file mode 100644
index 000000000..3be4dab1b
--- /dev/null
+++ b/src/pt/mangavibe/build.gradle
@@ -0,0 +1,18 @@
+apply plugin: 'com.android.application'
+apply plugin: 'kotlin-android'
+apply plugin: 'kotlinx-serialization'
+
+ext {
+ extName = 'MangaVibe'
+ pkgNameSuffix = 'pt.mangavibe'
+ extClass = '.MangaVibe'
+ extVersionCode = 1
+ libVersion = '1.2'
+ containsNsfw = true
+}
+
+dependencies {
+ implementation project(':lib-ratelimit')
+}
+
+apply from: "$rootDir/common.gradle"
diff --git a/src/pt/mangavibe/res/mipmap-hdpi/ic_launcher.png b/src/pt/mangavibe/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 000000000..f938f17d4
Binary files /dev/null and b/src/pt/mangavibe/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/src/pt/mangavibe/res/mipmap-mdpi/ic_launcher.png b/src/pt/mangavibe/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 000000000..efcd145ed
Binary files /dev/null and b/src/pt/mangavibe/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/src/pt/mangavibe/res/mipmap-xhdpi/ic_launcher.png b/src/pt/mangavibe/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 000000000..bd5328abc
Binary files /dev/null and b/src/pt/mangavibe/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/src/pt/mangavibe/res/mipmap-xxhdpi/ic_launcher.png b/src/pt/mangavibe/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 000000000..2062d5391
Binary files /dev/null and b/src/pt/mangavibe/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/src/pt/mangavibe/res/mipmap-xxxhdpi/ic_launcher.png b/src/pt/mangavibe/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 000000000..723065302
Binary files /dev/null and b/src/pt/mangavibe/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/src/pt/mangavibe/res/web_hi_res_512.png b/src/pt/mangavibe/res/web_hi_res_512.png
new file mode 100644
index 000000000..b003cc435
Binary files /dev/null and b/src/pt/mangavibe/res/web_hi_res_512.png differ
diff --git a/src/pt/mangavibe/src/eu/kanade/tachiyomi/extension/pt/mangavibe/MangaVibe.kt b/src/pt/mangavibe/src/eu/kanade/tachiyomi/extension/pt/mangavibe/MangaVibe.kt
new file mode 100644
index 000000000..3cb7bb1df
--- /dev/null
+++ b/src/pt/mangavibe/src/eu/kanade/tachiyomi/extension/pt/mangavibe/MangaVibe.kt
@@ -0,0 +1,350 @@
+package eu.kanade.tachiyomi.extension.pt.mangavibe
+
+import eu.kanade.tachiyomi.annotations.Nsfw
+import eu.kanade.tachiyomi.lib.ratelimit.RateLimitInterceptor
+import eu.kanade.tachiyomi.network.GET
+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.json.Json
+import okhttp3.Headers
+import okhttp3.HttpUrl.Companion.toHttpUrl
+import okhttp3.Interceptor
+import okhttp3.MediaType.Companion.toMediaTypeOrNull
+import okhttp3.OkHttpClient
+import okhttp3.Protocol
+import okhttp3.Request
+import okhttp3.Response
+import okhttp3.ResponseBody.Companion.toResponseBody
+import rx.Observable
+import uy.kohesive.injekt.injectLazy
+import java.text.Normalizer
+import java.text.SimpleDateFormat
+import java.util.Locale
+import java.util.concurrent.TimeUnit
+import kotlin.math.ceil
+
+@Nsfw
+class MangaVibe : HttpSource() {
+
+ override val name = "MangaVibe"
+
+ override val baseUrl = "https://mangavibe.top"
+
+ override val lang = "pt-BR"
+
+ override val supportsLatest = true
+
+ override val client: OkHttpClient = network.cloudflareClient.newBuilder()
+ .addInterceptor(RateLimitInterceptor(1, 2, TimeUnit.SECONDS))
+ .addInterceptor(::directoryCacheIntercept)
+ .build()
+
+ override fun headersBuilder(): Headers.Builder = Headers.Builder()
+ .add("Referer", "$baseUrl/")
+
+ private val json: Json by injectLazy()
+
+ private val directoryCache: MutableMap = mutableMapOf()
+
+ override fun popularMangaRequest(page: Int): Request {
+ val newHeaders = headersBuilder()
+ .add("Accept", ACCEPT_JSON)
+ .set("Referer", "$baseUrl/mangas?Ordem=Populares")
+ .add("X-Page", page.toString())
+ .build()
+
+ return GET("$baseUrl/$API_PATH/data?page=medias", newHeaders)
+ }
+
+ override fun popularMangaParse(response: Response): MangasPage {
+ val result = json.decodeFromString(response.body!!.string())
+
+ if (result.data.isNullOrEmpty()) {
+ return MangasPage(emptyList(), hasNextPage = false)
+ }
+
+ val totalPages = ceil(result.data.size.toDouble() / ITEMS_PER_PAGE)
+ val currentPage = response.request.header("X-Page")!!.toInt()
+
+ val mangaList = result.data
+ .sortedByDescending { it.views }
+ .drop(ITEMS_PER_PAGE * (currentPage - 1))
+ .take(ITEMS_PER_PAGE)
+ .map(::popularMangaFromObject)
+
+ return MangasPage(mangaList, hasNextPage = currentPage < totalPages)
+ }
+
+ private fun popularMangaFromObject(comic: MangaVibeComicDto): SManga = SManga.create().apply {
+ title = comic.title["romaji"] ?: comic.title["english"] ?: comic.title["native"]!!
+ thumbnail_url = comic.id.toThumbnailUrl()
+ url = "/manga/${comic.id}/${title.toSlug()}"
+ }
+
+ override fun latestUpdatesRequest(page: Int): Request {
+ val newHeaders = headersBuilder()
+ .add("Accept", ACCEPT_JSON)
+ .set("Referer", "$baseUrl/mangas?Ordem=Atualizados")
+ .add("X-Page", page.toString())
+ .build()
+
+ return GET("$baseUrl/$API_PATH/data?page=medias&Ordem=Atualizados", newHeaders)
+ }
+
+ override fun latestUpdatesParse(response: Response): MangasPage {
+ val result = json.decodeFromString(response.body!!.string())
+
+ if (result.data.isNullOrEmpty()) {
+ return MangasPage(emptyList(), hasNextPage = false)
+ }
+
+ val totalPages = ceil(result.data.size.toDouble() / ITEMS_PER_PAGE)
+ val currentPage = response.request.header("X-Page")!!.toInt()
+
+ val mangaList = result.data
+ .asSequence()
+ .distinctBy { it.title }
+ .filter { it.mediaID.isNullOrBlank().not() }
+ .drop(ITEMS_PER_PAGE * (currentPage - 1))
+ .take(ITEMS_PER_PAGE)
+ .map(::latestMangaFromObject)
+ .toList()
+
+ return MangasPage(mangaList, hasNextPage = currentPage < totalPages)
+ }
+
+ private fun latestMangaFromObject(chapter: MangaVibeLatestChapterDto): SManga = SManga.create().apply {
+ title = chapter.title!!
+ thumbnail_url = chapter.mediaID!!.toInt().toThumbnailUrl()
+ url = "/manga/${chapter.mediaID}/${chapter.title.toSlug()}"
+ }
+
+ override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
+ val newHeaders = headersBuilder()
+ .add("Accept", ACCEPT_JSON)
+ .add("X-Page", page.toString())
+ .build()
+
+ val apiUrl = "$baseUrl/$API_PATH/data".toHttpUrl().newBuilder()
+ .addQueryParameter("page", "medias")
+ .addQueryParameter("st", query)
+ .toString()
+
+ return GET(apiUrl, newHeaders)
+ }
+
+ override fun searchMangaParse(response: Response): MangasPage {
+ val result = json.decodeFromString(response.body!!.string())
+
+ if (result.data.isNullOrEmpty()) {
+ return MangasPage(emptyList(), hasNextPage = false)
+ }
+
+ val searchTerm = response.request.url.queryParameter("st")!!
+
+ val mangaList = result.data
+ .filter {
+ it.title.values.any { title ->
+ title?.contains(searchTerm, ignoreCase = true) ?: false
+ }
+ }
+ .sortedByDescending { it.views }
+ .map(::searchMangaFromObject)
+
+ return MangasPage(mangaList, hasNextPage = false)
+ }
+
+ private fun searchMangaFromObject(comic: MangaVibeComicDto): SManga = popularMangaFromObject(comic)
+
+ // Workaround to allow "Open in browser" use the real URL.
+ override fun fetchMangaDetails(manga: SManga): Observable {
+ return client.newCall(mangaDetailsApiRequest(manga))
+ .asObservableSuccess()
+ .map { response ->
+ mangaDetailsParse(response).apply { initialized = true }
+ }
+ }
+
+ private fun mangaDetailsApiRequest(manga: SManga): Request {
+ val comicId = manga.url.substringAfter("/manga/")
+ .substringBefore("/")
+
+ val newHeaders = headersBuilder()
+ .add("Accept", ACCEPT_JSON)
+ .set("Referer", "$baseUrl/mangas?Ordem=Populares")
+ .add("X-Id", comicId)
+ .build()
+
+ return GET("$baseUrl/$API_PATH/data?page=medias", newHeaders)
+ }
+
+ override fun mangaDetailsParse(response: Response): SManga {
+ val result = json.decodeFromString(response.body!!.string())
+
+ if (result.data.isNullOrEmpty()) {
+ throw Exception(COULD_NOT_PARSE_THE_MANGA)
+ }
+
+ val comicId = response.request.header("X-Id")!!.toInt()
+ val comic = result.data.find { it.id == comicId }
+ ?: throw Exception(COULD_NOT_PARSE_THE_MANGA)
+
+ return SManga.create().apply {
+ title = comic.title["romaji"] ?: comic.title["english"] ?: comic.title["native"]!!
+ description = comic.description.orEmpty()
+ genre = comic.genres?.joinToString(", ")
+ status = comic.status?.toStatus() ?: SManga.UNKNOWN
+ thumbnail_url = comic.id.toThumbnailUrl()
+ }
+ }
+
+ // Chapters are available in the same url of the manga details.
+ override fun chapterListRequest(manga: SManga): Request {
+ val comicId = manga.url.substringAfter("/manga/")
+ .substringBefore("/")
+
+ val newHeaders = headersBuilder()
+ .add("Accept", ACCEPT_JSON)
+ .set("Referer", baseUrl + manga.url)
+ .build()
+
+ return GET("$baseUrl/$API_PATH/data?page=chapter&mediaID=$comicId", newHeaders)
+ }
+
+ override fun chapterListParse(response: Response): List {
+ val result = json.decodeFromString(response.body!!.string())
+
+ if (result.data.isNullOrEmpty()) {
+ return emptyList()
+ }
+
+ return result.data
+ .map(::chapterFromObject)
+ .reversed()
+ }
+
+ private fun chapterFromObject(chapter: MangaVibeChapterDto): SChapter = SChapter.create().apply {
+ name = "Capítulo #" + chapter.number.toString().replace(".0", "")
+ chapter_number = chapter.number
+ date_upload = chapter.datePublished?.toDate() ?: 0L
+
+ val chapterUrl = "$baseUrl/chapter".toHttpUrl().newBuilder()
+ .addPathSegment(chapter.mediaID.toString())
+ .addPathSegment(chapter.title?.toSlug() ?: "null")
+ .addPathSegment(chapter.number.toString().replace(".0", ""))
+ .addQueryParameter("pgn", chapter.pages.toString())
+ .toString()
+ setUrlWithoutDomain(chapterUrl)
+ }
+
+ override fun fetchPageList(chapter: SChapter): Observable> {
+ val chapterUrlPaths = chapter.url
+ .removePrefix("/")
+ .split("/")
+
+ val comicId = chapterUrlPaths[1]
+ val chapterNumber = chapterUrlPaths[3].substringBefore("?")
+ val pageCount = chapter.url.substringAfterLast("?pgn=").toInt()
+
+ val pages = List(pageCount) { i ->
+ val pageUrl = "$CDN_URL/img/media/$comicId/chapter/$chapterNumber/${i + 1}.jpg"
+ Page(i, baseUrl, pageUrl)
+ }
+
+ return Observable.just(pages)
+ }
+
+ override fun pageListParse(response: Response): List =
+ throw Exception("This method should not be called!")
+
+ override fun fetchImageUrl(page: Page): Observable = Observable.just(page.imageUrl!!)
+
+ override fun imageUrlParse(response: Response): String = ""
+
+ override fun imageRequest(page: Page): Request {
+ val newHeaders = headersBuilder()
+ .add("Accept", ACCEPT_IMAGE)
+ .set("Referer", page.url)
+ .build()
+
+ return GET(page.imageUrl!!, newHeaders)
+ }
+
+ private fun directoryCacheIntercept(chain: Interceptor.Chain): Response {
+ if (!chain.request().url.toString().contains("data?page=medias")) {
+ return chain.proceed(chain.request())
+ }
+
+ val directoryType = if (chain.request().url.queryParameter("Ordem") == null)
+ POPULAR_KEY else LATEST_KEY
+ val page = chain.request().header("X-Page")?.toInt()
+
+ if (directoryCache.containsKey(directoryType) && page != null && page > 1) {
+ val jsonContentType = "application/json; charset=UTF-8".toMediaTypeOrNull()
+ val responseBody = directoryCache[directoryType]!!.toResponseBody(jsonContentType)
+
+ return Response.Builder()
+ .code(200)
+ .protocol(Protocol.HTTP_1_1)
+ .request(chain.request())
+ .message("OK")
+ .body(responseBody)
+ .build()
+ }
+
+ val response = chain.proceed(chain.request())
+ val responseContentType = response.body!!.contentType()
+ val responseString = response.body!!.string()
+
+ directoryCache[directoryType] = responseString
+
+ return response.newBuilder()
+ .body(responseString.toResponseBody(responseContentType))
+ .build()
+ }
+
+ private fun Int.toThumbnailUrl(): String = "$CDN_URL/img/media/$this/cover/l.jpg"
+
+ private fun String.toDate(): Long {
+ return runCatching { DATE_FORMATTER.parse(substringBefore("T"))?.time }
+ .getOrNull() ?: 0L
+ }
+
+ private fun String.toStatus(): Int = when (this) {
+ "Em lançamento" -> SManga.ONGOING
+ "Completo" -> SManga.COMPLETED
+ else -> SManga.UNKNOWN
+ }
+
+ private fun String.toSlug(): String {
+ return Normalizer
+ .normalize(this, Normalizer.Form.NFD)
+ .replace("[^\\p{ASCII}]".toRegex(), "")
+ .replace("[^a-zA-Z0-9\\s]+".toRegex(), "").trim()
+ .replace("\\s+".toRegex(), "-")
+ .toLowerCase(Locale("pt", "BR"))
+ }
+
+ companion object {
+ private const val API_PATH = "mangavibe/api/v1"
+ private const val CDN_URL = "https://cdn.mangavibe.top"
+
+ private const val ACCEPT_JSON = "application/json, text/plain, */*"
+ private const val ACCEPT_IMAGE = "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8"
+
+ private const val ITEMS_PER_PAGE = 24
+
+ private const val COULD_NOT_PARSE_THE_MANGA = "Ocorreu um erro ao obter as informações."
+
+ private const val POPULAR_KEY = "popular"
+ private const val LATEST_KEY = "latest"
+
+ private val DATE_FORMATTER by lazy { SimpleDateFormat("yyyy-MM-dd", Locale.ENGLISH) }
+ }
+}
diff --git a/src/pt/mangavibe/src/eu/kanade/tachiyomi/extension/pt/mangavibe/MangaVibeDto.kt b/src/pt/mangavibe/src/eu/kanade/tachiyomi/extension/pt/mangavibe/MangaVibeDto.kt
new file mode 100644
index 000000000..5228e41be
--- /dev/null
+++ b/src/pt/mangavibe/src/eu/kanade/tachiyomi/extension/pt/mangavibe/MangaVibeDto.kt
@@ -0,0 +1,37 @@
+package eu.kanade.tachiyomi.extension.pt.mangavibe
+
+import kotlinx.serialization.Serializable
+
+typealias MangaVibePopularDto = MangaVibeResultDto>
+typealias MangaVibeLatestDto = MangaVibeResultDto>
+typealias MangaVibeChapterListDto = MangaVibeResultDto>
+
+@Serializable
+data class MangaVibeResultDto(
+ val data: T? = null
+)
+
+@Serializable
+data class MangaVibeComicDto(
+ val description: String? = "",
+ val genres: List? = emptyList(),
+ val id: Int,
+ val status: String? = "",
+ val title: Map = emptyMap(),
+ val views: Int = -1
+)
+
+@Serializable
+data class MangaVibeLatestChapterDto(
+ val mediaID: String? = "",
+ val title: String? = ""
+)
+
+@Serializable
+data class MangaVibeChapterDto(
+ val datePublished: String? = "",
+ val mediaID: Int = -1,
+ val number: Float = -1f,
+ val pages: Int = -1,
+ val title: String? = ""
+)