diff --git a/src/pt/brscans/build.gradle b/src/pt/brscans/build.gradle new file mode 100644 index 00000000000..341e877cd41 --- /dev/null +++ b/src/pt/brscans/build.gradle @@ -0,0 +1,8 @@ +ext { + extName = 'BRScans' + extClass = '.BRScans' + extVersionCode = 1 + isNsfw = true +} + +apply plugin: "kei.plugins.extension.legacy" diff --git a/src/pt/brscans/res/mipmap-hdpi/ic_launcher.png b/src/pt/brscans/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 00000000000..847c2e390f2 Binary files /dev/null and b/src/pt/brscans/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/pt/brscans/res/mipmap-mdpi/ic_launcher.png b/src/pt/brscans/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 00000000000..ad4b815c65e Binary files /dev/null and b/src/pt/brscans/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/pt/brscans/res/mipmap-xhdpi/ic_launcher.png b/src/pt/brscans/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 00000000000..33e0ef93a32 Binary files /dev/null and b/src/pt/brscans/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/pt/brscans/res/mipmap-xxhdpi/ic_launcher.png b/src/pt/brscans/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 00000000000..433bc5cf1a9 Binary files /dev/null and b/src/pt/brscans/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/pt/brscans/res/mipmap-xxxhdpi/ic_launcher.png b/src/pt/brscans/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 00000000000..9d2dc79fe76 Binary files /dev/null and b/src/pt/brscans/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/pt/brscans/src/eu/kanade/tachiyomi/extension/pt/brscans/BRScans.kt b/src/pt/brscans/src/eu/kanade/tachiyomi/extension/pt/brscans/BRScans.kt new file mode 100644 index 00000000000..92230a2c26a --- /dev/null +++ b/src/pt/brscans/src/eu/kanade/tachiyomi/extension/pt/brscans/BRScans.kt @@ -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 = emptyMap() + private var genreMapFetched = false + + @Synchronized + private fun fetchGenreMap() { + if (genreMapFetched) return + try { + client.newCall(GET("$apiUrl/manhwas/genres/", headers)).execute().use { res -> + val genres = res.parseAs>() + genreMap = genres.associate { it.id to it.name } + genreMapFetched = true + } + } catch (_: Exception) {} + } + + // ============================== 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>() + val mangas = results.map { it.toSManga(genreMap) } + MangasPage(mangas, false) + } else { + val paginated = response.parseAs() + 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) + + // =============================== Search =============================== + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request = GET("$apiUrl/manhwas/search/?query=${query.trim()}", headers) + + override fun searchMangaParse(response: Response): MangasPage { + fetchGenreMap() + val results = response.parseAs>() + 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) + + override fun mangaDetailsParse(response: Response): SManga { + fetchGenreMap() + val dto = response.parseAs() + return dto.toSManga(genreMap).apply { + initialized = true + } + } + + // ============================== Chapters ============================== + + override fun chapterListRequest(manga: SManga): Request = mangaDetailsRequest(manga) + + override fun chapterListParse(response: Response): List { + val dto = response.parseAs() + 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 { + val dto = response.parseAs() + 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" + } +} diff --git a/src/pt/brscans/src/eu/kanade/tachiyomi/extension/pt/brscans/Dto.kt b/src/pt/brscans/src/eu/kanade/tachiyomi/extension/pt/brscans/Dto.kt new file mode 100644 index 00000000000..f177aa48eb9 --- /dev/null +++ b/src/pt/brscans/src/eu/kanade/tachiyomi/extension/pt/brscans/Dto.kt @@ -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 = 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 = emptyList(), + @SerialName("is_nsfw") val isNsfw: Boolean = false, + val chapters: List = emptyList(), +) { + fun toSManga(genreMap: Map = 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 = 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, +)