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)