diff --git a/src/en/arvenscans/build.gradle b/src/en/arvenscans/build.gradle index 98d02e4d0..a9b69b34e 100644 --- a/src/en/arvenscans/build.gradle +++ b/src/en/arvenscans/build.gradle @@ -1,9 +1,7 @@ ext { - extName = 'Arven Scans' - extClass = '.ArvenScans' - themePkg = 'mangathemesia' - baseUrl = 'https://arvenscans.com' - overrideVersionCode = 0 + extName = 'Vortex Scans' + extClass = '.VortexScans' + extVersionCode = 31 } apply from: "$rootDir/common.gradle" diff --git a/src/en/arvenscans/res/mipmap-hdpi/ic_launcher.png b/src/en/arvenscans/res/mipmap-hdpi/ic_launcher.png index ba568b04b..ec16cf9ee 100644 Binary files a/src/en/arvenscans/res/mipmap-hdpi/ic_launcher.png and b/src/en/arvenscans/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/en/arvenscans/res/mipmap-mdpi/ic_launcher.png b/src/en/arvenscans/res/mipmap-mdpi/ic_launcher.png index 25832490a..de4160824 100644 Binary files a/src/en/arvenscans/res/mipmap-mdpi/ic_launcher.png and b/src/en/arvenscans/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/en/arvenscans/res/mipmap-xhdpi/ic_launcher.png b/src/en/arvenscans/res/mipmap-xhdpi/ic_launcher.png index 74335d56c..b72de6414 100644 Binary files a/src/en/arvenscans/res/mipmap-xhdpi/ic_launcher.png and b/src/en/arvenscans/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/en/arvenscans/res/mipmap-xxhdpi/ic_launcher.png b/src/en/arvenscans/res/mipmap-xxhdpi/ic_launcher.png index c6f496dee..5c054b81e 100644 Binary files a/src/en/arvenscans/res/mipmap-xxhdpi/ic_launcher.png and b/src/en/arvenscans/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/en/arvenscans/res/mipmap-xxxhdpi/ic_launcher.png b/src/en/arvenscans/res/mipmap-xxxhdpi/ic_launcher.png index a7c865dc8..6423ae29a 100644 Binary files a/src/en/arvenscans/res/mipmap-xxxhdpi/ic_launcher.png and b/src/en/arvenscans/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/en/arvenscans/src/eu/kanade/tachiyomi/extension/en/arvenscans/ArvenScans.kt b/src/en/arvenscans/src/eu/kanade/tachiyomi/extension/en/arvenscans/ArvenScans.kt deleted file mode 100644 index 3c3c5bcb5..000000000 --- a/src/en/arvenscans/src/eu/kanade/tachiyomi/extension/en/arvenscans/ArvenScans.kt +++ /dev/null @@ -1,13 +0,0 @@ -package eu.kanade.tachiyomi.extension.en.arvenscans - -import eu.kanade.tachiyomi.multisrc.mangathemesia.MangaThemesia -import eu.kanade.tachiyomi.network.interceptor.rateLimit -import okhttp3.OkHttpClient -import java.util.concurrent.TimeUnit - -class ArvenScans : MangaThemesia("Arven Scans", "https://arvenscans.com", "en", "/series") { - - override val client: OkHttpClient = super.client.newBuilder() - .rateLimit(20, 5, TimeUnit.SECONDS) - .build() -} diff --git a/src/en/arvenscans/src/eu/kanade/tachiyomi/extension/en/arvenscans/Dto.kt b/src/en/arvenscans/src/eu/kanade/tachiyomi/extension/en/arvenscans/Dto.kt new file mode 100644 index 000000000..e08fc1192 --- /dev/null +++ b/src/en/arvenscans/src/eu/kanade/tachiyomi/extension/en/arvenscans/Dto.kt @@ -0,0 +1,113 @@ +package eu.kanade.tachiyomi.extension.en.arvenscans + +import eu.kanade.tachiyomi.source.model.SChapter +import eu.kanade.tachiyomi.source.model.SManga +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonPrimitive +import okhttp3.HttpUrl.Companion.toHttpUrl +import org.jsoup.Jsoup +import java.text.ParseException +import java.text.SimpleDateFormat +import java.util.Locale + +@Serializable +class SearchResponse( + val posts: List<Manga>, + val totalCount: Int, +) + +@Serializable +class Manga( + val id: Int, + val slug: String, + private val postTitle: String, + private val postContent: String? = null, + val isNovel: Boolean, + private val featuredImage: String? = null, + private val alternativeTitles: String? = null, + private val author: String? = null, + private val artist: String? = null, + private val seriesType: String? = null, + private val seriesStatus: String? = null, + private val genres: List<Name>? = emptyList(), +) { + fun toSManga(baseUrl: String) = SManga.create().apply { + url = "$slug#$id" + title = postTitle + thumbnail_url = "$baseUrl/_next/image".toHttpUrl().newBuilder().apply { + addQueryParameter("url", featuredImage) + addQueryParameter("w", "828") + addQueryParameter("q", "75") + }.toString() + author = this@Manga.author?.takeUnless { it.isEmpty() } + artist = this@Manga.artist?.takeUnless { it.isEmpty() } + description = buildString { + postContent?.takeUnless { it.isEmpty() }?.let { desc -> + val tmpDesc = desc.replace("\n", "<br>") + + append(Jsoup.parse(tmpDesc).text()) + } + alternativeTitles?.takeUnless { it.isEmpty() }?.let { altName -> + append("\n\n") + append("Alternative Names: ") + append(altName) + } + }.trim() + genre = getGenres() + status = when (seriesStatus) { + "ONGOING", "COMING_SOON" -> SManga.ONGOING + "COMPLETED" -> SManga.COMPLETED + "CANCELLED", "DROPPED" -> SManga.CANCELLED + else -> SManga.UNKNOWN + } + initialized = true + } + + fun getGenres() = buildList { + when (seriesType) { + "MANGA" -> add("Manga") + "MANHUA" -> add("Manhua") + "MANHWA" -> add("Manhwa") + else -> {} + } + genres?.forEach { add(it.name) } + }.distinct().joinToString() +} + +@Serializable +class Name(val name: String) + +@Serializable +class Post<T>(val post: T) + +@Serializable +class ChapterListResponse( + val isNovel: Boolean, + val slug: String, + val chapters: List<Chapter>, +) + +@Serializable +class Chapter( + private val id: Int, + private val slug: String, + private val number: JsonPrimitive, + private val createdBy: Name, + private val createdAt: String, + private val chapterStatus: String, +) { + fun isPublic() = chapterStatus == "PUBLIC" + + fun toSChapter(mangaSlug: String) = SChapter.create().apply { + url = "/series/$mangaSlug/$slug#$id" + name = "Chapter $number" + scanlator = createdBy.name + date_upload = try { + dateFormat.parse(createdAt)!!.time + } catch (_: ParseException) { + 0L + } + } +} + +private val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.ENGLISH) diff --git a/src/en/arvenscans/src/eu/kanade/tachiyomi/extension/en/arvenscans/Filters.kt b/src/en/arvenscans/src/eu/kanade/tachiyomi/extension/en/arvenscans/Filters.kt new file mode 100644 index 000000000..e9eda2739 --- /dev/null +++ b/src/en/arvenscans/src/eu/kanade/tachiyomi/extension/en/arvenscans/Filters.kt @@ -0,0 +1,101 @@ +package eu.kanade.tachiyomi.extension.en.arvenscans + +import eu.kanade.tachiyomi.source.model.Filter +import okhttp3.HttpUrl + +interface UrlPartFilter { + fun addUrlParameter(url: HttpUrl.Builder) +} + +abstract class SelectFilter( + name: String, + private val urlParameter: String, + private val options: List<Pair<String, String>>, +) : UrlPartFilter, Filter.Select<String>( + name, + options.map { it.first }.toTypedArray(), +) { + override fun addUrlParameter(url: HttpUrl.Builder) { + url.addQueryParameter(urlParameter, options[state].second) + } +} + +class CheckBoxFilter(name: String, val value: String) : Filter.CheckBox(name) + +abstract class CheckBoxGroup( + name: String, + private val urlParameter: String, + options: List<Pair<String, String>>, +) : UrlPartFilter, Filter.Group<CheckBoxFilter>( + name, + options.map { CheckBoxFilter(it.first, it.second) }, +) { + override fun addUrlParameter(url: HttpUrl.Builder) { + val checked = state.filter { it.state }.map { it.value } + + if (checked.isNotEmpty()) { + url.addQueryParameter(urlParameter, checked.joinToString(",")) + } + } +} + +class StatusFilter : SelectFilter( + "Status", + "seriesStatus", + listOf( + Pair("", ""), + Pair("Ongoing", "ONGOING"), + Pair("Completed", "COMPLETED"), + Pair("Cancelled", "CANCELLED"), + Pair("Dropped", "DROPPED"), + Pair("Mass Released", "MASS_RELEASED"), + Pair("Coming Soon", "COMING_SOON"), + ), +) + +class TypeFilter : SelectFilter( + "Type", + "seriesType", + listOf( + Pair("", ""), + Pair("Manga", "MANGA"), + Pair("Manhua", "MANHUA"), + Pair("Manhwa", "MANHWA"), + Pair("Russian", "RUSSIAN"), + ), +) + +class GenreFilter : CheckBoxGroup( + "Genres", + "genreIds", + listOf( + Pair("Action", "1"), + Pair("Adventure", "13"), + Pair("Comedy", "7"), + Pair("Drama", "2"), + Pair("elf", "25"), + Pair("Fantas", "28"), + Pair("Fantasy", "8"), + Pair("Historical", "19"), + Pair("Horror", "9"), + Pair("Josei", "21"), + Pair("Manhwa", "5"), + Pair("Martial Arts", "6"), + Pair("Mature", "12"), + Pair("Monsters", "14"), + Pair("Reincarnation", "16"), + Pair("Revenge", "17"), + Pair("Romance", "20"), + Pair("School Life", "23"), + Pair("Seinen", "10"), + Pair("shojo", "26"), + Pair("Shoujo", "22"), + Pair("Shounen", "3"), + Pair("Slice Of Life", "18"), + Pair("Sports", "4"), + Pair("Supernatural", "11"), + Pair("System", "15"), + Pair("terror", "24"), + Pair("Video Games", "27"), + ), +) diff --git a/src/en/arvenscans/src/eu/kanade/tachiyomi/extension/en/arvenscans/VortexScans.kt b/src/en/arvenscans/src/eu/kanade/tachiyomi/extension/en/arvenscans/VortexScans.kt new file mode 100644 index 000000000..5ca02669c --- /dev/null +++ b/src/en/arvenscans/src/eu/kanade/tachiyomi/extension/en/arvenscans/VortexScans.kt @@ -0,0 +1,145 @@ +package eu.kanade.tachiyomi.extension.en.arvenscans + +import eu.kanade.tachiyomi.network.GET +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.HttpUrl.Companion.toHttpUrl +import okhttp3.Request +import okhttp3.Response +import uy.kohesive.injekt.injectLazy + +class VortexScans : HttpSource() { + + override val name = "Vortex Scans" + + override val baseUrl = "https://vortexscans.com" + + override val lang = "en" + + override val supportsLatest = true + + override val client = network.cloudflareClient + + private val json by injectLazy<Json>() + + override fun headersBuilder() = super.headersBuilder() + .set("Referer", "$baseUrl/") + + private val titleCache by lazy { + val response = client.newCall(GET("$baseUrl/api/query?perPage=9999", headers)).execute() + val data = response.parseAs<SearchResponse>() + + data.posts + .filterNot { it.isNovel } + .associateBy { it.slug } + } + + override fun popularMangaRequest(page: Int) = GET("$baseUrl/home", headers) + + override fun popularMangaParse(response: Response): MangasPage { + val document = response.asJsoup() + val slugs = document.select("div:contains(Popular) + div.swiper div.manga-swipe > a") + .map { it.absUrl("href").substringAfterLast("/series/") } + + val entries = slugs.mapNotNull { + titleCache[it]?.toSManga(baseUrl) + } + + return MangasPage(entries, false) + } + + override fun latestUpdatesRequest(page: Int) = searchMangaRequest(page, "", getFilterList()) + override fun latestUpdatesParse(response: Response) = searchMangaParse(response) + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + val url = "$baseUrl/api/query".toHttpUrl().newBuilder().apply { + addQueryParameter("page", page.toString()) + addQueryParameter("perPage", perPage.toString()) + addQueryParameter("searchTerm", query.trim()) + filters.filterIsInstance<UrlPartFilter>().forEach { + it.addUrlParameter(this) + } + }.build() + + return GET(url, headers) + } + + override fun searchMangaParse(response: Response): MangasPage { + val data = response.parseAs<SearchResponse>() + val page = response.request.url.queryParameter("page")!!.toInt() + + val entries = data.posts + .filterNot { it.isNovel } + .map { it.toSManga(baseUrl) } + + val hasNextPage = data.totalCount > (page * perPage) + + return MangasPage(entries, hasNextPage) + } + + override fun getFilterList() = FilterList( + StatusFilter(), + TypeFilter(), + GenreFilter(), + ) + + override fun mangaDetailsRequest(manga: SManga): Request { + val id = manga.url.substringAfterLast("#") + val url = "$baseUrl/api/chapters?postId=$id&skip=0&take=1000&order=desc&userid=" + + return GET(url, headers) + } + + override fun getMangaUrl(manga: SManga): String { + val slug = manga.url.substringBeforeLast("#") + + return "$baseUrl/series/$slug" + } + + override fun mangaDetailsParse(response: Response): SManga { + val data = response.parseAs<Post<Manga>>() + + assert(!data.post.isNovel) { "Novels are unsupported" } + + // genres are only returned in search call + // and not when fetching details + return data.post.toSManga(baseUrl).apply { + genre = titleCache[data.post.slug]?.getGenres() + } + } + + override fun chapterListRequest(manga: SManga) = mangaDetailsRequest(manga) + + override fun chapterListParse(response: Response): List<SChapter> { + val data = response.parseAs<Post<ChapterListResponse>>() + + assert(!data.post.isNovel) { "Novels are unsupported" } + + return data.post.chapters + .filter { it.isPublic() } + .map { it.toSChapter(data.post.slug) } + } + + override fun pageListParse(response: Response): List<Page> { + val document = response.asJsoup() + + return document.select("main > section > img").mapIndexed { idx, img -> + Page(idx, imageUrl = img.absUrl("src")) + } + } + + override fun imageUrlParse(response: Response) = + throw UnsupportedOperationException() + + private inline fun <reified T> Response.parseAs(): T = + json.decodeFromString(body.string()) +} + +private const val perPage = 18