Skip to content
8 changes: 8 additions & 0 deletions src/pt/brscans/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
ext {
extName = 'BRScans'
extClass = '.BRScans'
extVersionCode = 1
isNsfw = true
}

apply plugin: "kei.plugins.extension.legacy"
Binary file added src/pt/brscans/res/mipmap-hdpi/ic_launcher.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/pt/brscans/res/mipmap-mdpi/ic_launcher.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/pt/brscans/res/mipmap-xhdpi/ic_launcher.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/pt/brscans/res/mipmap-xxhdpi/ic_launcher.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/pt/brscans/res/mipmap-xxxhdpi/ic_launcher.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
155 changes: 155 additions & 0 deletions src/pt/brscans/src/eu/kanade/tachiyomi/extension/pt/brscans/BRScans.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
package eu.kanade.tachiyomi.extension.pt.brscans

import androidx.preference.PreferenceScreen
import androidx.preference.SwitchPreferenceCompat
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.source.ConfigurableSource
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 keiyoushi.utils.getPreferencesLazy
import keiyoushi.utils.parseAs
import okhttp3.Request
import okhttp3.Response
import java.text.SimpleDateFormat
import java.util.Locale
import java.util.TimeZone

class BRScans :
HttpSource(),
ConfigurableSource {

override val name = "BRScans"

override val baseUrl = "https://brscans.vercel.app"

private val apiUrl = "https://e5oer7ngt8.execute-api.sa-east-1.amazonaws.com/dev"

override val lang = "pt-BR"

override val supportsLatest = true

private val dateFormat by lazy {
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.ROOT).apply {
timeZone = TimeZone.getTimeZone("UTC")
}
}

private val preferences by getPreferencesLazy()

private val showNsfw: Boolean get() = preferences.getBoolean(SHOW_NSFW_PREF, true)

// Genre caching system
private var genreMap: Map<Int, String> = emptyMap()
private var genreMapFetched = false

@Synchronized
private fun fetchGenreMap() {
if (genreMapFetched) return
try {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also add a fetchFailed bool so genres are not fetched multiple times on failure

client.newCall(GET("$apiUrl/manhwas/genres/", headers)).execute().use { res ->
val genres = res.parseAs<List<GenreDto>>()
genreMap = genres.associate { it.id to it.name }
genreMapFetched = true
}
} catch (_: Exception) {}
}
Comment thread
SMCodesP marked this conversation as resolved.

// ============================== Popular ===============================

override fun popularMangaRequest(page: Int): Request = if (showNsfw) {
// Fetch the search endpoint with no query to get everything including NSFW
GET("$apiUrl/manhwas/search/?query=", headers)
} else {
// Default paginated list which filters out NSFW
GET("$apiUrl/manhwas/?page=$page", headers)
}

override fun popularMangaParse(response: Response): MangasPage {
fetchGenreMap()
return if (showNsfw) {
val results = response.parseAs<List<ManhwaDto>>()
val mangas = results.map { it.toSManga(genreMap) }
MangasPage(mangas, false)
} else {
val paginated = response.parseAs<PaginatedManhwaDto>()
val mangas = paginated.results.map { it.toSManga(genreMap) }
val hasNext = paginated.next != null
MangasPage(mangas, hasNext)
}
}

// =============================== Latest ===============================

override fun latestUpdatesRequest(page: Int): Request = popularMangaRequest(page)

override fun latestUpdatesParse(response: Response): MangasPage = popularMangaParse(response)
Comment on lines +87 to +89

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Update the methods to throw an UnsupportedOperationException and set supportLatest to false


// =============================== Search ===============================

override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request = GET("$apiUrl/manhwas/search/?query=${query.trim()}", headers)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use HttpUrl builder to properly encode the query param


override fun searchMangaParse(response: Response): MangasPage {
fetchGenreMap()
val results = response.parseAs<List<ManhwaDto>>()
val filteredResults = if (showNsfw) {
results
} else {
results.filter { !it.isNsfw }
}
val mangas = filteredResults.map { it.toSManga(genreMap) }
return MangasPage(mangas, false)
}

// =========================== Manga Details ============================

override fun mangaDetailsRequest(manga: SManga): Request = GET("$apiUrl/manhwas/${manga.url}/", headers)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Override the getMangaUrl and getChapterUrl methods so that the "Open in WebView" feature works correctly


override fun mangaDetailsParse(response: Response): SManga {
fetchGenreMap()
val dto = response.parseAs<ManhwaDto>()
return dto.toSManga(genreMap).apply {
initialized = true
}
Comment on lines +114 to +116

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

initialized is used to indicate that the details have already been set during the popular/latest/search parsing methods

Suggested change
return dto.toSManga(genreMap).apply {
initialized = true
}
return dto.toSManga(genreMap)

}

// ============================== Chapters ==============================

override fun chapterListRequest(manga: SManga): Request = mangaDetailsRequest(manga)

override fun chapterListParse(response: Response): List<SChapter> {
val dto = response.parseAs<ManhwaDto>()
return dto.chapters.reversed().map { it.toSChapter(dateFormat) }
}

// =============================== Pages ================================

override fun pageListRequest(chapter: SChapter): Request = GET("$apiUrl/chapters/${chapter.url}/", headers)

override fun pageListParse(response: Response): List<Page> {
val dto = response.parseAs<ChapterDetailDto>()
return dto.pages.mapIndexedNotNull { index, pageDto ->
pageDto.toPage(index)
}
}

override fun imageUrlParse(response: Response): String = throw UnsupportedOperationException("Not used.")

// ============================= Settings ===============================

override fun setupPreferenceScreen(screen: PreferenceScreen) {
SwitchPreferenceCompat(screen.context).apply {
key = SHOW_NSFW_PREF
title = "Mostrar conteúdo adulto (+18)"
summary = "Habilita a listagem e visualização de manhwas adultos (+18) nos resultados."
setDefaultValue(true)
}.also(screen::addPreference)
}

companion object {
private const val SHOW_NSFW_PREF = "showNsfwPref"
}
}
142 changes: 142 additions & 0 deletions src/pt/brscans/src/eu/kanade/tachiyomi/extension/pt/brscans/Dto.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
package eu.kanade.tachiyomi.extension.pt.brscans

import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import keiyoushi.utils.tryParse
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import java.text.SimpleDateFormat

@Serializable
class PaginatedManhwaDto(
val next: String? = null,
val results: List<ManhwaDto> = emptyList(),
)

@Serializable
class VariantsDto(
val minimum: String? = null,
val medium: String? = null,
val original: String? = null,
val translated: String? = null,
val raw: String? = null,
val upscaled: String? = null,
) {
fun url(): String? {
val path = translated?.takeIf { it.isNotEmpty() }
?: original?.takeIf { it.isNotEmpty() }
?: raw?.takeIf { it.isNotEmpty() }
?: upscaled?.takeIf { it.isNotEmpty() }
?: medium?.takeIf { it.isNotEmpty() }
?: minimum?.takeIf { it.isNotEmpty() }

if (path.isNullOrEmpty()) return null
if (path.startsWith("http://") || path.startsWith("https://")) return path

// If it's a relative path, resolve it relative to the S3/CloudFront domain
return "https://d47sf0ah3vbtc.cloudfront.net/${path.removePrefix("/")}"
}
}

@Serializable
class ManhwaDto(
val id: Int,
val title: String,
val author: String? = null,
val status: String? = null,
val description: String? = null,
val thumbnail: VariantsDto? = null,
val genres: List<Int> = emptyList(),
@SerialName("is_nsfw") val isNsfw: Boolean = false,
val chapters: List<SimpleChapterDto> = emptyList(),
) {
fun toSManga(genreMap: Map<Int, String> = emptyMap()) = SManga.create().apply {
url = id.toString()
title = this@ManhwaDto.title
author = this@ManhwaDto.author?.takeIf { it.isNotBlank() }
description = this@ManhwaDto.description?.takeIf { it.isNotBlank() }
thumbnail_url = thumbnail?.url()

status = when (this@ManhwaDto.status?.lowercase()) {
"ongoing", "em lançamento", "ativo" -> SManga.ONGOING
"completed", "completo", "finalizado" -> SManga.COMPLETED
"hiatus", "pausa", "em pausa" -> SManga.ON_HIATUS
else -> SManga.UNKNOWN
}

genre = genres.mapNotNull { genreMap[it] }
.joinToString { it }
.takeIf { it.isNotBlank() }
}
}

@Serializable
class SimpleChapterDto(
val id: Int,
val title: String,
val slug: String? = null,
@SerialName("release_date") val releaseDate: String? = null,
@SerialName("created_at") val createdAt: String? = null,
) {
fun toSChapter(dateFormat: SimpleDateFormat) = SChapter.create().apply {
url = id.toString()
name = title
date_upload = (releaseDate ?: createdAt)?.let { dateStr ->
// Date strings look like "2026-05-25T08:33:42Z" or similar ISO-8601 formats
dateFormat.tryParse(dateStr.substringBefore("."))
} ?: 0L
}
}

@Serializable
class RecentChapterDto(
val id: Int,
val title: String,
val slug: String? = null,
@SerialName("release_date") val releaseDate: String? = null,
@SerialName("created_at") val createdAt: String? = null,
val manhwa: ManhwaBriefDto? = null,
) {
fun toSManga() = manhwa?.let {
SManga.create().apply {
url = it.id.toString()
title = it.title
thumbnail_url = it.thumbnail?.url()
}
}
}

@Serializable
class ManhwaBriefDto(
val id: Int,
val title: String,
val slug: String? = null,
val thumbnail: VariantsDto? = null,
)

@Serializable
class ChapterDetailDto(
val id: Int,
val title: String,
val pages: List<PageDto> = emptyList(),
)

@Serializable
class PageDto(
val id: Int,
val order: Int = 0,
val images: VariantsDto? = null,
) {
fun toPage(index: Int): Page? {
val imgUrl = images?.url() ?: return null
return Page(index, imageUrl = imgUrl)
}
}

@Serializable
class GenreDto(
val id: Int,
val name: String,
val slug: String? = null,
)