diff --git a/src/pt/taiyo/AndroidManifest.xml b/src/pt/taiyo/AndroidManifest.xml
new file mode 100644
index 000000000..26b60d15a
--- /dev/null
+++ b/src/pt/taiyo/AndroidManifest.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/pt/taiyo/build.gradle b/src/pt/taiyo/build.gradle
new file mode 100644
index 000000000..2e28fafce
--- /dev/null
+++ b/src/pt/taiyo/build.gradle
@@ -0,0 +1,8 @@
+ext {
+ extName = 'Taiyō'
+ extClass = '.Taiyo'
+ extVersionCode = 1
+ isNsfw = true
+}
+
+apply from: "$rootDir/common.gradle"
diff --git a/src/pt/taiyo/res/mipmap-hdpi/ic_launcher.png b/src/pt/taiyo/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 000000000..790174cad
Binary files /dev/null and b/src/pt/taiyo/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/src/pt/taiyo/res/mipmap-mdpi/ic_launcher.png b/src/pt/taiyo/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 000000000..6bd630ba9
Binary files /dev/null and b/src/pt/taiyo/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/src/pt/taiyo/res/mipmap-xhdpi/ic_launcher.png b/src/pt/taiyo/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 000000000..8f390d647
Binary files /dev/null and b/src/pt/taiyo/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/src/pt/taiyo/res/mipmap-xxhdpi/ic_launcher.png b/src/pt/taiyo/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 000000000..91756b858
Binary files /dev/null and b/src/pt/taiyo/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/src/pt/taiyo/res/mipmap-xxxhdpi/ic_launcher.png b/src/pt/taiyo/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 000000000..92ca8345a
Binary files /dev/null and b/src/pt/taiyo/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/src/pt/taiyo/src/eu/kanade/tachiyomi/extension/pt/taiyo/Taiyo.kt b/src/pt/taiyo/src/eu/kanade/tachiyomi/extension/pt/taiyo/Taiyo.kt
new file mode 100644
index 000000000..593432ca4
--- /dev/null
+++ b/src/pt/taiyo/src/eu/kanade/tachiyomi/extension/pt/taiyo/Taiyo.kt
@@ -0,0 +1,274 @@
+package eu.kanade.tachiyomi.extension.pt.taiyo
+
+import eu.kanade.tachiyomi.extension.pt.taiyo.dto.AdditionalInfoDto
+import eu.kanade.tachiyomi.extension.pt.taiyo.dto.ChapterListDto
+import eu.kanade.tachiyomi.extension.pt.taiyo.dto.MediaChapterDto
+import eu.kanade.tachiyomi.extension.pt.taiyo.dto.ResponseDto
+import eu.kanade.tachiyomi.extension.pt.taiyo.dto.SearchResultDto
+import eu.kanade.tachiyomi.network.GET
+import eu.kanade.tachiyomi.network.POST
+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.ParsedHttpSource
+import eu.kanade.tachiyomi.util.asJsoup
+import kotlinx.serialization.decodeFromString
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.buildJsonObject
+import kotlinx.serialization.json.decodeFromStream
+import kotlinx.serialization.json.put
+import kotlinx.serialization.json.putJsonObject
+import okhttp3.HttpUrl.Companion.toHttpUrl
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.Request
+import okhttp3.RequestBody.Companion.toRequestBody
+import okhttp3.Response
+import org.jsoup.nodes.Document
+import org.jsoup.nodes.Element
+import rx.Observable
+import uy.kohesive.injekt.injectLazy
+import java.text.SimpleDateFormat
+import java.util.Locale
+
+class Taiyo : ParsedHttpSource() {
+
+ override val name = "Taiyō"
+
+ override val baseUrl = "https://www.taiyo.moe"
+
+ override val lang = "pt-BR"
+
+ override val supportsLatest = true
+
+ override val client = network.client.newBuilder()
+ .rateLimitHost(baseUrl.toHttpUrl(), 2)
+ .rateLimitHost(IMG_CDN.toHttpUrl(), 2)
+ .build()
+
+ private val json: Json by injectLazy()
+
+ // ============================== Popular ===============================
+ override fun popularMangaRequest(page: Int) = GET(baseUrl, headers)
+
+ override fun popularMangaSelector() = "main > div.flex > div.overflow-hidden div.flex > a"
+
+ override fun popularMangaFromElement(element: Element) = SManga.create().apply {
+ setUrlWithoutDomain(element.attr("href"))
+ thumbnail_url = element.selectFirst("div.overflow-hidden > img")?.getImageUrl()
+ title = element.selectFirst("p")!!.text()
+ }
+
+ override fun popularMangaNextPageSelector() = null
+
+ // =============================== Latest ===============================
+ override fun latestUpdatesRequest(page: Int) = GET(baseUrl, headers)
+
+ override fun latestUpdatesSelector() = "main div.grow div.flex:has(div.grow)"
+
+ override fun latestUpdatesFromElement(element: Element) = SManga.create().apply {
+ with(element.selectFirst("a.line-clamp-1")!!) {
+ setUrlWithoutDomain(attr("href"))
+ title = text()
+ }
+ thumbnail_url = element.selectFirst("img")?.getImageUrl()?.replace("&w=128", "&w=256")
+ }
+
+ override fun latestUpdatesNextPageSelector() = null
+
+ // =============================== 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("$baseUrl/media/$id"))
+ .asObservableSuccess()
+ .map(::searchMangaByIdParse)
+ } else {
+ super.fetchSearchManga(page, query, filters)
+ }
+ }
+
+ private fun searchMangaByIdParse(response: Response): MangasPage {
+ val details = mangaDetailsParse(response.asJsoup())
+ return MangasPage(listOf(details), false)
+ }
+
+ override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
+ val jsonObj = buildJsonObject {
+ putJsonObject("0") {
+ putJsonObject("json") {
+ put("title", query)
+ }
+ }
+ }
+
+ val requestBody = json.encodeToString(jsonObj).toRequestBody(MEDIA_TYPE)
+
+ return POST("$baseUrl/api/trpc/medias.search?batch=1", headers, requestBody)
+ }
+
+ override fun searchMangaParse(response: Response): MangasPage {
+ val obj = response.parseAs>>>().first()
+ val mangas = obj.data.map { item ->
+ SManga.create().apply {
+ url = "/media/${item.id}"
+ title = item.title
+ thumbnail_url = item.coverId?.let {
+ "$baseUrl/_next/image?url=$IMG_CDN/${item.id}/covers/$it.jpg&w=256&q=75"
+ }
+ }
+ }
+ return MangasPage(mangas, false)
+ }
+
+ override fun searchMangaSelector(): String {
+ throw UnsupportedOperationException()
+ }
+
+ override fun searchMangaFromElement(element: Element): SManga {
+ throw UnsupportedOperationException()
+ }
+
+ override fun searchMangaNextPageSelector(): String? {
+ throw UnsupportedOperationException()
+ }
+
+ // =========================== Manga Details ============================
+ override fun mangaDetailsParse(document: Document) = SManga.create().apply {
+ setUrlWithoutDomain(document.location())
+ thumbnail_url = document.selectFirst("section:has(h2) img")?.getImageUrl()
+ title = document.selectFirst("p.media-title")!!.text()
+
+ val additionalDataObj = document.parseJsonFromDocument {
+ substringBefore(",\\\"trackers\\\"") + "}"
+ }
+
+ genre = additionalDataObj?.genres?.joinToString { it.portugueseName }
+ status = when (additionalDataObj?.status.orEmpty()) {
+ "FINISHED" -> SManga.COMPLETED
+ "RELEASING" -> SManga.ONGOING
+ else -> SManga.UNKNOWN
+ }
+
+ description = buildString {
+ val synopsis = document.selectFirst("section > div.flex + div p")?.text()
+ ?: additionalDataObj?.synopsis
+ synopsis?.also { append("$it\n\n") }
+
+ additionalDataObj?.titles?.takeIf { it.isNotEmpty() }?.run {
+ append("Títulos alternativos:")
+ forEach {
+ val languageName = Locale(it.language.substringBefore("_")).displayLanguage
+ append("\n\t$languageName: ${it.title}")
+ }
+ }
+ }
+ }
+
+ // ============================== Chapters ==============================
+ override fun fetchChapterList(manga: SManga): Observable> {
+ val id = manga.url.substringAfter("/media/").trimEnd('/')
+ var page = 1
+ val apiUrl = "$baseUrl/api/trpc/mediaChapters.getByMediaId?batch=1".toHttpUrl()
+ val chapters = buildList {
+ do {
+ val input = buildJsonObject {
+ putJsonObject("0") {
+ putJsonObject("json") {
+ put("mediaId", id)
+ put("page", page)
+ put("perPage", 50)
+ }
+ }
+ }
+
+ page++
+
+ val pageUrl = apiUrl.newBuilder()
+ .addQueryParameter("input", json.encodeToString(input))
+ .build()
+
+ val res = client.newCall(GET(pageUrl, headers)).execute()
+ val parsed = res.parseAs>>().first().data
+ addAll(
+ parsed.chapters.map {
+ SChapter.create().apply {
+ chapter_number = it.number
+ name = it.title ?: "Capítulo ${it.number}".replace(".0", "")
+ url = "/chapter/${it.id}/1"
+ date_upload = it.createdAt.orEmpty().toDate()
+ }
+ },
+ )
+ } while (page <= parsed.totalPages)
+ }
+
+ return Observable.just(chapters.sortedByDescending { it.chapter_number })
+ }
+
+ override fun chapterListSelector(): String {
+ throw UnsupportedOperationException()
+ }
+
+ override fun chapterFromElement(element: Element): SChapter {
+ throw UnsupportedOperationException()
+ }
+
+ // =============================== Pages ================================
+ override fun pageListParse(document: Document): List {
+ val chapterObj = document.parseJsonFromDocument("mediaChapter") {
+ substringBefore(",\\\"chapters\\\"") + "}}"
+ }!!
+
+ val base = "$IMG_CDN/${chapterObj.media.id}/chapters/${chapterObj.id}"
+
+ return chapterObj.pages.mapIndexed { index, item ->
+ Page(index, imageUrl = "$base/${item.id}.jpg")
+ }
+ }
+
+ override fun imageUrlParse(document: Document): String {
+ throw UnsupportedOperationException()
+ }
+
+ // ============================= Utilities ==============================
+ private fun Element.getImageUrl() = absUrl("srcset").substringBefore(" ")
+
+ private inline fun Response.parseAs(): T = use {
+ json.decodeFromStream(it.body.byteStream())
+ }
+
+ private fun String.toDate(): Long {
+ return runCatching { DATE_FORMATTER.parse(this)?.time }
+ .getOrNull() ?: 0L
+ }
+
+ private inline fun Document.parseJsonFromDocument(
+ itemName: String = "media",
+ crossinline transformer: String.() -> String,
+ ): T? {
+ return runCatching {
+ val script = selectFirst("script:containsData($itemName\\\\\":):containsData(\\\"6:\\[)")!!.data()
+ val obj = script.substringAfter(",{\\\"$itemName\\\":")
+ .run(transformer)
+ .replace("\\", "")
+ json.decodeFromString(obj)
+ }.onFailure { it.printStackTrace() }.getOrNull()
+ }
+
+ companion object {
+ const val PREFIX_SEARCH = "id:"
+
+ private const val IMG_CDN = "https://cdn.taiyo.moe/medias"
+
+ private val MEDIA_TYPE = "application/json; charset=utf-8".toMediaType()
+
+ private val DATE_FORMATTER by lazy {
+ SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSZ", Locale.ENGLISH)
+ }
+ }
+}
diff --git a/src/pt/taiyo/src/eu/kanade/tachiyomi/extension/pt/taiyo/TaiyoUrlActivity.kt b/src/pt/taiyo/src/eu/kanade/tachiyomi/extension/pt/taiyo/TaiyoUrlActivity.kt
new file mode 100644
index 000000000..68764df25
--- /dev/null
+++ b/src/pt/taiyo/src/eu/kanade/tachiyomi/extension/pt/taiyo/TaiyoUrlActivity.kt
@@ -0,0 +1,41 @@
+package eu.kanade.tachiyomi.extension.pt.taiyo
+
+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://www.taiyo.moe/media/- intents
+ * and redirects them to the main Tachiyomi process.
+ */
+class TaiyoUrlActivity : 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 item = pathSegments[1]
+ val mainIntent = Intent().apply {
+ action = "eu.kanade.tachiyomi.SEARCH"
+ putExtra("query", "${Taiyo.PREFIX_SEARCH}$item")
+ 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/taiyo/src/eu/kanade/tachiyomi/extension/pt/taiyo/dto/TaiyoDto.kt b/src/pt/taiyo/src/eu/kanade/tachiyomi/extension/pt/taiyo/dto/TaiyoDto.kt
new file mode 100644
index 000000000..d92017129
--- /dev/null
+++ b/src/pt/taiyo/src/eu/kanade/tachiyomi/extension/pt/taiyo/dto/TaiyoDto.kt
@@ -0,0 +1,79 @@
+package eu.kanade.tachiyomi.extension.pt.taiyo.dto
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class ResponseDto(val result: ResultDto) {
+ val data: T = result.data.json
+}
+
+@Serializable
+data class ResultDto(val data: DataDto)
+
+@Serializable
+data class DataDto(val json: T)
+
+@Serializable
+data class SearchResultDto(
+ val id: String,
+ val title: String,
+ val coverId: String? = null,
+)
+
+@Serializable
+data class AdditionalInfoDto(
+ val synopsis: String? = null,
+ val status: String? = null,
+ val genres: List? = null,
+ val titles: List? = null,
+)
+
+enum class Genre(val portugueseName: String) {
+ ACTION("Ação"),
+ ADVENTURE("Aventura"),
+ COMEDY("Comédia"),
+ DRAMA("Drama"),
+ ECCHI("Ecchi"),
+ FANTASY("Fantasia"),
+ HENTAI("Hentai"),
+ HORROR("Horror"),
+ MAHOU_SHOUJO("Mahou Shoujo"),
+ MECHA("Mecha"),
+ MUSIC("Música"),
+ MYSTERY("Mistério"),
+ PSYCHOLOGICAL("Psicológico"),
+ ROMANCE("Romance"),
+ SCI_FI("Sci-fi"),
+ SLICE_OF_LIFE("Slice of Life"),
+ SPORTS("Esportes"),
+ SUPERNATURAL("Sobrenatural"),
+ THRILLER("Thriller"),
+}
+
+@Serializable
+data class TitleDto(val title: String, val language: String)
+
+@Serializable
+data class ChapterListDto(val chapters: List, val totalPages: Int)
+
+@Serializable
+data class ChapterDto(
+ val id: String,
+ val number: Float,
+ val scans: List,
+ val title: String?,
+ val createdAt: String? = null,
+)
+
+@Serializable
+data class ScanDto(val name: String)
+
+@Serializable
+data class MediaChapterDto(
+ val id: String,
+ val media: ItemId,
+ val pages: List,
+)
+
+@Serializable
+data class ItemId(val id: String)