diff --git a/src/pt/lycantoons/build.gradle b/src/pt/lycantoons/build.gradle new file mode 100644 index 000000000..bcd4abb04 --- /dev/null +++ b/src/pt/lycantoons/build.gradle @@ -0,0 +1,9 @@ +ext { + extName = 'Lycan Toons' + extClass = '.LycanToons' + baseUrl = 'https://lycantoons.com' + extVersionCode = 1 + isNsfw = false +} + +apply from: "$rootDir/common.gradle" \ No newline at end of file diff --git a/src/pt/lycantoons/res/mipmap-hdpi/ic_launcher.png b/src/pt/lycantoons/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..7c22778c2 Binary files /dev/null and b/src/pt/lycantoons/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/pt/lycantoons/res/mipmap-mdpi/ic_launcher.png b/src/pt/lycantoons/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..84bad2aa4 Binary files /dev/null and b/src/pt/lycantoons/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/pt/lycantoons/res/mipmap-xhdpi/ic_launcher.png b/src/pt/lycantoons/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..db284b819 Binary files /dev/null and b/src/pt/lycantoons/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/pt/lycantoons/res/mipmap-xxhdpi/ic_launcher.png b/src/pt/lycantoons/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..13d642c4a Binary files /dev/null and b/src/pt/lycantoons/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/pt/lycantoons/res/mipmap-xxxhdpi/ic_launcher.png b/src/pt/lycantoons/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..4bade9746 Binary files /dev/null and b/src/pt/lycantoons/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/pt/lycantoons/src/eu/kanade/tachiyomi/extension/pt/lycantoons/LycanToons.kt b/src/pt/lycantoons/src/eu/kanade/tachiyomi/extension/pt/lycantoons/LycanToons.kt new file mode 100644 index 000000000..df382135e --- /dev/null +++ b/src/pt/lycantoons/src/eu/kanade/tachiyomi/extension/pt/lycantoons/LycanToons.kt @@ -0,0 +1,157 @@ +package eu.kanade.tachiyomi.extension.pt.lycantoons + +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.POST +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 keiyoushi.utils.jsonInstance +import keiyoushi.utils.parseAs +import kotlinx.serialization.encodeToString +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import org.jsoup.Jsoup + +class LycanToons : HttpSource() { + + override val name = "Lycan Toons" + + override val baseUrl = "https://lycantoons.com" + + override val lang = "pt-BR" + + override val supportsLatest = true + + override val client = network.cloudflareClient + + private val pageHeaders by lazy { + headers.newBuilder() + .add("Referer", "$baseUrl/") + .build() + } + + // =====================Popular===================== + + override fun popularMangaRequest(page: Int): Request = metricsRequest("popular", page) + + override fun popularMangaParse(response: Response): MangasPage = + response.parseAs().data.toMangasPage() + + // =====================Latest===================== + + override fun latestUpdatesRequest(page: Int): Request = metricsRequest("recently-updated", page) + + override fun latestUpdatesParse(response: Response): MangasPage = + response.parseAs().data.toMangasPage() + + // =====================Search===================== + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + val payload = SearchRequestBody( + limit = PAGE_LIMIT, + page = page, + search = query, + seriesType = filters.valueOrEmpty(), + status = filters.valueOrEmpty(), + tags = filters.selectedTags(), + ) + + val body = json.encodeToString(payload).toRequestBody(JSON_MEDIA_TYPE) + return POST("$baseUrl/api/series", headers, body) + } + + override fun searchMangaParse(response: Response): MangasPage = + response.parseAs().series.toMangasPage() + + override fun getFilterList(): FilterList = LycanToonsFilters.get() + + // =====================Details===================== + + override fun mangaDetailsRequest(manga: SManga): Request = seriesRequest(manga.slug()) + + override fun mangaDetailsParse(response: Response): SManga { + val result = response.parseAs() + return result.toSManga() + } + + // =====================Chapters===================== + + override fun chapterListRequest(manga: SManga): Request = seriesRequest(manga.slug()) + + override fun chapterListParse(response: Response): List = + response.parseAs().let { series -> + series.capitulos!! + .map { it.toSChapter(series.slug) } + .sortedByDescending { it.chapter_number } + } + + // =====================Pages======================== + + override fun pageListRequest(chapter: SChapter): Request = GET( + "$baseUrl${chapter.url}", + pageHeaders, + ) + + override fun pageListParse(response: Response): List { + val html = response.body.string() + val (slug, chapterNumber) = response.extractSlugAndChapter() + + val dto = extractScriptData(html) + val pageCount = dto.pageCount + + val chapterPath = "$cdnUrl/$slug/$chapterNumber" + return List(pageCount) { index -> + val imageUrl = "$chapterPath/page-$index.jpg" + Page(index, imageUrl = imageUrl) + } + } + + private fun extractScriptData(html: String): PageListDto { + val document = Jsoup.parse(html) + + val scriptData = document.select("script") + .map { it.data() } + .first { it.contains("chapterData") } + + val rawJson = CHAPTER_DATA_REGEX.find(scriptData)!!.groupValues[1] + + val cleanJson = "\"$rawJson\"".parseAs() + + return cleanJson.parseAs() + } + + override fun imageUrlParse(response: Response): String = throw UnsupportedOperationException() + + // =====================Utils===================== + + private fun Response.extractSlugAndChapter(): Pair { + val segments = request.url.pathSegments + val slug = segments[1] + val chapterNumber = segments[2] + return slug to chapterNumber + } + + private fun metricsRequest(path: String, page: Int): Request = + GET("$baseUrl/api/metrics/$path?limit=$PAGE_LIMIT&page=$page", headers) + + private fun List.toMangasPage(): MangasPage = + MangasPage(map { it.toSManga() }, false) + + private fun seriesRequest(slug: String): Request = GET("$baseUrl/api/series/$slug", headers) + + private fun SManga.slug(): String = url.substringAfterLast("/") + + private val json by lazy { jsonInstance } + + companion object { + private const val PAGE_LIMIT = 13 + private val JSON_MEDIA_TYPE = "application/json".toMediaType() + private const val cdnUrl = "https://cdn.lycantoons.com/file/lycantoons" + private val CHAPTER_DATA_REGEX = """\\?"chapterData\\?"\s*:\s*(\{.*?\})""".toRegex() + } +} diff --git a/src/pt/lycantoons/src/eu/kanade/tachiyomi/extension/pt/lycantoons/LycanToonsDto.kt b/src/pt/lycantoons/src/eu/kanade/tachiyomi/extension/pt/lycantoons/LycanToonsDto.kt new file mode 100644 index 000000000..447bedcfb --- /dev/null +++ b/src/pt/lycantoons/src/eu/kanade/tachiyomi/extension/pt/lycantoons/LycanToonsDto.kt @@ -0,0 +1,101 @@ +package eu.kanade.tachiyomi.extension.pt.lycantoons + +import eu.kanade.tachiyomi.source.model.SChapter +import eu.kanade.tachiyomi.source.model.SManga +import keiyoushi.utils.tryParse +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.jsonPrimitive +import java.text.SimpleDateFormat +import java.util.Locale +import java.util.TimeZone + +@Serializable +data class PopularResponse( + val data: List, + val pagination: PaginationDto? = null, +) + +@Serializable +data class SeriesDto( + val title: String, + val slug: String, + val coverUrl: String? = null, + val author: String? = null, + val artist: String? = null, + val description: String? = null, + val genre: List? = null, + val status: String? = null, + val seriesType: String? = null, + val capitulos: List? = null, +) + +@Serializable +data class PaginationDto( + val page: Int? = null, + val totalPages: Int? = null, + val hasNext: Boolean? = null, +) + +@Serializable +data class SearchRequestBody( + val limit: Int, + val page: Int, + val search: String, + val seriesType: String, + val status: String, + val tags: List, +) + +@Serializable +data class ChapterDto( + val id: Int, + val numero: JsonElement, + val createdAt: String? = null, + val coverUrl: String? = null, + val capaUrl: String? = null, + val pageCount: Int? = null, +) + +@Serializable +data class PageListDto( + val numero: JsonElement, + val pageCount: Int, +) + +@Serializable +data class SearchResponse( + val series: List, +) + +fun SeriesDto.toSManga(): SManga = SManga.create().apply { + title = this@toSManga.title + url = "/series/$slug" + thumbnail_url = coverUrl + author = this@toSManga.author?.takeIf { it.isNotBlank() } + artist = this@toSManga.artist?.takeIf { it.isNotBlank() } + genre = this@toSManga.genre?.takeIf { it.isNotEmpty() }?.joinToString() + description = this@toSManga.description + status = parseStatus(this@toSManga.status) +} + +fun ChapterDto.toSChapter(slug: String): SChapter = SChapter.create().apply { + val numberString = numero.jsonPrimitive.content + name = "Capítulo $numberString" + val pagesQuery = pageCount?.let { "?pages=$it" }.orEmpty() + url = "/series/$slug/$numberString$pagesQuery" + date_upload = dateFormat.tryParse(createdAt) + chapter_number = numberString.toFloatOrNull() ?: -1f +} + +private fun parseStatus(status: String?): Int = when (status) { + "ONGOING" -> SManga.ONGOING + "COMPLETED" -> SManga.COMPLETED + "HIATUS" -> SManga.ON_HIATUS + "CANCELLED" -> SManga.CANCELLED + else -> SManga.UNKNOWN +} + +private val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.ROOT).apply { + timeZone = TimeZone.getTimeZone("UTC") +} diff --git a/src/pt/lycantoons/src/eu/kanade/tachiyomi/extension/pt/lycantoons/LycanToonsFilters.kt b/src/pt/lycantoons/src/eu/kanade/tachiyomi/extension/pt/lycantoons/LycanToonsFilters.kt new file mode 100644 index 000000000..3459f731a --- /dev/null +++ b/src/pt/lycantoons/src/eu/kanade/tachiyomi/extension/pt/lycantoons/LycanToonsFilters.kt @@ -0,0 +1,83 @@ +package eu.kanade.tachiyomi.extension.pt.lycantoons + +import eu.kanade.tachiyomi.source.model.Filter +import eu.kanade.tachiyomi.source.model.FilterList + +class SeriesTypeFilter : ChoiceFilter( + "Tipo", + arrayOf( + "" to "Todos", + "MANGA" to "Mangá", + "MANHWA" to "Manhwa", + "MANHUA" to "Manhua", + "COMIC" to "Comic", + "WEBTOON" to "Webtoon", + ), +) + +class StatusFilter : ChoiceFilter( + "Status", + arrayOf( + "" to "Todos", + "ONGOING" to "Em andamento", + "COMPLETED" to "Completo", + "HIATUS" to "Hiato", + "CANCELLED" to "Cancelado", + ), +) + +open class ChoiceFilter( + name: String, + private val entries: Array>, +) : Filter.Select( + name, + entries.map { it.second }.toTypedArray(), +) { + fun getValue(): String = entries[state].first +} + +class TagsFilter : Filter.Group( + "Tags", + listOf( + TagCheckBox("Ação", "action"), + TagCheckBox("Aventura", "adventure"), + TagCheckBox("Comédia", "comedy"), + TagCheckBox("Drama", "drama"), + TagCheckBox("Fantasia", "fantasy"), + TagCheckBox("Terror", "horror"), + TagCheckBox("Mistério", "mystery"), + TagCheckBox("Romance", "romance"), + TagCheckBox("Vida escolar", "school_life"), + TagCheckBox("Sci-fi", "sci_fi"), + TagCheckBox("Slice of life", "slice_of_life"), + TagCheckBox("Esportes", "sports"), + TagCheckBox("Sobrenatural", "supernatural"), + TagCheckBox("Thriller", "thriller"), + TagCheckBox("Tragédia", "tragedy"), + ), +) + +class TagCheckBox( + name: String, + val value: String, +) : Filter.CheckBox(name) + +inline fun > FilterList.find(): T? = + this.filterIsInstance().firstOrNull() + +inline fun FilterList.valueOrEmpty(): String = + find()?.getValue().orEmpty() + +fun FilterList.selectedTags(): List = + find()?.state + ?.filter { it.state } + ?.map { it.value } + .orEmpty() + +object LycanToonsFilters { + fun get(): FilterList = FilterList( + SeriesTypeFilter(), + StatusFilter(), + TagsFilter(), + ) +} diff --git a/src/pt/windscan/build.gradle b/src/pt/windscan/build.gradle deleted file mode 100644 index 6c490b2f9..000000000 --- a/src/pt/windscan/build.gradle +++ /dev/null @@ -1,10 +0,0 @@ -ext { - extName = 'Wind Scan' - extClass = '.WindScan' - themePkg = 'greenshit' - baseUrl = 'https://windscan.xyz' - overrideVersionCode = 41 - isNsfw = true -} - -apply from: "$rootDir/common.gradle" diff --git a/src/pt/windscan/res/mipmap-hdpi/ic_launcher.png b/src/pt/windscan/res/mipmap-hdpi/ic_launcher.png deleted file mode 100644 index a81888740..000000000 Binary files a/src/pt/windscan/res/mipmap-hdpi/ic_launcher.png and /dev/null differ diff --git a/src/pt/windscan/res/mipmap-mdpi/ic_launcher.png b/src/pt/windscan/res/mipmap-mdpi/ic_launcher.png deleted file mode 100644 index e38ca436f..000000000 Binary files a/src/pt/windscan/res/mipmap-mdpi/ic_launcher.png and /dev/null differ diff --git a/src/pt/windscan/res/mipmap-xhdpi/ic_launcher.png b/src/pt/windscan/res/mipmap-xhdpi/ic_launcher.png deleted file mode 100644 index 7b6f047a5..000000000 Binary files a/src/pt/windscan/res/mipmap-xhdpi/ic_launcher.png and /dev/null differ diff --git a/src/pt/windscan/res/mipmap-xxhdpi/ic_launcher.png b/src/pt/windscan/res/mipmap-xxhdpi/ic_launcher.png deleted file mode 100644 index 2047a87f3..000000000 Binary files a/src/pt/windscan/res/mipmap-xxhdpi/ic_launcher.png and /dev/null differ diff --git a/src/pt/windscan/res/mipmap-xxxhdpi/ic_launcher.png b/src/pt/windscan/res/mipmap-xxxhdpi/ic_launcher.png deleted file mode 100644 index 49fa79936..000000000 Binary files a/src/pt/windscan/res/mipmap-xxxhdpi/ic_launcher.png and /dev/null differ diff --git a/src/pt/windscan/src/eu/kanade/tachiyomi/extension/pt/windscan/WindScan.kt b/src/pt/windscan/src/eu/kanade/tachiyomi/extension/pt/windscan/WindScan.kt deleted file mode 100644 index bc0bfa386..000000000 --- a/src/pt/windscan/src/eu/kanade/tachiyomi/extension/pt/windscan/WindScan.kt +++ /dev/null @@ -1,18 +0,0 @@ -package eu.kanade.tachiyomi.extension.pt.windscan - -import eu.kanade.tachiyomi.multisrc.greenshit.GreenShit -import eu.kanade.tachiyomi.network.interceptor.rateLimit - -class WindScan : GreenShit( - "Wind Scan", - "https://windscan.xyz", - "pt-BR", - scanId = 6, -) { - // Moved from Madara to GreenShit - override val versionId = 2 - - override val client = super.client.newBuilder() - .rateLimit(2) - .build() -}