Zero Scans migrate to new site. (#11358)

* Initial Commit

* Update

* Add space in extension name.

* Review updates
This commit is contained in:
FourTOne5 2022-04-13 00:37:56 +06:00 committed by GitHub
parent f7b61b226f
commit 6defaacb09
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 604 additions and 1 deletions

View File

@ -13,7 +13,6 @@ class GenkanGenerator : ThemeSourceGenerator {
override val sources = listOf(
SingleLang("Hunlight Scans", "https://hunlight-scans.info", "en"),
SingleLang("ZeroScans", "https://zeroscans.com", "en"),
SingleLang("The Nonames Scans", "https://the-nonames.com", "en"),
SingleLang("Edelgarde Scans", "https://edelgardescans.com", "en"),
SingleLang("LynxScans", "https://lynxscans.com", "en", overrideVersionCode = 3),

View File

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest package="eu.kanade.tachiyomi.extension" />

View File

@ -0,0 +1,3 @@
## 1.2.4
Migrated to the new site.

View File

@ -0,0 +1,12 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlinx-serialization'
ext {
extName = 'Zero Scans'
pkgNameSuffix = 'en.zeroscans'
extClass = '.ZeroScans'
extVersionCode = 4
}
apply from: "$rootDir/common.gradle"

View File

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

View File

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

View File

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View File

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 51 KiB

View File

@ -0,0 +1,349 @@
package eu.kanade.tachiyomi.extension.en.zeroscans
import android.util.Log
import eu.kanade.tachiyomi.network.GET
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 kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Request
import okhttp3.Response
import rx.Observable
import rx.Single
import rx.schedulers.Schedulers
import uy.kohesive.injekt.injectLazy
class ZeroScans : HttpSource() {
override val name: String = "Zero Scans"
override val lang: String = "en"
override val baseUrl: String = "https://beta.zeroscans.com"
override val supportsLatest: Boolean = true
private val json: Json by injectLazy()
private lateinit var comicList: List<ZeroScansComicDto>
private lateinit var rankings: ZeroScansRankingsDto
private val zsHelper = ZeroScansHelper()
override fun fetchLatestUpdates(page: Int): Observable<MangasPage> {
if (page == 1) runCatching { updateComicsData() }
return super.fetchLatestUpdates(page)
}
override fun latestUpdatesRequest(page: Int): Request {
return GET("$baseUrl/$API_PATH/new-chapters")
}
override fun latestUpdatesParse(response: Response): MangasPage {
val newChapters = response.parseAs<NewChaptersResponseDto>()
val titlesList = newChapters.all.mapNotNull {
comicList.firstOrNull { comic -> comic.slug == it.slug }
}.map { comic -> zsHelper.zsComicEntryToSManga(comic) }
return MangasPage(titlesList, false)
}
override fun fetchPopularManga(page: Int): Observable<MangasPage> {
return fetchSearchManga(page, query = "", filters = getFilterList())
}
override fun fetchSearchManga(
page: Int,
query: String,
filters: FilterList
): Observable<MangasPage> {
if (page == 1) runCatching { updateComicsData() }
filters.filterIsInstance<RankingsFilter>().firstOrNull()?.let { rankingFilter ->
val type = rankingList[rankingFilter.state!!.index].type
val ascending = rankingFilter.state!!.ascending
getRankingsIfNeeded(type, ascending)?.let {
return Observable.just(MangasPage(it, false))
}
}
var filteredComics = comicList
if (query.isNotBlank()) {
filteredComics = filteredComics.filter { it.name.contains(query, ignoreCase = true) }
}
filters.forEach { filter ->
when (filter) {
is StatusFilter -> {
filteredComics = filteredComics.filter { zsHelper.checkStatusFilter(filter, it) }
}
is GenreFilter -> {
filteredComics = filteredComics.filter { zsHelper.checkGenreFilter(filter, it) }
}
is SortFilter -> {
filter.state?.let {
val type = sortList[it.index].type
val ascending = it.ascending
filteredComics = zsHelper.applySortFilter(type, ascending, filteredComics)
}
}
else -> { /* Do Nothing */ }
}
}
// Get 20 comics at a time
val chunkedFilteredComics = filteredComics.chunked(20)
if (chunkedFilteredComics.isEmpty()) {
return Observable.just(MangasPage(emptyList(), false))
}
val comics = chunkedFilteredComics[page - 1].map { comic -> zsHelper.zsComicEntryToSManga(comic) }
val hasNextPage = page < chunkedFilteredComics.size
return Observable.just(MangasPage(comics, hasNextPage))
}
private fun getRankingsIfNeeded(type: String?, ascending: Boolean): List<SManga>? {
if (type.isNullOrBlank()) return null
val rankingEntries = when (type) {
"weekly" -> {
if (!ascending) rankings.weekly.reversed()
else rankings.weekly
}
"monthly" -> {
if (!ascending) rankings.monthly.reversed()
else rankings.monthly
}
else -> {
if (!ascending) rankings.allTime.reversed()
else rankings.allTime
}
}
val titlesList = rankingEntries.mapNotNull { rankingEntry ->
comicList.firstOrNull { rankingEntry.slug == it.slug }
}.map { comic -> zsHelper.zsComicEntryToSManga(comic) }
return titlesList
}
override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
runCatching { updateComicsData() }
val mangaSlug = "$baseUrl${manga.url}".toHttpUrl().pathSegments[1]
try {
val comic = comicList.first { comic -> comic.slug == mangaSlug }
.let { zsHelper.zsComicEntryToSManga(it) }
return Observable.just(comic)
} catch (e: NoSuchElementException) {
throw Exception("Migrate from Zero Scans to Zero Scans")
}
}
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
try {
var page = 1
val response = client.newCall(zsChapterListRequest(page, manga)).execute()
val zsChapterPage = zsChapterListParse(response)
val zsChapters = zsChapterPage.chapters.toMutableList()
var hasMoreResult = zsChapterPage.hasNextPage
while (hasMoreResult) {
page++
val newResponse = client.newCall(zsChapterListRequest(page, manga)).execute()
val newZSChapterPage = zsChapterListParse(newResponse)
zsChapters.addAll(newZSChapterPage.chapters)
hasMoreResult = newZSChapterPage.hasNextPage
}
zsChapters.map { it.toSChapter(manga) }.let {
return Observable.just(it)
}
} catch (e: Exception) {
Log.e("Zero Scans", "Error parsing chapter list", e)
throw(e)
}
}
private fun zsChapterListRequest(page: Int, manga: SManga): Request {
val mangaId = "$baseUrl${manga.url}".toHttpUrl().queryParameter("id")
return GET("$baseUrl/$API_PATH/comic/$mangaId/chapters?sort=desc&page=$page")
}
private fun zsChapterListParse(response: Response): ZeroScansChapterPage {
return response.parseAs<ZeroScansResponseDto<ZeroScansChaptersResponseDto>>()
.data.let {
ZeroScansChapterPage(
it.data,
it.currentPage < it.lastPage
)
}
}
class ZeroScansChapterPage(
val chapters: List<ZeroScansChapterDto>,
val hasNextPage: Boolean
)
private fun ZeroScansChapterDto.toSChapter(manga: SManga): SChapter {
val comicSlug = "$baseUrl${manga.url}".toHttpUrl().pathSegments[1]
val zsChapter = this
return SChapter.create().apply {
name = "Chapter ${zsChapter.name}"
scanlator = zsChapter.group
chapter_number = zsChapter.name.toFloat()
date_upload = zsHelper.parseChapterUploadDate(zsChapter.createdAt)
url = "/comics/$comicSlug/${zsChapter.id}"
}
}
override fun pageListRequest(chapter: SChapter): Request {
val chapterUrlPaths = "$baseUrl${chapter.url}".toHttpUrl().pathSegments
val mangaSlug = chapterUrlPaths[1]
val chapterId = chapterUrlPaths[2]
return GET("$baseUrl/$API_PATH/comic/$mangaSlug/chapters/$chapterId")
}
override fun pageListParse(response: Response): List<Page> {
val allQualityZSPages = response.parseAs<ZeroScansResponseDto<ZeroScansPageResponseDto>>().data.chapter
val highResZSPages = allQualityZSPages.highQuality.takeIf { it.isNotEmpty() } ?: allQualityZSPages.goodQuality
val pages = highResZSPages.mapIndexed { index, url ->
Page(index, imageUrl = url)
}
return pages
}
// Fetch Comics, Genres, Statuses and Rankings on creating source
init {
Single.fromCallable {
runCatching { updateComicsData() }
}.subscribeOn(Schedulers.io())
.observeOn(Schedulers.io())
.subscribe({}, {})
}
// Filters
override fun getFilterList(): FilterList {
val filters = mutableListOf<Filter<*>>()
if (genreList.isNotEmpty()) {
filters.add(GenreFilter(genreList))
}
if (statusList.isNotEmpty()) {
filters.add(StatusFilter(statusList))
}
filters += listOf(
SortFilter(sortList),
RankingsHeader(),
RankingsHeader2(),
RankingsFilter(rankingList)
)
return FilterList(filters)
}
class GenreFilter(genres: List<Genre>) : Filter.Group<Genre>("Genre", genres)
class Genre(name: String, val id: Int) : Filter.TriState(name)
private var genreList: List<Genre> = emptyList()
class StatusFilter(statuses: List<Status>) : Filter.Group<Status>("Status", statuses)
class Status(name: String, val id: Int) : Filter.TriState(name)
private var statusList: List<Status> = emptyList()
class SortFilter(sorts: List<ZeroScans.Sort>) :
Filter.Sort("Sort by", sorts.map { it.name }.toTypedArray(), Selection(3, false))
class Sort(val name: String, val type: String)
private val sortList = listOf(
Sort("Alphabetic", "alphabetic"),
Sort("Rating", "rating"),
Sort("Chapter Count", "chapter_count"),
Sort("Bookmark Count", "bookmark_count"),
Sort("View Count", "view_count")
)
class RankingsHeader :
Filter.Header("Note: Genre, Sort, Status filter and Search query")
class RankingsHeader2 :
Filter.Header("are not applied to rankings")
class RankingsFilter(rankings: List<Ranking>) :
Filter.Sort("Rankings", rankings.map { it.name }.toTypedArray(), Selection(0, false))
class Ranking(val name: String, val type: String? = null)
private val rankingList = listOf(
Ranking("None"),
Ranking("All Time", "all-time"),
Ranking("Weekly", "weekly"),
Ranking("Monthly", "monthly")
)
// Helpers
private inline fun <reified T> Response.parseAs(): T = use {
json.decodeFromString(it.body?.string().orEmpty())
}
private fun comicsDataRequest(): Request {
return GET("$baseUrl/$API_PATH/comics")
}
private fun comicsDataParse(response: Response): ZeroScansComicsDataDto {
return response.parseAs<ZeroScansResponseDto<ZeroScansComicsDataDto>>().data
}
private fun updateComicsData() {
val response = client.newCall(comicsDataRequest()).execute()
comicsDataParse(response).let {
genreList = it.genres.map { genreDto ->
Genre(genreDto.name, genreDto.id)
}
statusList = it.statuses.map { statusDto ->
Status(statusDto.name, statusDto.id)
}
comicList = it.comics
rankings = it.rankings
}
}
// Unused Stuff
override fun imageUrlParse(response: Response): String = ""
override fun popularMangaRequest(page: Int): Request = throw UnsupportedOperationException("Not Used")
override fun popularMangaParse(response: Response): MangasPage = throw UnsupportedOperationException("Not Used")
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request =
throw UnsupportedOperationException("Not Used")
override fun searchMangaParse(response: Response): MangasPage = throw UnsupportedOperationException("Not Used")
override fun chapterListRequest(manga: SManga): Request = throw UnsupportedOperationException("Not Used")
override fun chapterListParse(response: Response): List<SChapter> = throw UnsupportedOperationException("Not Used")
override fun mangaDetailsParse(response: Response): SManga = throw UnsupportedOperationException("Not Used")
companion object {
const val API_PATH = "swordflake"
}
}

View File

@ -0,0 +1,116 @@
package eu.kanade.tachiyomi.extension.en.zeroscans
import eu.kanade.tachiyomi.source.model.Page
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
@Serializable
data class NewChaptersResponseDto(
val all: List<NewChaptersMangaDto>
)
@Serializable
data class NewChaptersMangaDto(
val slug: String
)
@Serializable
data class ZeroScansResponseDto<T>(
val success: Boolean? = null,
val data: T,
val message: String? = null
)
@Serializable
data class ZeroScansComicsDataDto(
val comics: List<ZeroScansComicDto>,
val genres: List<ZeroScansGenreDto>,
val statuses: List<ZeroScansStatusDto>,
val rankings: ZeroScansRankingsDto
)
@Serializable
data class ZeroScansComicDto(
val name: String,
val slug: String,
val id: Int,
val cover: ZeroScansCoverDto,
val summary: String,
val statuses: List<ZeroScansStatusDto>,
val genres: List<ZeroScansGenreDto>,
@SerialName("chapter_count") val chapterCount: Int,
@SerialName("bookmark_count") val bookmarkCount: Int,
@SerialName("view_count") val viewCount: Int,
val rating: JsonElement
) {
fun getRating(): Float {
return this.rating.toString().toFloatOrNull() ?: 0F
}
}
@Serializable
data class ZeroScansGenreDto(
val name: String,
val id: Int
)
@Serializable
data class ZeroScansStatusDto(
val name: String,
val id: Int
)
@Serializable
data class ZeroScansRankingsDto(
@SerialName("all_time") val allTime: List<ZeroScansRankingsEntryDto>,
@SerialName("weekly_comics") val weekly: List<ZeroScansRankingsEntryDto>,
@SerialName("monthly_comics") val monthly: List<ZeroScansRankingsEntryDto>
)
@Serializable
data class ZeroScansRankingsEntryDto(
val slug: String
)
@Serializable
data class ZeroScansCoverDto(
val horizontal: String? = null,
val vertical: String? = null,
val full: String? = null
) {
fun getHighResCover(): String {
return when {
!this.full.isNullOrBlank() -> this.full
!this.horizontal.isNullOrBlank() -> this.horizontal.replace("-horizontal", "-full")
!this.vertical.isNullOrBlank() -> this.vertical.replace("-vertical", "-full")
else -> ""
}
}
}
@Serializable
data class ZeroScansChaptersResponseDto(
val data: List<ZeroScansChapterDto> = emptyList(),
@SerialName("current_page") val currentPage: Int,
@SerialName("last_page") val lastPage: Int
)
@Serializable
data class ZeroScansChapterDto(
val id: Int,
val name: Int,
val group: String?,
@SerialName("created_at") val createdAt: String
)
@Serializable
data class ZeroScansPageResponseDto(
val chapter: ZeroScansChapterPagesDto
)
@Serializable
data class ZeroScansChapterPagesDto(
@SerialName("high_quality") val highQuality: List<String> = emptyList(),
@SerialName("good_quality") val goodQuality: List<String> = emptyList()
)

View File

@ -0,0 +1,122 @@
package eu.kanade.tachiyomi.extension.en.zeroscans
import eu.kanade.tachiyomi.source.model.SManga
import java.util.Calendar
import java.util.Locale
class ZeroScansHelper {
// Search Related
fun checkStatusFilter(
filter: ZeroScans.StatusFilter,
comic: ZeroScansComicDto
): Boolean {
val includedStatusIds = filter.state.filter { it.isIncluded() }.map { it.id }
val excludedStatusIds = filter.state.filter { it.isExcluded() }.map { it.id }
val comicStatusesId = comic.statuses.map { it.id }
if (includedStatusIds.isEmpty() && excludedStatusIds.isEmpty()) return true
return includedStatusIds.any { it in comicStatusesId } && excludedStatusIds.any { it !in comicStatusesId }
}
fun checkGenreFilter(
filter: ZeroScans.GenreFilter,
comic: ZeroScansComicDto
): Boolean {
val includedGenreIds = filter.state.filter { it.isIncluded() }.map { it.id }
val excludedGenreIds = filter.state.filter { it.isExcluded() }.map { it.id }
val comicStatusesId = comic.genres.map { it.id }
if (includedGenreIds.isEmpty() && excludedGenreIds.isEmpty()) return true
return includedGenreIds.any { it in comicStatusesId } && excludedGenreIds.any { it !in comicStatusesId }
}
fun applySortFilter(
type: String,
ascending: Boolean,
comics: List<ZeroScansComicDto>
): List<ZeroScansComicDto> {
var sortedList = when (type) {
"alphabetic" -> comics.sortedBy { it.name.toLowerCase(Locale.ROOT) }
"rating" -> comics.sortedBy { it.getRating() }
"chapter_count" -> comics.sortedBy { it.chapterCount }
"bookmark_count" -> comics.sortedBy { it.bookmarkCount }
"view_count" -> comics.sortedBy { it.viewCount }
else -> comics
}
if (!ascending) {
sortedList = sortedList.reversed()
}
return sortedList
}
// Chapter Related
fun parseChapterUploadDate(date: String): Long {
val value = date.split(' ')[0].toInt()
return when (date.split(' ')[1].removeSuffix("s")) {
"sec" -> Calendar.getInstance().apply {
add(Calendar.SECOND, value * -1)
}.timeInMillis
"min" -> Calendar.getInstance().apply {
add(Calendar.MINUTE, value * -1)
}.timeInMillis
"hour" -> Calendar.getInstance().apply {
add(Calendar.HOUR_OF_DAY, value * -1)
}.timeInMillis
"day" -> Calendar.getInstance().apply {
add(Calendar.DATE, value * -1)
}.timeInMillis
"week" -> Calendar.getInstance().apply {
add(Calendar.DATE, value * 7 * -1)
}.timeInMillis
"month" -> Calendar.getInstance().apply {
add(Calendar.MONTH, value * -1)
}.timeInMillis
"year" -> Calendar.getInstance().apply {
add(Calendar.YEAR, value * -1)
}.timeInMillis
else -> {
return 0
}
}
}
// Manga Related
fun zsComicEntryToSManga(comic: ZeroScansComicDto): SManga {
var comicDescription = comic.summary
if (comic.statuses.any { it.id == 4 }) {
comicDescription = "The series has been dropped.\n\n$comicDescription"
}
return SManga.create().apply {
title = comic.name
url = "/comics/${comic.slug}?id=${comic.id}"
thumbnail_url = comic.cover.getHighResCover()
description = comicDescription
genre = comic.genres.joinToString { it.name }
status = comic.getTachiyomiStatus()
initialized = true
}
}
private fun ZeroScansComicDto.getTachiyomiStatus(): Int {
// 1 = New & 4 = Dropped
val compatibleStatus = statuses.filterNot { it.id in listOf(1, 4) }
// TODO Apply 6 to ON_HIATUS after ext-lib 1.3 merge
compatibleStatus.firstOrNull { it.id in listOf(5, 6) }
?.also { return SManga.ONGOING }
compatibleStatus.firstOrNull { it.id == 3 }
?.also { return SManga.COMPLETED }
// Nothing Matched
return SManga.UNKNOWN
}
}