diff --git a/src/en/rizzcomic/AndroidManifest.xml b/src/en/rizzcomic/AndroidManifest.xml new file mode 100644 index 000000000..568741e54 --- /dev/null +++ b/src/en/rizzcomic/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/src/en/rizzcomic/build.gradle b/src/en/rizzcomic/build.gradle new file mode 100644 index 000000000..dc8a6e4e3 --- /dev/null +++ b/src/en/rizzcomic/build.gradle @@ -0,0 +1,12 @@ +apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' +apply plugin: 'kotlinx-serialization' + +ext { + extName = 'Rizz Comic' + pkgNameSuffix = 'en.rizzcomic' + extClass = '.RizzComic' + extVersionCode = 1 +} + +apply from: "$rootDir/common.gradle" \ No newline at end of file diff --git a/src/en/rizzcomic/res/mipmap-hdpi/ic_launcher.png b/src/en/rizzcomic/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..3240c622c Binary files /dev/null and b/src/en/rizzcomic/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/en/rizzcomic/res/mipmap-mdpi/ic_launcher.png b/src/en/rizzcomic/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..8be812241 Binary files /dev/null and b/src/en/rizzcomic/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/en/rizzcomic/res/mipmap-xhdpi/ic_launcher.png b/src/en/rizzcomic/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..eb6609980 Binary files /dev/null and b/src/en/rizzcomic/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/en/rizzcomic/res/mipmap-xxhdpi/ic_launcher.png b/src/en/rizzcomic/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..6a2e222eb Binary files /dev/null and b/src/en/rizzcomic/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/en/rizzcomic/res/mipmap-xxxhdpi/ic_launcher.png b/src/en/rizzcomic/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..c99d7c610 Binary files /dev/null and b/src/en/rizzcomic/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/en/rizzcomic/res/web_hi_res_512.png b/src/en/rizzcomic/res/web_hi_res_512.png new file mode 100644 index 000000000..a9e73db42 Binary files /dev/null and b/src/en/rizzcomic/res/web_hi_res_512.png differ diff --git a/src/en/rizzcomic/src/eu/kanade/tachiyomi/extension/en/rizzcomic/RizzComic.kt b/src/en/rizzcomic/src/eu/kanade/tachiyomi/extension/en/rizzcomic/RizzComic.kt new file mode 100644 index 000000000..f9ac3a6a9 --- /dev/null +++ b/src/en/rizzcomic/src/eu/kanade/tachiyomi/extension/en/rizzcomic/RizzComic.kt @@ -0,0 +1,281 @@ +package eu.kanade.tachiyomi.extension.en.rizzcomic + +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.POST +import eu.kanade.tachiyomi.network.asObservableSuccess +import eu.kanade.tachiyomi.network.interceptor.rateLimit +import eu.kanade.tachiyomi.source.model.Filter +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 eu.kanade.tachiyomi.util.asJsoup +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import okhttp3.FormBody +import okhttp3.Request +import okhttp3.Response +import rx.Observable +import uy.kohesive.injekt.injectLazy +import java.text.SimpleDateFormat +import java.util.Locale + +class RizzComic : HttpSource() { + + override val name = "Rizz Comic" + + override val lang = "en" + + override val baseUrl = "https://rizzcomic.com" + + override val supportsLatest = true + + private val json: Json by injectLazy() + + override val client = network.cloudflareClient.newBuilder() + .rateLimit(1) + .build() + + override fun headersBuilder() = super.headersBuilder() + .set("Referer", "$baseUrl/") + + private val apiHeaders by lazy { + headersBuilder() + .set("X-Requested-With", "XMLHttpRequest") + .build() + } + + private var urlPrefix: String? = null + private var genreCache: List> = emptyList() + private var attempts = 0 + + private fun updateCache() { + if ((urlPrefix.isNullOrEmpty() || genreCache.isEmpty()) && attempts < 3) { + runCatching { + val document = client.newCall(GET("$baseUrl/series", headers)) + .execute().use { it.asJsoup() } + + urlPrefix = document.selectFirst(".listupd a") + ?.attr("href") + ?.substringAfter("/series/") + ?.substringBefore("-") + + genreCache = document.selectFirst(".filter .genrez") + ?.select("li") + .orEmpty() + .map { + val name = it.select("label").text() + val id = it.select("input").attr("value") + + Pair(name, id) + } + } + + attempts++ + } + } + + private fun getUrlPrefix(): String { + if (urlPrefix.isNullOrEmpty()) { + updateCache() + } + + return urlPrefix!! + } + + override fun popularMangaRequest(page: Int) = searchMangaRequest(page, "", SortFilter.POPULAR) + override fun popularMangaParse(response: Response) = searchMangaParse(response) + + override fun latestUpdatesRequest(page: Int) = searchMangaRequest(page, "", SortFilter.LATEST) + override fun latestUpdatesParse(response: Response) = searchMangaParse(response) + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + if (query.isNotEmpty()) { + val form = FormBody.Builder() + .add("search_value", query.trim()) + .build() + + return POST("$baseUrl/Index/live_search", apiHeaders, form) + } + + val form = FormBody.Builder().apply { + filters.filterIsInstance().forEach { + it.addFormParameter(this) + } + }.build() + + return POST("$baseUrl/Index/filter_series", apiHeaders, form) + } + + override fun getFilterList(): FilterList { + val filters: MutableList> = mutableListOf( + Filter.Header("Filters don't work with text search"), + SortFilter(), + StatusFilter(), + TypeFilter(), + ) + + filters += if (genreCache.isEmpty()) { + listOf( + Filter.Separator(), + Filter.Header("Press reset to attempt to load genres"), + ) + } else { + listOf( + GenreFilter(genreCache), + ) + } + + return FilterList(filters) + } + + override fun searchMangaParse(response: Response): MangasPage { + updateCache() + + val result = response.parseAs>() + + val entries = result.map { comic -> + SManga.create().apply { + url = "${comic.slug}#${comic.id}" + title = comic.title + description = comic.synopsis + author = listOfNotNull(comic.author, comic.serialization).joinToString() + artist = comic.artist + status = comic.status.parseStatus() + thumbnail_url = comic.cover?.let { "$baseUrl/assets/images/$it" } + genre = buildList { + add(comic.type?.capitalize()) + comic.genreIds?.onEach { gId -> + add(genreCache.firstOrNull { it.second == gId }?.first) + } + }.filterNotNull().joinToString() + initialized = true + } + } + + return MangasPage(entries, false) + } + + override fun fetchMangaDetails(manga: SManga): Observable { + return client.newCall(mangaDetailsRequest(manga)) + .asObservableSuccess() + .map { mangaDetailsParse(it, manga) } + } + + override fun mangaDetailsRequest(manga: SManga): Request { + val slug = manga.url.substringBefore("#") + val randomPart = getUrlPrefix() + + return GET("$baseUrl/series/$randomPart-$slug", headers) + } + + override fun getMangaUrl(manga: SManga): String { + val slug = manga.url.substringBefore("#") + + val urlPart = urlPrefix?.let { "$it-" } ?: "" + + return "$baseUrl/series/$urlPart$slug" + } + + private fun mangaDetailsParse(response: Response, manga: SManga) = manga.apply { + val document = response.use { it.asJsoup() } + + title = document.selectFirst("h1.entry-title")?.text().orEmpty() + artist = document.selectFirst(".tsinfo .imptdt:contains(artist) i")?.ownText() + author = listOfNotNull( + document.selectFirst(".tsinfo .imptdt:contains(author) i")?.ownText(), + document.selectFirst(".tsinfo .imptdt:contains(serialization) i")?.ownText(), + ).joinToString() + genre = buildList { + add( + document.selectFirst(".tsinfo .imptdt:contains(type) a") + ?.ownText() + ?.capitalize(), + ) + document.select(".mgen a").eachText().onEach { add(it) } + }.filterNotNull().joinToString() + status = document.selectFirst(".tsinfo .imptdt:contains(status) i")?.text().parseStatus() + thumbnail_url = document.selectFirst(".infomanga > div[itemprop=image] img, .thumb img")?.absUrl("src") + } + + private fun String?.parseStatus(): Int = when { + this == null -> SManga.UNKNOWN + listOf("ongoing", "publishing").any { contains(it, ignoreCase = true) } -> SManga.ONGOING + contains("hiatus", ignoreCase = true) -> SManga.ON_HIATUS + contains("completed", ignoreCase = true) -> SManga.COMPLETED + listOf("dropped", "cancelled").any { contains(it, ignoreCase = true) } -> SManga.CANCELLED + else -> SManga.UNKNOWN + } + + override fun chapterListRequest(manga: SManga): Request { + val id = manga.url.substringAfter("#") + val slug = manga.url.substringBefore("#") + + return GET("$baseUrl/index/search_chapters/$id#$slug", apiHeaders) + } + + override fun chapterListParse(response: Response): List { + val result = response.parseAs>() + val slug = response.request.url.fragment!! + + return result.map { + SChapter.create().apply { + url = "$slug-chapter-${it.name}" + name = "Chapter ${it.name}" + date_upload = runCatching { + dateFormat.parse(it.time!!)!!.time + }.getOrDefault(0L) + } + } + } + + override fun pageListRequest(chapter: SChapter): Request { + return GET("$baseUrl/chapter/${getUrlPrefix()}-${chapter.url}", headers) + } + + override fun pageListParse(response: Response): List { + val document = response.use { it.asJsoup() } + val chapterUrl = response.request.url.toString() + + return document.select("div#readerarea img") + .mapIndexed { i, img -> + Page(i, chapterUrl, img.absUrl("src")) + } + } + + override fun imageRequest(page: Page): Request { + val newHeaders = headersBuilder() + .set("Accept", "image/avif,image/webp,image/png,image/jpeg,*/*") + .set("Referer", page.url) + .build() + + return GET(page.imageUrl!!, newHeaders) + } + + override fun mangaDetailsParse(response: Response): SManga { + throw UnsupportedOperationException("Not Used") + } + + override fun imageUrlParse(response: Response): String { + throw UnsupportedOperationException("Not Used") + } + + private inline fun Response.parseAs(): T = + use { it.body.string() }.let(json::decodeFromString) + + companion object { + private fun String.capitalize() = replaceFirstChar { + if (it.isLowerCase()) { + it.titlecase(Locale.ROOT) + } else { + it.toString() + } + } + + private val dateFormat by lazy { + SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.ENGLISH) + } + } +} diff --git a/src/en/rizzcomic/src/eu/kanade/tachiyomi/extension/en/rizzcomic/RizzComicDto.kt b/src/en/rizzcomic/src/eu/kanade/tachiyomi/extension/en/rizzcomic/RizzComicDto.kt new file mode 100644 index 000000000..f20d7454f --- /dev/null +++ b/src/en/rizzcomic/src/eu/kanade/tachiyomi/extension/en/rizzcomic/RizzComicDto.kt @@ -0,0 +1,35 @@ +package eu.kanade.tachiyomi.extension.en.rizzcomic + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class Comic( + val id: Int, + val title: String, + @SerialName("image_url") val cover: String? = null, + @SerialName("long_description") val synopsis: String? = null, + val status: String? = null, + val type: String? = null, + val artist: String? = null, + val author: String? = null, + val serialization: String? = null, + @SerialName("genre_id") val genres: String? = null, +) { + val slug get() = title.trim().lowercase() + .replace(slugRegex, "-") + .replace("-s-", "s-") + .replace("-ll-", "ll-") + + val genreIds get() = genres?.split(",")?.map(String::trim) + + companion object { + private val slugRegex = Regex("""[^a-z0-9]+""") + } +} + +@Serializable +data class Chapter( + @SerialName("chapter_time") val time: String? = null, + @SerialName("chapter_title") val name: String, +) diff --git a/src/en/rizzcomic/src/eu/kanade/tachiyomi/extension/en/rizzcomic/RizzComicFilters.kt b/src/en/rizzcomic/src/eu/kanade/tachiyomi/extension/en/rizzcomic/RizzComicFilters.kt new file mode 100644 index 000000000..fd9426d51 --- /dev/null +++ b/src/en/rizzcomic/src/eu/kanade/tachiyomi/extension/en/rizzcomic/RizzComicFilters.kt @@ -0,0 +1,84 @@ +package eu.kanade.tachiyomi.extension.en.rizzcomic + +import eu.kanade.tachiyomi.source.model.Filter +import eu.kanade.tachiyomi.source.model.FilterList +import okhttp3.FormBody + +interface FormBodyFilter { + fun addFormParameter(form: FormBody.Builder) +} + +abstract class SelectFilter( + name: String, + private val options: List>, + defaultValue: String? = null, +) : FormBodyFilter, Filter.Select( + name, + options.map { it.first }.toTypedArray(), + options.indexOfFirst { it.second == defaultValue }.takeIf { it != -1 } ?: 0, +) { + abstract val formParameter: String + override fun addFormParameter(form: FormBody.Builder) { + form.add(formParameter, options[state].second) + } +} + +class SortFilter(defaultOrder: String? = null) : SelectFilter("Sort By", sort, defaultOrder) { + override val formParameter = "OrderValue" + companion object { + private val sort = listOf( + Pair("Default", "all"), + Pair("A-Z", "title"), + Pair("Z-A", "titlereverse"), + Pair("Latest Update", "update"), + Pair("Latest Added", "latest"), + Pair("Popular", "popular"), + ) + + val POPULAR = FilterList(StatusFilter(), TypeFilter(), SortFilter("popular")) + val LATEST = FilterList(StatusFilter(), TypeFilter(), SortFilter("update")) + } +} + +class StatusFilter : SelectFilter("Status", status) { + override val formParameter = "StatusValue" + companion object { + private val status = listOf( + Pair("All", "all"), + Pair("Ongoing", "ongoing"), + Pair("Complete", "completed"), + Pair("Hiatus", "hiatus"), + ) + } +} + +class TypeFilter : SelectFilter("Type", type) { + override val formParameter = "TypeValue" + companion object { + private val type = listOf( + Pair("All", "all"), + Pair("Manga", "Manga"), + Pair("Manhwa", "Manhwa"), + Pair("Manhua", "Manhua"), + Pair("Comic", "Comic"), + ) + } +} + +class CheckBoxFilter( + name: String, + val value: String, +) : Filter.CheckBox(name) + +class GenreFilter( + genres: List>, +) : FormBodyFilter, Filter.Group( + "Genre", + genres.map { CheckBoxFilter(it.first, it.second) }, +) { + override fun addFormParameter(form: FormBody.Builder) { + state.filter { it.state }.forEach { + form.add("genres_checked[]", it.value) + } + } +}