diff --git a/src/pt/slimeread/AndroidManifest.xml b/src/pt/slimeread/AndroidManifest.xml new file mode 100644 index 000000000..d91361ff4 --- /dev/null +++ b/src/pt/slimeread/AndroidManifest.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + diff --git a/src/pt/slimeread/build.gradle b/src/pt/slimeread/build.gradle new file mode 100644 index 000000000..7ef2001f4 --- /dev/null +++ b/src/pt/slimeread/build.gradle @@ -0,0 +1,8 @@ +ext { + extName = 'SlimeRead' + extClass = '.SlimeRead' + extVersionCode = 1 + isNsfw = true +} + +apply from: "$rootDir/common.gradle" diff --git a/src/pt/slimeread/res/mipmap-hdpi/ic_launcher.png b/src/pt/slimeread/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..a20519904 Binary files /dev/null and b/src/pt/slimeread/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/pt/slimeread/res/mipmap-mdpi/ic_launcher.png b/src/pt/slimeread/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..841ef2e73 Binary files /dev/null and b/src/pt/slimeread/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/pt/slimeread/res/mipmap-xhdpi/ic_launcher.png b/src/pt/slimeread/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..9034642e8 Binary files /dev/null and b/src/pt/slimeread/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/pt/slimeread/res/mipmap-xxhdpi/ic_launcher.png b/src/pt/slimeread/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..919a41edd Binary files /dev/null and b/src/pt/slimeread/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/pt/slimeread/res/mipmap-xxxhdpi/ic_launcher.png b/src/pt/slimeread/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..7ec1182dd Binary files /dev/null and b/src/pt/slimeread/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/pt/slimeread/src/eu/kanade/tachiyomi/extension/pt/slimeread/SlimeRead.kt b/src/pt/slimeread/src/eu/kanade/tachiyomi/extension/pt/slimeread/SlimeRead.kt new file mode 100644 index 000000000..8e6105a5a --- /dev/null +++ b/src/pt/slimeread/src/eu/kanade/tachiyomi/extension/pt/slimeread/SlimeRead.kt @@ -0,0 +1,189 @@ +package eu.kanade.tachiyomi.extension.pt.slimeread + +import eu.kanade.tachiyomi.extension.pt.slimeread.dto.ChapterDto +import eu.kanade.tachiyomi.extension.pt.slimeread.dto.LatestResponseDto +import eu.kanade.tachiyomi.extension.pt.slimeread.dto.MangaInfoDto +import eu.kanade.tachiyomi.extension.pt.slimeread.dto.PageListDto +import eu.kanade.tachiyomi.extension.pt.slimeread.dto.PopularMangaDto +import eu.kanade.tachiyomi.extension.pt.slimeread.dto.toSMangaList +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.Request +import okhttp3.Response +import rx.Observable +import uy.kohesive.injekt.injectLazy + +class SlimeRead : HttpSource() { + + override val name = "SlimeRead" + + override val baseUrl = "https://slimeread.com" + + override val lang = "pt-BR" + + override val supportsLatest = true + + override val client by lazy { + network.client.newBuilder() + .rateLimitHost(baseUrl.toHttpUrl(), 2) + .rateLimitHost(API_URL.toHttpUrl(), 1) + .build() + } + + override fun headersBuilder() = super.headersBuilder().add("Origin", baseUrl) + + private val json: Json by injectLazy() + + // ============================== Popular =============================== + override fun popularMangaRequest(page: Int) = GET("$API_URL/ranking/semana?nsfw=false", headers) + + override fun popularMangaParse(response: Response): MangasPage { + val items = response.parseAs>() + val mangaList = items.toSMangaList() + return MangasPage(mangaList, false) + } + + // =============================== Latest =============================== + override fun latestUpdatesRequest(page: Int) = GET("$API_URL/books?page=$page", headers) + + override fun latestUpdatesParse(response: Response): MangasPage { + val dto = response.parseAs() + val mangaList = dto.data.toSMangaList() + val hasNextPage = dto.page < dto.pages + return MangasPage(mangaList, hasNextPage) + } + + // =============================== 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/book/$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() = SlimeReadFilters.FILTER_LIST + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + val params = SlimeReadFilters.getSearchParameters(filters) + + val url = "$API_URL/book_search".toHttpUrl().newBuilder() + .addIfNotBlank("query", query) + .addIfNotBlank("genre[]", params.genre) + .addIfNotBlank("status", params.status) + .addIfNotBlank("searchMethod", params.searchMethod) + .apply { + params.categories.forEach { + addQueryParameter("categories[]", it) + } + }.build() + + return GET(url, headers) + } + + override fun searchMangaParse(response: Response) = popularMangaParse(response) + + // =========================== Manga Details ============================ + override fun getMangaUrl(manga: SManga) = baseUrl + manga.url.replace("/book/", "/manga/") + + override fun mangaDetailsRequest(manga: SManga) = GET(API_URL + manga.url, headers) + + override fun mangaDetailsParse(response: Response) = SManga.create().apply { + val info = response.parseAs() + thumbnail_url = info.thumbnail_url + title = info.name + description = info.description + genre = info.categories.joinToString() + status = when (info.status) { + 1 -> SManga.ONGOING + 2 -> SManga.COMPLETED + 3, 4 -> SManga.CANCELLED + 5 -> SManga.ON_HIATUS + else -> SManga.UNKNOWN + } + } + + // ============================== Chapters ============================== + override fun chapterListRequest(manga: SManga) = + GET("$API_URL/book_cap_units_all?manga_id=${manga.url.substringAfterLast("/")}", headers) + + override fun chapterListParse(response: Response): List { + val items = response.parseAs>() + val mangaId = response.request.url.queryParameter("manga_id")!! + return items.map { + SChapter.create().apply { + name = "Cap " + parseChapterNumber(it.number) + chapter_number = it.number + scanlator = it.scan?.scan_name + url = "/book_cap_units?manga_id=$mangaId&cap=${it.number}" + } + }.reversed() + } + + private fun parseChapterNumber(number: Float): String { + val cap = number + 1F + val num = "%.2f".format(cap) + .let { if (cap < 10F) "0$it" else it } + .replace(",00", "") + .replace(",", ".") + return num + } + + override fun getChapterUrl(chapter: SChapter): String { + val url = "$baseUrl${chapter.url}".toHttpUrl() + val id = url.queryParameter("manga_id")!! + val cap = url.queryParameter("cap")!!.toFloat() + val num = parseChapterNumber(cap) + return "$baseUrl/ler/$id/cap-$num" + } + + // =============================== Pages ================================ + override fun pageListRequest(chapter: SChapter) = GET(API_URL + chapter.url, headers) + + override fun pageListParse(response: Response): List { + val pages = response.parseAs>().flatMap { it.pages } + + return pages.mapIndexed { index, item -> + Page(index, "", item.url) + } + } + + 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 + } + + companion object { + const val PREFIX_SEARCH = "id:" + + private const val API_URL = "https://ai3.slimeread.com:8443" + } +} diff --git a/src/pt/slimeread/src/eu/kanade/tachiyomi/extension/pt/slimeread/SlimeReadFilters.kt b/src/pt/slimeread/src/eu/kanade/tachiyomi/extension/pt/slimeread/SlimeReadFilters.kt new file mode 100644 index 000000000..019bf954d --- /dev/null +++ b/src/pt/slimeread/src/eu/kanade/tachiyomi/extension/pt/slimeread/SlimeReadFilters.kt @@ -0,0 +1,141 @@ +package eu.kanade.tachiyomi.extension.pt.slimeread + +import eu.kanade.tachiyomi.source.model.Filter +import eu.kanade.tachiyomi.source.model.FilterList + +object SlimeReadFilters { + 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 + } + + 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 } + } + + internal class CategoriesFilter : CheckBoxFilterList("Categorias", SlimeReadFiltersData.CATEGORIES) + + internal class GenreFilter : SelectFilter("Gênero", SlimeReadFiltersData.GENRES) + internal class SearchMethodFilter : SelectFilter("Método de busca", SlimeReadFiltersData.SEARCH_METHODS) + internal class StatusFilter : SelectFilter("Status", SlimeReadFiltersData.STATUS) + + val FILTER_LIST get() = FilterList( + CategoriesFilter(), + GenreFilter(), + SearchMethodFilter(), + StatusFilter(), + ) + + data class FilterSearchParams( + val categories: Sequence = emptySequence(), + val genre: String = "", + val searchMethod: String = "", + val status: String = "", + ) + + internal fun getSearchParameters(filters: FilterList): FilterSearchParams { + if (filters.isEmpty()) return FilterSearchParams() + + return FilterSearchParams( + filters.parseCheckbox(SlimeReadFiltersData.CATEGORIES), + filters.getSelected(), + filters.getSelected(), + filters.getSelected(), + ) + } + + private object SlimeReadFiltersData { + val CATEGORIES = arrayOf( + Pair("Adulto", "125"), + Pair("Artes Marciais", "117"), + Pair("Avant Garde", "154"), + Pair("Aventura", "112"), + Pair("Ação", "146"), + Pair("Comédia", "147"), + Pair("Culinária", "126"), + Pair("Doujinshi", "113"), + Pair("Drama", "148"), + Pair("Ecchi", "127"), + Pair("Erotico", "152"), + Pair("Esporte", "135"), + Pair("Fantasia", "114"), + Pair("Ficção Científica", "120"), + Pair("Filosofico", "150"), + Pair("Harém", "128"), + Pair("Histórico", "115"), + Pair("Isekai", "129"), + Pair("Josei", "116"), + Pair("Mecha", "130"), + Pair("Militar", "149"), + Pair("Mistério", "142"), + Pair("Médico", "118"), + Pair("One-shot", "131"), + Pair("Premiado", "155"), + Pair("Psicológico", "119"), + Pair("Romance", "141"), + Pair("Seinen", "140"), + Pair("Shoujo", "133"), + Pair("Shoujo-ai", "121"), + Pair("Shounen", "139"), + Pair("Shounen-ai", "134"), + Pair("Slice-of-life", "122"), + Pair("Sobrenatural", "123"), + Pair("Sugestivo", "153"), + Pair("Terror", "144"), + Pair("Thriller", "151"), + Pair("Tragédia", "137"), + Pair("Vida Escolar", "132"), + Pair("Yaoi", "124"), + Pair("Yuri", "136"), + ) + + private val SELECT = Pair("Selecione", "") + + val GENRES = arrayOf( + SELECT, + Pair("Manga", "29"), + Pair("Light Novel", "34"), + Pair("Manhua", "31"), + Pair("Manhwa", "30"), + Pair("Novel", "33"), + Pair("Webcomic", "35"), + Pair("Webnovel", "36"), + Pair("Webtoon", "32"), + Pair("4-Koma", "37"), + ) + + val SEARCH_METHODS = arrayOf( + SELECT, + Pair("Preciso", "0"), + Pair("Geral", "1"), + ) + + val STATUS = arrayOf( + SELECT, + Pair("Em andamento", "1"), + Pair("Completo", "2"), + Pair("Dropado", "3"), + Pair("Cancelado", "4"), + Pair("Hiato", "5"), + ) + } +} diff --git a/src/pt/slimeread/src/eu/kanade/tachiyomi/extension/pt/slimeread/SlimeReadUrlActivity.kt b/src/pt/slimeread/src/eu/kanade/tachiyomi/extension/pt/slimeread/SlimeReadUrlActivity.kt new file mode 100644 index 000000000..3e1241c75 --- /dev/null +++ b/src/pt/slimeread/src/eu/kanade/tachiyomi/extension/pt/slimeread/SlimeReadUrlActivity.kt @@ -0,0 +1,41 @@ +package eu.kanade.tachiyomi.extension.pt.slimeread + +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://slimeread.com/manga// intents + * and redirects them to the main Tachiyomi process. + */ +class SlimeReadUrlActivity : 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", "${SlimeRead.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/slimeread/src/eu/kanade/tachiyomi/extension/pt/slimeread/dto/SlimeReadDto.kt b/src/pt/slimeread/src/eu/kanade/tachiyomi/extension/pt/slimeread/dto/SlimeReadDto.kt new file mode 100644 index 000000000..17cebaa73 --- /dev/null +++ b/src/pt/slimeread/src/eu/kanade/tachiyomi/extension/pt/slimeread/dto/SlimeReadDto.kt @@ -0,0 +1,72 @@ +package eu.kanade.tachiyomi.extension.pt.slimeread.dto + +import eu.kanade.tachiyomi.source.model.SManga +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class PopularMangaDto( + @SerialName("book_image") val thumbnail_url: String?, + @SerialName("book_id") val id: Int, + @SerialName("book_name_original") val name: String, +) + +@Serializable +data class LatestResponseDto( + val pages: Int, + val page: Int, + val data: List, +) + +fun List.toSMangaList(): List = map { item -> + SManga.create().apply { + thumbnail_url = item.thumbnail_url + title = item.name + url = "/book/${item.id}" + } +} + +@Serializable +data class MangaInfoDto( + @SerialName("book_image") val thumbnail_url: String?, + @SerialName("book_name_original") val name: String, + @SerialName("book_status") val status: Int, + @SerialName("book_synopsis") val description: String?, + @SerialName("book_categories") private val _categories: List, +) { + @Serializable + data class CategoryDto(val categories: CatDto) + + @Serializable + data class CatDto(@SerialName("cat_name_ptBR") val name: String) + + val categories = _categories.map { it.categories.name } +} + +@Serializable +data class ChapterDto( + @SerialName("btc_cap") val number: Float, + val scan: ScanDto?, +) { + @Serializable + data class ScanDto(val scan_name: String?) +} + +@Serializable +data class PageListDto(@SerialName("book_temp_cap_unit") val pages: List) + +@Serializable +data class PageDto( + @SerialName("btcu_image") private val path: String, + @SerialName("btcu_provider_host") private val hostId: Int, +) { + val url by lazy { + val baseUrl = when (hostId) { + 2 -> "https://cdn.slimeread.com/" + 5 -> "https://black.slimeread.com/" + else -> "https://objects.slimeread.com/" + } + + baseUrl + path + } +}