diff --git a/src/pt/tomato/build.gradle b/src/pt/tomato/build.gradle new file mode 100644 index 00000000..6b964445 --- /dev/null +++ b/src/pt/tomato/build.gradle @@ -0,0 +1,7 @@ +ext { + extName = 'Tomato' + extClass = '.Tomato' + extVersionCode = 1 +} + +apply from: "$rootDir/common.gradle" diff --git a/src/pt/tomato/res/mipmap-hdpi/ic_launcher.png b/src/pt/tomato/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 00000000..a9e39ecc Binary files /dev/null and b/src/pt/tomato/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/pt/tomato/res/mipmap-mdpi/ic_launcher.png b/src/pt/tomato/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 00000000..2adff7e0 Binary files /dev/null and b/src/pt/tomato/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/pt/tomato/res/mipmap-xhdpi/ic_launcher.png b/src/pt/tomato/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 00000000..31fa1a88 Binary files /dev/null and b/src/pt/tomato/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/pt/tomato/res/mipmap-xxhdpi/ic_launcher.png b/src/pt/tomato/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 00000000..cc673719 Binary files /dev/null and b/src/pt/tomato/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/pt/tomato/res/mipmap-xxxhdpi/ic_launcher.png b/src/pt/tomato/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 00000000..d2f201dc Binary files /dev/null and b/src/pt/tomato/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/pt/tomato/src/eu/kanade/tachiyomi/animeextension/pt/tomato/Tomato.kt b/src/pt/tomato/src/eu/kanade/tachiyomi/animeextension/pt/tomato/Tomato.kt new file mode 100644 index 00000000..a8b31601 --- /dev/null +++ b/src/pt/tomato/src/eu/kanade/tachiyomi/animeextension/pt/tomato/Tomato.kt @@ -0,0 +1,336 @@ +package eu.kanade.tachiyomi.animeextension.pt.tomato + +import android.app.Application +import android.util.Log +import androidx.preference.ListPreference +import androidx.preference.PreferenceScreen +import eu.kanade.tachiyomi.animeextension.pt.tomato.dto.AnimeResultDto +import eu.kanade.tachiyomi.animeextension.pt.tomato.dto.EpisodeInfoDto +import eu.kanade.tachiyomi.animeextension.pt.tomato.dto.EpisodesResultDto +import eu.kanade.tachiyomi.animeextension.pt.tomato.dto.SearchAnimeItemDto +import eu.kanade.tachiyomi.animeextension.pt.tomato.dto.SearchResultDto +import eu.kanade.tachiyomi.animesource.ConfigurableAnimeSource +import eu.kanade.tachiyomi.animesource.model.AnimeFilterList +import eu.kanade.tachiyomi.animesource.model.AnimesPage +import eu.kanade.tachiyomi.animesource.model.SAnime +import eu.kanade.tachiyomi.animesource.model.SEpisode +import eu.kanade.tachiyomi.animesource.model.Video +import eu.kanade.tachiyomi.animesource.online.AnimeHttpSource +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.POST +import eu.kanade.tachiyomi.network.interceptor.rateLimitHost +import eu.kanade.tachiyomi.util.parallelMapBlocking +import eu.kanade.tachiyomi.util.parseAs +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.add +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonArray +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get +import uy.kohesive.injekt.injectLazy +import kotlin.time.Duration.Companion.seconds + +class Tomato : ConfigurableAnimeSource, AnimeHttpSource() { + + override val name = "Tomato" + + override val baseUrl = "https://beta-api.tomatoanimes.com" + + override val lang = "pt-BR" + + override val supportsLatest = true + + private val preferences by lazy { + Injekt.get().getSharedPreferences("source_$id", 0x0000) + } + + private val json: Json by injectLazy() + + override fun headersBuilder() = super.headersBuilder().add( + "Authorization", + "Bearer $TOKEN", + ) + + private val episodesClient by lazy { + client.newBuilder().rateLimitHost(baseUrl.toHttpUrl(), 1, 0.5.seconds).build() + } + + // ============================== Popular =============================== + override fun popularAnimeRequest(page: Int) = + GET("$baseUrl/v2/animes/feed", headers = headers) + + override fun popularAnimeParse(response: Response): AnimesPage { + val responseJson = response.parseAs() + + val emAlta = responseJson["data"]?.jsonArray?.find { + it.jsonObject["title"]?.jsonPrimitive?.content?.contains("curtidos") == true + } + + val animes = emAlta?.jsonObject?.get("data")?.jsonArray?.parallelMapBlocking { + animeFromId(it.jsonObject["anime_id"]!!.jsonPrimitive.int) + } + ?: emptyList() + + return AnimesPage(animes, false) + } + + // =============================== Latest =============================== + override fun latestUpdatesRequest(page: Int) = + GET("$baseUrl/v2/animes/feed", headers = headers) + + override fun latestUpdatesParse(response: Response): AnimesPage { + val responseJson = response.parseAs() + + val emAlta = responseJson["data"]?.jsonArray?.find { + it.jsonObject["type"]!!.jsonPrimitive.int == 7 + } + + val animes = emAlta?.jsonObject?.get("data")?.jsonArray?.parallelMapBlocking { + animeFromId(it.jsonObject["ep_anime_id"]!!.jsonPrimitive.int) + } + ?: emptyList() + + return AnimesPage(animes, false) + } + + // =============================== Search =============================== + override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request { + val params = TomatoFilters.getSearchParameters(filters) + + val data = buildJsonObject { + put("token", TOKEN) + put("search", query) + put("content_type", "anime") + put("page", page - 1) + + if (params.genres.isNotEmpty()) { + putJsonArray("tags") { + params.genres.forEach { add(it) } + } + } + } + + val body = json.encodeToString(JsonObject.serializer(), data) + .toRequestBody("application/json".toMediaType()) + + return POST("$baseUrl/v2/content/search", headers = headers, body = body) + } + + override fun searchAnimeParse(response: Response): AnimesPage { + val searchResult = response.parseAs().result + val results = searchResult.map { it.toSAnime() } + return AnimesPage(results, false) + } + + private fun SearchAnimeItemDto.toSAnime(): SAnime { + return SAnime.create().apply { + setUrlWithoutDomain("$baseUrl/v2/anime/$id") + title = name + thumbnail_url = image + } + } + + override fun getFilterList() = TomatoFilters.FILTER_LIST + + // =========================== Anime Details ============================ + private fun animeFromId(id: Int): SAnime { + val response = client.newCall( + GET("$baseUrl/v2/anime/$id", headers = headers), + ).execute() + + return animeDetailsParse(response) + } + + override fun animeDetailsParse(response: Response): SAnime { + val anime = response.parseAs() + return SAnime.create().apply { + setUrlWithoutDomain("$baseUrl/v2/anime/${anime.animeDetails.animeId}") + title = anime.animeDetails.animeName + description = anime.animeDetails.animeDescription + genre = anime.animeDetails.animeGenre + thumbnail_url = anime.animeDetails.animeCoverUrl + } + } + + // ============================== Episodes ============================== + override fun episodeListParse(response: Response): List { + val anime = response.parseAs() + + val seasons = anime.animeSeasons + + val episodeList = mutableListOf() + + seasons.forEach { season -> + var nextPage = 0 + do { + val data = buildJsonObject { + put("token", TOKEN) + put("page", nextPage) + put("order", "ASC") + } + + val body = json.encodeToString(JsonObject.serializer(), data) + .toRequestBody("application/json".toMediaType()) + + val request = POST( + "$baseUrl/season/${season.seasonId}/episodes", + headers = headers, + body = body, + ) + val episodes = + episodesClient.newCall(request).execute().parseAs().data + + episodes.forEach { episode -> + val partName = "Temporada ${season.seasonNumber} x ${episode.epNumber}" + val fullName = "$partName - ${episode.epName}" + + val prev = episodeList.find { it.name.contains(partName) } + + val newUrl = "&episode[${season.seasonDubbed}]=${episode.epId}" + if (prev != null) { + prev.url += newUrl + } else { + episodeList.add( + SEpisode.create().apply { + episode_number = episode.epNumber + name = fullName + url = "http://localhost?season=${season.seasonNumber}$newUrl" + }, + ) + } + } + + if (episodes.size == 25) nextPage += 1 else nextPage = -1 + } while (nextPage != -1) + } + + return episodeList.reversed() + } + + // ============================ Video Links ============================= + override suspend fun getVideoList(episode: SEpisode): List