New source: SlimeRead (#317)

* feat: Create SlimeRead base

* feat: Implement popular manga page

* feat: Implement latest manga page

* feat: Implement search manga page

* feat: Implement manga details page

* feat: Implement chapter list

* feat: Parse page list

* fix: Revert chapter list

* chore: Apply rate-limit in the source API

* chore: Add Origin header to API requests

* chore: Add source icon

* chore: Add isNsfw flag
This commit is contained in:
Claudemirovsky 2024-01-17 07:32:04 -03:00 committed by GitHub
parent 55636d4525
commit 8b038a9d3d
11 changed files with 473 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.slimeread.SlimeReadUrlActivity"
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="slimeread.com"
android:pathPattern="/manga/..*"
android:scheme="https" />
</intent-filter>
</activity>
</application>
</manifest>

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@ -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<List<PopularMangaDto>>()
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<LatestResponseDto>()
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<MangasPage> {
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<MangaInfoDto>()
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<SChapter> {
val items = response.parseAs<List<ChapterDto>>()
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<Page> {
val pages = response.parseAs<List<PageListDto>>().flatMap { it.pages }
return pages.mapIndexed { index, item ->
Page(index, "", item.url)
}
}
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
}
companion object {
const val PREFIX_SEARCH = "id:"
private const val API_URL = "https://ai3.slimeread.com:8443"
}
}

View File

@ -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<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
}
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 }
}
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<String> = 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<CategoriesFilter>(SlimeReadFiltersData.CATEGORIES),
filters.getSelected<GenreFilter>(),
filters.getSelected<SearchMethodFilter>(),
filters.getSelected<StatusFilter>(),
)
}
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"),
)
}
}

View File

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

View File

@ -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<PopularMangaDto>,
)
fun List<PopularMangaDto>.toSMangaList(): List<SManga> = 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<CategoryDto>,
) {
@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<PageDto>)
@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
}
}