diff --git a/src/pt/tsukimangas/AndroidManifest.xml b/src/pt/tsukimangas/AndroidManifest.xml
new file mode 100644
index 000000000..825716396
--- /dev/null
+++ b/src/pt/tsukimangas/AndroidManifest.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/pt/tsukimangas/build.gradle b/src/pt/tsukimangas/build.gradle
new file mode 100644
index 000000000..7e38c9877
--- /dev/null
+++ b/src/pt/tsukimangas/build.gradle
@@ -0,0 +1,8 @@
+ext {
+ extName = 'Tsuki Mangás'
+ extClass = '.TsukiMangas'
+ extVersionCode = 1
+ isNsfw = true
+}
+
+apply from: "$rootDir/common.gradle"
diff --git a/src/pt/tsukimangas/res/mipmap-hdpi/ic_launcher.png b/src/pt/tsukimangas/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 000000000..8be15d792
Binary files /dev/null and b/src/pt/tsukimangas/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/src/pt/tsukimangas/res/mipmap-mdpi/ic_launcher.png b/src/pt/tsukimangas/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 000000000..fd4f11242
Binary files /dev/null and b/src/pt/tsukimangas/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/src/pt/tsukimangas/res/mipmap-xhdpi/ic_launcher.png b/src/pt/tsukimangas/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 000000000..0e72a5571
Binary files /dev/null and b/src/pt/tsukimangas/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/src/pt/tsukimangas/res/mipmap-xxhdpi/ic_launcher.png b/src/pt/tsukimangas/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 000000000..652fffad6
Binary files /dev/null and b/src/pt/tsukimangas/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/src/pt/tsukimangas/res/mipmap-xxxhdpi/ic_launcher.png b/src/pt/tsukimangas/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 000000000..82085d654
Binary files /dev/null and b/src/pt/tsukimangas/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/src/pt/tsukimangas/src/eu/kanade/tachiyomi/extension/pt/tsukimangas/TsukiMangas.kt b/src/pt/tsukimangas/src/eu/kanade/tachiyomi/extension/pt/tsukimangas/TsukiMangas.kt
new file mode 100644
index 000000000..ea3e2a2fb
--- /dev/null
+++ b/src/pt/tsukimangas/src/eu/kanade/tachiyomi/extension/pt/tsukimangas/TsukiMangas.kt
@@ -0,0 +1,252 @@
+package eu.kanade.tachiyomi.extension.pt.tsukimangas
+
+import eu.kanade.tachiyomi.extension.pt.tsukimangas.dto.ChapterListDto
+import eu.kanade.tachiyomi.extension.pt.tsukimangas.dto.CompleteMangaDto
+import eu.kanade.tachiyomi.extension.pt.tsukimangas.dto.MangaListDto
+import eu.kanade.tachiyomi.extension.pt.tsukimangas.dto.PageListDto
+import eu.kanade.tachiyomi.network.GET
+import eu.kanade.tachiyomi.network.asObservableSuccess
+import eu.kanade.tachiyomi.network.interceptor.rateLimitHost
+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.json.Json
+import kotlinx.serialization.json.decodeFromStream
+import okhttp3.HttpUrl
+import okhttp3.HttpUrl.Companion.toHttpUrl
+import okhttp3.Interceptor
+import okhttp3.Request
+import okhttp3.Response
+import rx.Observable
+import uy.kohesive.injekt.injectLazy
+import java.text.SimpleDateFormat
+import java.util.Locale
+
+class TsukiMangas : HttpSource() {
+
+ override val name = "Tsuki Mangás"
+
+ override val baseUrl = "https://tsuki-mangas.com"
+
+ private val API_URL = baseUrl + API_PATH
+
+ override val lang = "pt-BR"
+
+ override val supportsLatest = true
+
+ override val client by lazy {
+ network.client.newBuilder()
+ .addInterceptor(::imageCdnSwapper)
+ .rateLimitHost(baseUrl.toHttpUrl(), 2)
+ .rateLimitHost(MAIN_CDN.toHttpUrl(), 1)
+ .rateLimitHost(SECONDARY_CDN.toHttpUrl(), 1)
+ .build()
+ }
+
+ override fun headersBuilder() = super.headersBuilder().add("Referer", "$baseUrl/")
+
+ private val json: Json by injectLazy()
+
+ // ============================== Popular ===============================
+ override fun popularMangaRequest(page: Int) = GET("$API_URL/mangas?page=$page&filter=0", headers)
+
+ override fun popularMangaParse(response: Response): MangasPage {
+ val item = response.parseAs()
+ val mangas = item.data.map {
+ SManga.create().apply {
+ url = "/obra" + it.entryPath
+ thumbnail_url = baseUrl + it.imagePath
+ title = it.title
+ }
+ }
+ val hasNextPage = item.page < item.lastPage
+ return MangasPage(mangas, hasNextPage)
+ }
+
+ // =============================== Latest ===============================
+ // Yes, "lastests". High IQ move.
+ // Also yeah, there's a "?format=0" glued to the page number. Without this,
+ // the request will blow up with a HTTP 500.
+ override fun latestUpdatesRequest(page: Int) = GET("$API_URL/home/lastests?page=$page%3Fformat%3D0", headers)
+
+ override fun latestUpdatesParse(response: Response) = popularMangaParse(response)
+
+ // =============================== Search ===============================
+ override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable {
+ return if (query.startsWith(PREFIX_SEARCH)) { // URL intent handler
+ val id = query.removePrefix(PREFIX_SEARCH)
+ client.newCall(GET("$API_URL/mangas/$id", headers))
+ .asObservableSuccess()
+ .map(::searchMangaByIdParse)
+ } else {
+ super.fetchSearchManga(page, query, filters)
+ }
+ }
+
+ private fun searchMangaByIdParse(response: Response): MangasPage {
+ val details = mangaDetailsParse(response)
+ return MangasPage(listOf(details), false)
+ }
+
+ override fun getFilterList() = TsukiMangasFilters.FILTER_LIST
+
+ override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
+ val params = TsukiMangasFilters.getSearchParameters(filters)
+ val url = "$API_URL/mangas".toHttpUrl().newBuilder()
+ .addQueryParameter("page", page.toString())
+ .addQueryParameter("title", query.trim())
+ .addIfNotBlank("filter", params.filter)
+ .addIfNotBlank("format", params.format)
+ .addIfNotBlank("status", params.status)
+ .addIfNotBlank("adult_content", params.adult)
+ .apply {
+ params.genres.forEach { addQueryParameter("genres[]", it) }
+ params.tags.forEach { addQueryParameter("tags[]", it) }
+ }.build()
+
+ return GET(url, headers)
+ }
+
+ override fun searchMangaParse(response: Response) = popularMangaParse(response)
+
+ // =========================== Manga Details ============================
+ override fun mangaDetailsRequest(manga: SManga): Request {
+ val id = manga.url.getMangaId()
+ return GET("$API_URL/mangas/$id", headers)
+ }
+
+ override fun getMangaUrl(manga: SManga) = baseUrl + manga.url
+
+ override fun mangaDetailsParse(response: Response) = SManga.create().apply {
+ val mangaDto = response.parseAs()
+ url = "/obra" + mangaDto.entryPath
+ thumbnail_url = baseUrl + mangaDto.imagePath
+ title = mangaDto.title
+ artist = mangaDto.staff
+ genre = mangaDto.genres.joinToString { it.genre }
+ status = parseStatus(mangaDto.status.orEmpty())
+ description = buildString {
+ mangaDto.synopsis?.also { append("$it\n\n") }
+ if (mangaDto.titles.isNotEmpty()) {
+ append("Títulos alternativos: ${mangaDto.titles.joinToString { it.title }}")
+ }
+ }
+ }
+
+ private fun parseStatus(status: String) = when (status) {
+ "Ativo" -> SManga.ONGOING
+ "Completo" -> SManga.COMPLETED
+ "Hiato" -> SManga.ON_HIATUS
+ else -> SManga.UNKNOWN
+ }
+
+ // ============================== Chapters ==============================
+ override fun chapterListRequest(manga: SManga): Request {
+ val id = manga.url.getMangaId()
+ return GET("$API_URL/chapters/$id/all", headers)
+ }
+
+ override fun chapterListParse(response: Response): List {
+ val parsed = response.parseAs()
+
+ return parsed.chapters.reversed().map {
+ SChapter.create().apply {
+ name = "Capítulo ${it.number}"
+ // Sometimes the "number" attribute have letters or other characters,
+ // which could ruin the automatic chapter number recognition system.
+ chapter_number = it.number.trim { char -> !char.isDigit() }.toFloatOrNull() ?: 1F
+
+ url = "$API_PATH/chapter/versions/${it.versionId}"
+
+ date_upload = it.created_at.orEmpty().toDate()
+ }
+ }
+ }
+
+ // =============================== Pages ================================
+ override fun pageListParse(response: Response): List {
+ val data = response.parseAs()
+ val sortedPages = data.pages.sortedBy { it.url.substringAfterLast("/") }
+ val host = getImageHost(sortedPages.first().url)
+
+ return sortedPages.mapIndexed { index, item ->
+ Page(index, imageUrl = host + item.url)
+ }
+ }
+
+ /**
+ * The source normally uses only one CDN per chapter, so we'll try to get
+ * the correct CDN before loading all pages, leaving the [imageCdnSwapper]
+ * as the last choice.
+ */
+ private fun getImageHost(path: String): String {
+ val pageCheck = super.client.newCall(GET(MAIN_CDN + path, headers)).execute()
+ pageCheck.close()
+ return when {
+ !pageCheck.isSuccessful -> SECONDARY_CDN
+ else -> MAIN_CDN
+ }
+ }
+
+ override fun imageUrlParse(response: Response): String {
+ throw UnsupportedOperationException()
+ }
+
+ // ============================= Utilities ==============================
+ private inline fun Response.parseAs(): T = use {
+ json.decodeFromStream(it.body.byteStream())
+ }
+
+ private fun HttpUrl.Builder.addIfNotBlank(query: String, value: String): HttpUrl.Builder {
+ if (value.isNotBlank()) addQueryParameter(query, value)
+ return this
+ }
+
+ private fun String.getMangaId() = substringAfter("/obra/").substringBefore("/")
+
+ private fun String.toDate(): Long {
+ return runCatching { DATE_FORMATTER.parse(trim())?.time }
+ .getOrNull() ?: 0L
+ }
+
+ /**
+ * This may sound stupid (because it is), but a similar approach exists
+ * in the source itself, because they somehow don't know to which server
+ * each page belongs to. I thought the `server` attribute returned by page
+ * objects would be enough, but it turns out that it isn't. Day ruined.
+ */
+ private fun imageCdnSwapper(chain: Interceptor.Chain): Response {
+ val request = chain.request()
+ val response = chain.proceed(request)
+
+ return if (response.code != 404) {
+ response
+ } else {
+ response.close()
+ val url = request.url.toString()
+ val newUrl = when {
+ url.startsWith(MAIN_CDN) -> url.replace("$MAIN_CDN/tsuki", SECONDARY_CDN)
+ url.startsWith(SECONDARY_CDN) -> url.replace(SECONDARY_CDN, "$MAIN_CDN/tsuki")
+ else -> url
+ }
+
+ val newRequest = GET(newUrl, request.headers)
+ chain.proceed(newRequest)
+ }
+ }
+
+ companion object {
+ const val PREFIX_SEARCH = "id:"
+
+ private val DATE_FORMATTER by lazy {
+ SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.ENGLISH)
+ }
+
+ private const val MAIN_CDN = "https://cdn.tsuki-mangas.com/tsuki"
+ private const val SECONDARY_CDN = "https://cdn2.tsuki-mangas.com"
+ private const val API_PATH = "/api/v2"
+ }
+}
diff --git a/src/pt/tsukimangas/src/eu/kanade/tachiyomi/extension/pt/tsukimangas/TsukiMangasFilters.kt b/src/pt/tsukimangas/src/eu/kanade/tachiyomi/extension/pt/tsukimangas/TsukiMangasFilters.kt
new file mode 100644
index 000000000..77c5c6c5e
--- /dev/null
+++ b/src/pt/tsukimangas/src/eu/kanade/tachiyomi/extension/pt/tsukimangas/TsukiMangasFilters.kt
@@ -0,0 +1,475 @@
+package eu.kanade.tachiyomi.extension.pt.tsukimangas
+
+import eu.kanade.tachiyomi.source.model.Filter
+import eu.kanade.tachiyomi.source.model.FilterList
+
+object TsukiMangasFilters {
+ open class CheckBoxFilterList(name: String, val pairs: Array>) :
+ Filter.Group(name, pairs.map { CheckBoxVal(it.first) })
+
+ private class CheckBoxVal(name: String) : Filter.CheckBox(name, false)
+
+ private inline fun FilterList.parseCheckbox(
+ options: Array>,
+ ): Sequence {
+ return (first { it is R } as CheckBoxFilterList).state
+ .asSequence()
+ .filter { it.state }
+ .map { checkbox -> options.find { it.first == checkbox.name }!!.second }
+ }
+
+ open class SelectFilter(
+ displayName: String,
+ val vals: Array>,
+ ) : Filter.Select(
+ displayName,
+ vals.map { it.first }.toTypedArray(),
+ ) {
+ val selected get() = vals[state].second
+ }
+
+ private inline fun FilterList.getSelected(): String {
+ return (first { it is R } as SelectFilter).selected
+ }
+
+ internal class GenresFilter : CheckBoxFilterList("Gêneros", GENRES)
+ internal class TagsFilter : CheckBoxFilterList("Tags", TAGS)
+
+ internal class FormatFilter : SelectFilter("Formato", FORMATS)
+ internal class AdultFilter : SelectFilter("Mostrar conteúdo adulto", ADULT_OPTIONS)
+ internal class ContentFilter : SelectFilter("Filtro", CONTENT_FILTER)
+ internal class StatusFilter : SelectFilter("Status", STATUS)
+
+ internal val FILTER_LIST get() = FilterList(
+ GenresFilter(),
+ TagsFilter(),
+
+ FormatFilter(),
+ AdultFilter(),
+ ContentFilter(),
+ StatusFilter(),
+ )
+
+ internal data class FilterSearchParams(
+ val genres: Sequence = emptySequence(),
+ val tags: Sequence = emptySequence(),
+
+ val format: String = "",
+ val adult: String = "",
+ val filter: String = "",
+ val status: String = "",
+ )
+
+ internal fun getSearchParameters(filters: FilterList): FilterSearchParams {
+ if (filters.isEmpty()) return FilterSearchParams()
+
+ return FilterSearchParams(
+ filters.parseCheckbox(GENRES),
+ filters.parseCheckbox(TAGS),
+
+ filters.getSelected(),
+ filters.getSelected(),
+ filters.getSelected(),
+ filters.getSelected(),
+ )
+ }
+
+ private val GENRES = arrayOf(
+ "4-Koma",
+ "Adaptação",
+ "Aliens",
+ "Animais",
+ "Antologia",
+ "Artes Marciais",
+ "Aventura",
+ "Ação",
+ "Colorido por fã",
+ "Comédia",
+ "Criado pelo Usuário",
+ "Crime",
+ "Cross-dressing",
+ "Deliquentes",
+ "Demônios",
+ "Doujinshi",
+ "Drama",
+ "Ecchi",
+ "Esportes",
+ "Fantasia",
+ "Fantasmas",
+ "Filosófico",
+ "Gals",
+ "Ganhador de Prêmio",
+ "Garotas Monstro",
+ "Garotas Mágicas",
+ "Gastronomia",
+ "Gore",
+ "Harém",
+ "Harém Reverso",
+ "Hentai",
+ "Histórico",
+ "Horror",
+ "Incesto",
+ "Isekai",
+ "Jogos Tradicionais",
+ "Lolis",
+ "Long Strip",
+ "Mafia",
+ "Magia",
+ "Mecha",
+ "Medicina",
+ "Militar",
+ "Mistério",
+ "Monstros",
+ "Música",
+ "Ninjas",
+ "Obscenidade",
+ "Oficialmente Colorido",
+ "One-shot",
+ "Policial",
+ "Psicológico",
+ "Pós-apocalíptico",
+ "Realidade Virtual",
+ "Reencarnação",
+ "Romance",
+ "Samurais",
+ "Sci-Fi",
+ "Shotas",
+ "Shoujo Ai",
+ "Shounen Ai",
+ "Slice of Life",
+ "Sobrenatural",
+ "Sobrevivência",
+ "Super Herói",
+ "Thriller",
+ "Todo Colorido",
+ "Trabalho de Escritório",
+ "Tragédia",
+ "Troca de Gênero",
+ "Vampiros",
+ "Viagem no Tempo",
+ "Vida Escolar",
+ "Violência Sexual",
+ "Vídeo Games",
+ "Webcomic",
+ "Wuxia",
+ "Yaoi",
+ "Yuri",
+ "Zumbis",
+ ).map { Pair(it, it) }.toTypedArray()
+
+ private val TAGS = arrayOf(
+ Pair("4-Koma", "4-Koma"),
+ Pair("Acromático", "Achromatic"),
+ Pair("Adoção", "Adoption"),
+ Pair("Agricultura", "Agriculture"),
+ Pair("Airsoft", "Airsoft"),
+ Pair("Alienígenas", "Aliens"),
+ Pair("Alquimia", "Alchemy"),
+ Pair("Amadurecimento", "Coming Of Age"),
+ Pair("Ambiental", "Environmental"),
+ Pair("Amnésia", "Amnesia"),
+ Pair("Amor entre Adolescentes", "Teens' Love"),
+ Pair("Amor entre Homens", "Boys' Love"),
+ Pair("Anacronismo", "Anachronism"),
+ Pair("Animais", "Animals"),
+ Pair("Anjos", "Angels"),
+ Pair("Anti-Herói", "Anti-Hero"),
+ Pair("Antologia", "Anthology"),
+ Pair("Antropomorfismo", "Anthropomorphism"),
+ Pair("Anúncio Publicitário", "Advertisement"),
+ Pair("Ao Ar Livre", "Outdoor"),
+ Pair("Arco e Flecha", "Archery"),
+ Pair("Armas", "Guns"),
+ Pair("Artes Marciais", "Martial Arts"),
+ Pair("Assassinos", "Assassins"),
+ Pair("Assexual", "Asexual"),
+ Pair("Astronomia", "Astronomy"),
+ Pair("Atletismo", "Athletics"),
+ Pair("Atuação", "Acting"),
+ Pair("Autobiográfico", "Autobiographical"),
+ Pair("Aviação", "Aviation"),
+ Pair("Badminton", "Badminton"),
+ Pair("Banda", "Band"),
+ Pair("Bar", "Bar"),
+ Pair("Barreira de Idioma Estrangeiro", "Foreign Language Barrier"),
+ Pair("Basquete", "Basketball"),
+ Pair("Batalha Real", "Battle Royale"),
+ Pair("Batalha de Cartas", "Card Battle"),
+ Pair("Beisebol", "Baseball"),
+ Pair("Biográfico", "Biographical"),
+ Pair("Bissexual", "Bisexual"),
+ Pair("Bombeiros", "Firefighters"),
+ Pair("Boxe", "Boxing"),
+ Pair("Bruxa", "Witch"),
+ Pair("Bullying", "Bullying"),
+ Pair("CGI Completo", "Full CGI"),
+ Pair("CGI", "CGI"),
+ Pair("Caligrafia", "Calligraphy"),
+ Pair("Canibalismo", "Cannibalism"),
+ Pair("Carros", "Cars"),
+ Pair("Casamento", "Marriage"),
+ Pair("Centauro", "Centaur"),
+ Pair("Chibi", "Chibi"),
+ Pair("Chuunibyou", "Chuunibyou"),
+ Pair("Ciborgue", "Cyborg"),
+ Pair("Ciclismo", "Cycling"),
+ Pair("Ciclomotores", "Mopeds"),
+ Pair("Circo", "Circus"),
+ Pair("Civilização Perdida", "Lost Civilization"),
+ Pair("Clone", "Clone"),
+ Pair("Clube Escolar", "School Club"),
+ Pair("Comida", "Food"),
+ Pair("Comédia Surrealista", "Surreal Comedy"),
+ Pair("Conspiração", "Conspiracy"),
+ Pair("Conto de Fadas", "Fairy Tale"),
+ Pair("Cor Completa", "Full Color"),
+ Pair("Cosplay", "Cosplay"),
+ Pair("Crime", "Crime"),
+ Pair("Crossover", "Crossover"),
+ Pair("Cultivo", "Cultivation"),
+ Pair("Culto", "Cult"),
+ Pair("Cultura Otaku", "Otaku Culture"),
+ Pair("Cyberpunk", "Cyberpunk"),
+ Pair("Dança", "Dancing"),
+ Pair("Deficiência", "Disability"),
+ Pair("Delinquentes", "Delinquents"),
+ Pair("Demônios", "Demons"),
+ Pair("Denpa", "Denpa"),
+ Pair("Desenho", "Drawing"),
+ Pair("Desenvolvimento de Software", "Software Development"),
+ Pair("Deserto", "Desert"),
+ Pair("Detetive", "Detective"),
+ Pair("Deuses", "Gods"),
+ Pair("Diferença de Idade", "Age Gap"),
+ Pair("Dinossauros", "Dinosaurs"),
+ Pair("Distópico", "Dystopian"),
+ Pair("Donzela do Santuário", "Shrine Maiden"),
+ Pair("Dragões", "Dragons"),
+ Pair("Drogas", "Drugs"),
+ Pair("Dullahan", "Dullahan"),
+ Pair("E-Sports", "E-Sports"),
+ Pair("Economia", "Economics"),
+ Pair("Educacional", "Educational"),
+ Pair("Elenco Conjunto", "Ensemble Cast"),
+ Pair("Elenco Principalmente Adolescente", "Primarily Teen Cast"),
+ Pair("Elenco Principalmente Adulto", "Primarily Adult Cast"),
+ Pair("Elenco Principalmente Feminino", "Primarily Female Cast"),
+ Pair("Elenco Principalmente Infantil", "Primarily Child Cast"),
+ Pair("Elenco Principalmente Masculino", "Primarily Male Cast"),
+ Pair("Elfo", "Elf"),
+ Pair("Empregadas", "Maids"),
+ Pair("Episódico", "Episodic"),
+ Pair("Ero Guro", "Ero Guro"),
+ Pair("Escola", "School"),
+ Pair("Escravidão", "Slavery"),
+ Pair("Escrita", "Writing"),
+ Pair("Esgrima", "Fencing"),
+ Pair("Espaço", "Space"),
+ Pair("Espionagem", "Espionage"),
+ Pair("Esqueleto", "Skeleton"),
+ Pair("Faculdade", "College"),
+ Pair("Fada", "Fairy"),
+ Pair("Família Encontrada", "Found Family"),
+ Pair("Fantasia Urbana", "Urban Fantasy"),
+ Pair("Fantasma", "Ghost"),
+ Pair("Filosofia", "Philosophy"),
+ Pair("Fitness", "Fitness"),
+ Pair("Flash", "Flash"),
+ Pair("Fotografia", "Photography"),
+ Pair("Freira", "Nun"),
+ Pair("Fugitivo", "Fugitive"),
+ Pair("Futebol Americano", "American Football"),
+ Pair("Futebol", "Football"),
+ Pair("Gangues", "Gangs"),
+ Pair("Garota Monstro", "Monster Girl"),
+ Pair("Garotas Bonitinhas Fazendo Coisas Bonitinhas", "Cute Girls Doing Cute Things"),
+ Pair("Garoto Feminino", "Femboy"),
+ Pair("Garoto Monstro", "Monster Boy"),
+ Pair("Garotos Bonitinhos Fazendo Coisas Bonitinhas", "Cute Boys Doing Cute Things"),
+ Pair("Go", "Go"),
+ Pair("Goblin", "Goblin"),
+ Pair("Golfe", "Golf"),
+ Pair("Gore", "Gore"),
+ Pair("Guerra", "War"),
+ Pair("Gyaru", "Gyaru"),
+ Pair("Gêmeos", "Twins"),
+ Pair("Handebol", "Handball"),
+ Pair("Harém Feminino", "Female Harem"),
+ Pair("Harém Masculino", "Male Harem"),
+ Pair("Harém com Gêneros Mistos", "Mixed Gender Harem"),
+ Pair("Henshin", "Henshin"),
+ Pair("Heterossexual", "Heterosexual"),
+ Pair("Hikikomori", "Hikikomori"),
+ Pair("Histórico", "Historical"),
+ Pair("Horror Corporal", "Body Horror"),
+ Pair("Horror Cósmico", "Cosmic Horror"),
+ Pair("Identidades Dissociativas", "Dissociative Identities"),
+ Pair("Inteligência Artificial", "Artificial Intelligence"),
+ Pair("Isekai", "Isekai"),
+ Pair("Iyashikei", "Iyashikei"),
+ Pair("Jogo da Morte", "Death Game"),
+ Pair("Jogos Eletrônicos", "Video Games"),
+ Pair("Jogos de Azar", "Gambling"),
+ Pair("Judô", "Judo"),
+ Pair("Kaiju", "Kaiju"),
+ Pair("Karuta", "Karuta"),
+ Pair("Kemonomimi", "Kemonomimi"),
+ Pair("Kuudere", "Kuudere"),
+ Pair("Lacrosse", "Lacrosse"),
+ Pair("Literatura Clássica", "Classic Literature"),
+ Pair("Lobisomem", "Werewolf"),
+ Pair("Luta Livre", "Wrestling"),
+ Pair("Luta com Espada", "Swordplay"),
+ Pair("Luta com Lança", "Spearplay"),
+ Pair("Líder de Torcida", "Cheerleading"),
+ Pair("Magia", "Magic"),
+ Pair("Mahjong", "Mahjong"),
+ Pair("Manipulação de Memória", "Memory Manipulation"),
+ Pair("Manipulação do Tempo", "Time Manipulation"),
+ Pair("Maquiagem", "Makeup"),
+ Pair("Maria-rapaz", "Tomboy"),
+ Pair("Masmorra", "Dungeon"),
+ Pair("Medicina", "Medicine"),
+ Pair("Mergulho", "Scuba Diving"),
+ Pair("Meta", "Meta"),
+ Pair("Militar", "Military"),
+ Pair("Mitologia", "Mythology"),
+ Pair("Moda", "Fashion"),
+ Pair("Mordomo", "Butler"),
+ Pair("Motocicletas", "Motorcycles"),
+ Pair("Mudança de Forma", "Shapeshifting"),
+ Pair("Mulher de Escritório", "Office Lady"),
+ Pair("Mundo Virtual", "Virtual World"),
+ Pair("Musical", "Musical"),
+ Pair("Máfia", "Mafia"),
+ Pair("Natação", "Swimming"),
+ Pair("Navios", "Ships"),
+ Pair("Necromancia", "Necromancy"),
+ Pair("Nekomimi", "Nekomimi"),
+ Pair("Ninja", "Ninja"),
+ Pair("Noir", "Noir"),
+ Pair("Nudez", "Nudity"),
+ Pair("Não Ficção", "Non-Fiction"),
+ Pair("Oiran", "Oiran"),
+ Pair("Ojou-Sama", "Ojou-Sama"),
+ Pair("Ordem Acrônica", "Achronological Order"),
+ Pair("Pandemia", "Pandemic"),
+ Pair("Parkour", "Parkour"),
+ Pair("Paródia", "Parody"),
+ Pair("Patinagem no Gelo", "Ice Skating"),
+ Pair("Pele Bronzeada", "Tanned Skin"),
+ Pair("Pesca", "Fishing"),
+ Pair("Piratas", "Pirates"),
+ Pair("Polícia", "Police"),
+ Pair("Política", "Politics"),
+ Pair("Ponto de Vista", "POV"),
+ Pair("Prisão", "Prison"),
+ Pair("Professor(a)", "Teacher"),
+ Pair("Protagonista Feminina", "Female Protagonist"),
+ Pair("Protagonista Masculino", "Male Protagonist"),
+ Pair("Pular no Tempo", "Time Skip"),
+ Pair("Puppetry", "Puppetry"),
+ Pair("Pós-Apocalíptico", "Post-Apocalyptic"),
+ Pair("Pós-Vida", "Afterlife"),
+ Pair("Pôquer", "Poker"),
+ Pair("Quimera", "Chimera"),
+ Pair("Rakugo", "Rakugo"),
+ Pair("Reabilitação", "Rehabilitation"),
+ Pair("Realidade Aumentada", "Augmented Reality"),
+ Pair("Reencarnação", "Reincarnation"),
+ Pair("Regressão de Idade", "Age Regression"),
+ Pair("Religião", "Religion"),
+ Pair("Robô Real", "Real Robot"),
+ Pair("Robôs", "Robots"),
+ Pair("Rotoscopia", "Rotoscoping"),
+ Pair("Rugby", "Rugby"),
+ Pair("Rural", "Rural"),
+ Pair("Samurai", "Samurai"),
+ Pair("Sem Diálogo", "No Dialogue"),
+ Pair("Sem Gênero", "Agender"),
+ Pair("Sem-teto", "Homeless"),
+ Pair("Sereia", "Mermaid"),
+ Pair("Shogi", "Shogi"),
+ Pair("Skateboarding", "Skateboarding"),
+ Pair("Slapstick", "Slapstick"),
+ Pair("Sobrevivência", "Survival"),
+ Pair("Steampunk", "Steampunk"),
+ Pair("Stop Motion", "Stop Motion"),
+ Pair("Suicídio", "Suicide"),
+ Pair("Sumô", "Sumo"),
+ Pair("Super Robô", "Super Robot"),
+ Pair("Super-herói", "Superhero"),
+ Pair("Superpoder", "Super Power"),
+ Pair("Surf", "Surfing"),
+ Pair("Sátira", "Satire"),
+ Pair("Súcubo", "Succubus"),
+ Pair("Tanques", "Tanks"),
+ Pair("Temas LGBTQ+", "LGBTQ+ Themes"),
+ Pair("Terrorismo", "Terrorism"),
+ Pair("Tokusatsu", "Tokusatsu"),
+ Pair("Tortura", "Torture"),
+ Pair("Trabalho", "Work"),
+ Pair("Tragédia", "Tragedy"),
+ Pair("Transgênero", "Transgender"),
+ Pair("Travestismo", "Crossdressing"),
+ Pair("Trens", "Trains"),
+ Pair("Triângulo Amoroso", "Love Triangle"),
+ Pair("Troca de Corpos", "Body Swapping"),
+ Pair("Troca de Gênero", "Gender Bending"),
+ Pair("Tríades", "Triads"),
+ Pair("Tsundere", "Tsundere"),
+ Pair("Tênis de Mesa", "Table Tennis"),
+ Pair("Tênis", "Tennis"),
+ Pair("Universo Alternativo", "Alternate Universe"),
+ Pair("Urbano", "Urban"),
+ Pair("VTuber", "VTuber"),
+ Pair("Vampiro", "Vampire"),
+ Pair("Viagem", "Travel"),
+ Pair("Vida Familiar", "Family Life"),
+ Pair("Vikings", "Vikings"),
+ Pair("Vilã", "Villainess"),
+ Pair("Vingança", "Revenge"),
+ Pair("Vôlei", "Volleyball"),
+ Pair("Wuxia", "Wuxia"),
+ Pair("Yakuza", "Yakuza"),
+ Pair("Yandere", "Yandere"),
+ Pair("Youkai", "Youkai"),
+ Pair("Yuri", "Yuri"),
+ Pair("Zumbi", "Zombie"),
+ Pair("Ídolo", "Idol"),
+ Pair("Ópera Espacial", "Space Opera"),
+ Pair("Órfão/Órfã", "Orphan"),
+ )
+
+ private val ANY = Pair("Qualquer um", "")
+
+ private val FORMATS = arrayOf(
+ ANY,
+ Pair("Mangá", "1"),
+ Pair("Manhwa", "2"),
+ Pair("Manhua", "3"),
+ Pair("Novel", "4"),
+ )
+
+ private val ADULT_OPTIONS = arrayOf(
+ ANY,
+ Pair("Sim", "1"),
+ Pair("Não", "0"),
+ )
+
+ private val CONTENT_FILTER = arrayOf(
+ ANY,
+ Pair("Mais popular", "0"),
+ Pair("Menos popular", "1"),
+ Pair("Melhores notas", "2"),
+ Pair("Piores notas", "3"),
+ )
+
+ private val STATUS = arrayOf(
+ ANY,
+ Pair("Ativo", "0"),
+ Pair("Completo", "1"),
+ Pair("Cancelado", "2"),
+ Pair("Hiato", "3"),
+ )
+}
diff --git a/src/pt/tsukimangas/src/eu/kanade/tachiyomi/extension/pt/tsukimangas/TsukiMangasUrlActivity.kt b/src/pt/tsukimangas/src/eu/kanade/tachiyomi/extension/pt/tsukimangas/TsukiMangasUrlActivity.kt
new file mode 100644
index 000000000..1722468b1
--- /dev/null
+++ b/src/pt/tsukimangas/src/eu/kanade/tachiyomi/extension/pt/tsukimangas/TsukiMangasUrlActivity.kt
@@ -0,0 +1,41 @@
+package eu.kanade.tachiyomi.extension.pt.tsukimangas
+
+import android.app.Activity
+import android.content.ActivityNotFoundException
+import android.content.Intent
+import android.os.Bundle
+import android.util.Log
+import kotlin.system.exitProcess
+
+/**
+ * Springboard that accepts https://tsuki-mangas.com/obra//- intents
+ * and redirects them to the main Tachiyomi process.
+ */
+class TsukiMangasUrlActivity : Activity() {
+
+ private val tag = javaClass.simpleName
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ val pathSegments = intent?.data?.pathSegments
+ if (pathSegments != null && pathSegments.size > 1) {
+ val id = pathSegments[1]
+ val mainIntent = Intent().apply {
+ action = "eu.kanade.tachiyomi.SEARCH"
+ putExtra("query", "${TsukiMangas.PREFIX_SEARCH}$id")
+ putExtra("filter", packageName)
+ }
+
+ try {
+ startActivity(mainIntent)
+ } catch (e: ActivityNotFoundException) {
+ Log.e(tag, e.toString())
+ }
+ } else {
+ Log.e(tag, "could not parse uri from intent $intent")
+ }
+
+ finish()
+ exitProcess(0)
+ }
+}
diff --git a/src/pt/tsukimangas/src/eu/kanade/tachiyomi/extension/pt/tsukimangas/dto/TsukiMangasDto.kt b/src/pt/tsukimangas/src/eu/kanade/tachiyomi/extension/pt/tsukimangas/dto/TsukiMangasDto.kt
new file mode 100644
index 000000000..7207a0150
--- /dev/null
+++ b/src/pt/tsukimangas/src/eu/kanade/tachiyomi/extension/pt/tsukimangas/dto/TsukiMangasDto.kt
@@ -0,0 +1,70 @@
+package eu.kanade.tachiyomi.extension.pt.tsukimangas.dto
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class MangaListDto(
+ val data: List,
+ val page: Int,
+ val lastPage: Int,
+)
+
+@Serializable
+data class SimpleMangaDto(
+ val id: Int,
+ @SerialName("url") val slug: String,
+ val title: String,
+ val poster: String? = null,
+ val cover: String? = null,
+) {
+ val imagePath = "/img/imgs/${poster ?: cover ?: "nobackground.jpg"}"
+ val entryPath = "/$id/$slug"
+}
+
+@Serializable
+data class CompleteMangaDto(
+ val id: Int,
+ @SerialName("url") val slug: String,
+
+ val title: String,
+ val poster: String? = null,
+ val cover: String? = null,
+ val status: String? = null,
+ val synopsis: String? = null,
+ val staff: String? = null,
+ val genres: List = emptyList(),
+ val titles: List = emptyList(),
+) {
+ val entryPath = "/$id/$slug"
+
+ val imagePath = "/img/imgs/${poster ?: cover ?: "nobackground.jpg"}"
+
+ @Serializable
+ data class Genre(val genre: String)
+
+ @Serializable
+ data class Title(val title: String)
+}
+
+@Serializable
+data class ChapterListDto(val chapters: List)
+
+@Serializable
+data class ChapterDto(
+ val number: String,
+ val title: String? = null,
+ val created_at: String? = null,
+ private val versions: List,
+) {
+ @Serializable
+ data class Version(val id: Int)
+
+ val versionId = versions.first().id
+}
+
+@Serializable
+data class PageListDto(val pages: List)
+
+@Serializable
+data class PageDto(val url: String)