New source: pt/Tsuki Mangás (#597)

* feat: Create Tsuki Mangás base

* feat: Implement popular manga page

* feat: Implement latest updates page

* feat: Implement search page

* feat: Implement manga details page

* fix: Fix URL intent handler

* fix: Fix webview url

* feat: Implement chapter list page

* feat: Implement page list

* fix: Fix chapter URLs

Kotlinx-serialization moment

* feat: Apply rate limit to image CDNs

* refactor: Make the API path a separate constant

* chore: Add source icon

... Actually they don't have a icon yet, they're just using the "TSUKI"
text, so I did the same in the icon. it may be updated later, when they
create a proper icon.

* fix: Fix random http 404 in pages

* fix: Prevent multiple wrong requests

* refactor: Apply suggestion - set custom interceptor before ratelimit
This commit is contained in:
Claudemirovsky 2024-01-25 11:24:19 -03:00 committed by GitHub
parent 646334b6b2
commit 2ea74d8170
11 changed files with 868 additions and 0 deletions

View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity
android:name=".pt.tsukimangas.TsukiMangasUrlActivity"
android:excludeFromRecents="true"
android:exported="true"
android:theme="@android:style/Theme.NoDisplay">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="tsuki-mangas.com"
android:pathPattern="/obra/..*"
android:scheme="https" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@ -0,0 +1,8 @@
ext {
extName = 'Tsuki Mangás'
extClass = '.TsukiMangas'
extVersionCode = 1
isNsfw = true
}
apply from: "$rootDir/common.gradle"

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

View File

@ -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<MangaListDto>()
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<MangasPage> {
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<CompleteMangaDto>()
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<SChapter> {
val parsed = response.parseAs<ChapterListDto>()
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<Page> {
val data = response.parseAs<PageListDto>()
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 <reified T> 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"
}
}

View File

@ -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<Pair<String, String>>) :
Filter.Group<Filter.CheckBox>(name, pairs.map { CheckBoxVal(it.first) })
private class CheckBoxVal(name: String) : Filter.CheckBox(name, false)
private inline fun <reified R> FilterList.parseCheckbox(
options: Array<Pair<String, String>>,
): Sequence<String> {
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<Pair<String, String>>,
) : Filter.Select<String>(
displayName,
vals.map { it.first }.toTypedArray(),
) {
val selected get() = vals[state].second
}
private inline fun <reified R> 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<String> = emptySequence(),
val tags: Sequence<String> = 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<GenresFilter>(GENRES),
filters.parseCheckbox<TagsFilter>(TAGS),
filters.getSelected<FormatFilter>(),
filters.getSelected<AdultFilter>(),
filters.getSelected<ContentFilter>(),
filters.getSelected<StatusFilter>(),
)
}
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"),
)
}

View File

@ -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/<id>/<item> 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)
}
}

View File

@ -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<SimpleMangaDto>,
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<Genre> = emptyList(),
val titles: List<Title> = 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<ChapterDto>)
@Serializable
data class ChapterDto(
val number: String,
val title: String? = null,
val created_at: String? = null,
private val versions: List<Version>,
) {
@Serializable
data class Version(val id: Int)
val versionId = versions.first().id
}
@Serializable
data class PageListDto(val pages: List<PageDto>)
@Serializable
data class PageDto(val url: String)