-
Notifications
You must be signed in to change notification settings - Fork 1.5k
Create a Brscans extension #16291
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Create a Brscans extension #16291
Changes from all commits
ec67377
fbdf23c
61a3d76
4f67210
47c9a33
596a59b
b74d023
c6f65ca
3f7c5cc
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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" |
| 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 { | ||||||||||
| 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) {} | ||||||||||
| } | ||||||||||
|
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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Update the methods to throw an |
||||||||||
|
|
||||||||||
| // =============================== Search =============================== | ||||||||||
|
|
||||||||||
| override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request = GET("$apiUrl/manhwas/search/?query=${query.trim()}", headers) | ||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Use |
||||||||||
|
|
||||||||||
| 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) | ||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Override the |
||||||||||
|
|
||||||||||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||
| } | ||||||||||
|
|
||||||||||
| // ============================== 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" | ||||||||||
| } | ||||||||||
| } | ||||||||||
| 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, | ||
| ) |
There was a problem hiding this comment.
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