Flix Scans (#17532)

* new source: Flix Scans

* some changes
This commit is contained in:
AwkwardPeak7 2023-08-16 23:54:39 +05:00 committed by GitHub
parent ef58b57c65
commit 97f21bc28b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 538 additions and 0 deletions

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

View File

@ -0,0 +1,317 @@
package eu.kanade.tachiyomi.extension.en.flixscans
import android.util.Log
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.network.interceptor.rateLimitHost
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.encodeToString
import kotlinx.serialization.json.Json
import okhttp3.Call
import okhttp3.Callback
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import rx.Observable
import uy.kohesive.injekt.injectLazy
class FlixScans : HttpSource() {
override val name = "Flix Scans"
override val lang = "en"
override val baseUrl = "https://flixscans.net"
private val apiUrl = "https://api.flixscans.net/api/v1"
override val supportsLatest = true
private val json: Json by injectLazy()
override val client = network.cloudflareClient.newBuilder()
.rateLimit(2)
.build()
// only returns 15 chapters each request, so using higher rate limit
private val chapterClient = network.cloudflareClient.newBuilder()
.rateLimitHost(apiUrl.toHttpUrl(), 1, 2)
.build()
override fun headersBuilder() = super.headersBuilder()
.add("Referer", baseUrl)
override fun fetchPopularManga(page: Int): Observable<MangasPage> {
runCatching { fetchGenre() }
return super.fetchPopularManga(page)
}
override fun popularMangaRequest(page: Int): Request {
return GET("$apiUrl/webtoon/homepage/home", headers)
}
override fun popularMangaParse(response: Response): MangasPage {
val result = response.parseAs<HomeDto>()
val entries = (result.hot + result.topAll + result.topMonth + result.topWeek)
.distinctBy { it.id }
.map(BrowseSeries::toSManga)
return MangasPage(entries, false)
}
override fun fetchLatestUpdates(page: Int): Observable<MangasPage> {
runCatching { fetchGenre() }
return super.fetchLatestUpdates(page)
}
override fun latestUpdatesRequest(page: Int): Request {
return GET("$apiUrl/search/advance?page=$page&serie_type=webtoon", headers)
}
override fun latestUpdatesParse(response: Response): MangasPage {
val result = response.parseAs<ApiResponse<BrowseSeries>>()
val currentPage = response.request.url.queryParameter("page")
?.toIntOrNull() ?: 1
val entries = result.data.map(BrowseSeries::toSManga)
val hasNextPage = result.meta.lastPage > currentPage
return MangasPage(entries, hasNextPage)
}
private var fetchGenreList: List<GenreHolder> = emptyList()
private var fetchGenreCallOngoing = false
private var fetchGenreFailed = false
private var fetchGenreAttempt = 0
private fun fetchGenre() {
if (fetchGenreAttempt < 3 && (fetchGenreList.isEmpty() || fetchGenreFailed) && !fetchGenreCallOngoing) {
fetchGenreCallOngoing = true
// fetch genre asynchronously as it sometimes hangs
client.newCall(fetchGenreRequest()).enqueue(fetchGenreCallback)
}
}
private val fetchGenreCallback = object : Callback {
override fun onFailure(call: Call, e: okio.IOException) {
fetchGenreAttempt++
fetchGenreFailed = true
fetchGenreCallOngoing = false
e.message?.let { Log.e("$name Filters", it) }
}
override fun onResponse(call: Call, response: Response) {
fetchGenreCallOngoing = false
fetchGenreAttempt++
if (!response.isSuccessful) {
fetchGenreFailed = true
response.close()
return
}
val parsed = runCatching {
response.use(::fetchGenreParse)
}
fetchGenreFailed = parsed.isFailure
fetchGenreList = parsed.getOrElse {
Log.e("$name Filters", it.stackTraceToString())
emptyList()
}
}
}
private fun fetchGenreRequest(): Request {
return GET("$apiUrl/search/genres", headers)
}
private fun fetchGenreParse(response: Response): List<GenreHolder> {
return response.parseAs<List<GenreHolder>>()
}
override fun getFilterList(): FilterList {
val filters: MutableList<Filter<*>> = mutableListOf(
Filter.Header("Ignored when using Text Search"),
MainGenreFilter(),
TypeFilter(),
StatusFilter(),
)
filters += if (fetchGenreList.isNotEmpty()) {
listOf(
GenreFilter("Genre", fetchGenreList),
)
} else {
listOf(
Filter.Separator(),
Filter.Header("Press 'reset' to attempt to show Genres"),
)
}
return FilterList(filters)
}
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
runCatching { fetchGenre() }
return super.fetchSearchManga(page, query, filters)
}
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
if (query.isNotEmpty()) {
val requestBody = SearchInput(query.trim())
.let(json::encodeToString)
.toRequestBody(JSON_MEDIA_TYPE)
val newHeaders = headersBuilder()
.add("Content-Length", requestBody.contentLength().toString())
.add("Content-Type", requestBody.contentType().toString())
.build()
return POST("$apiUrl/search/serie?page=$page", newHeaders, requestBody)
}
val advSearchUrl = apiUrl.toHttpUrl().newBuilder().apply {
addPathSegments("search/advance")
addQueryParameter("page", page.toString())
addQueryParameter("serie_type", "webtoon")
filters.forEach { filter ->
when (filter) {
is GenreFilter -> {
filter.checked.let {
if (it.isNotEmpty()) {
addQueryParameter("genres", it.joinToString(","))
}
}
}
is MainGenreFilter -> {
if (filter.state > 0) {
addQueryParameter("main_genres", filter.selected)
}
}
is TypeFilter -> {
if (filter.state > 0) {
addQueryParameter("type", filter.selected)
}
}
is StatusFilter -> {
if (filter.state > 0) {
addQueryParameter("status", filter.selected)
}
}
else -> {}
}
}
}.build()
return GET(advSearchUrl, headers)
}
override fun searchMangaParse(response: Response) = latestUpdatesParse(response)
override fun mangaDetailsRequest(manga: SManga): Request {
val id = manga.url.split("-")[1]
return GET("$apiUrl/webtoon/series/$id", headers)
}
override fun getMangaUrl(manga: SManga) = baseUrl + manga.url
override fun mangaDetailsParse(response: Response): SManga {
val result = response.parseAs<SeriesResponse>()
return result.serie.toSManga()
}
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
return chapterClient.newCall(chapterListRequest(manga))
.asObservableSuccess()
.map(::chapterListParse)
}
override fun chapterListRequest(manga: SManga): Request {
val id = manga.url.split("-")[1]
return paginatedChapterListRequest(id)
}
private fun paginatedChapterListRequest(seriesID: String, page: Int = 1): Request {
return GET("$apiUrl/webtoon/chapters/$seriesID-asc?page=$page", headers)
}
override fun chapterListParse(response: Response): List<SChapter> {
val result = response.parseAs<ApiResponse<Chapter>>()
val id = response.request.url.toString()
.substringAfterLast("/")
.substringBefore("-")
val chapters = result.data.toMutableList()
var page = 1
while (page < result.meta.lastPage) {
page++
val newResponse = chapterClient.newCall(paginatedChapterListRequest(id, page)).execute()
if (!newResponse.isSuccessful) {
newResponse.close()
continue
}
val newResult = newResponse.parseAs<ApiResponse<Chapter>>()
chapters.addAll(newResult.data)
}
return chapters.map(Chapter::toSChapter).reversed()
}
override fun pageListRequest(chapter: SChapter): Request {
val id = chapter.url
.substringAfterLast("/")
.substringBefore("-")
return GET("$apiUrl/webtoon/chapters/chapter/$id", headers)
}
override fun getChapterUrl(chapter: SChapter) = baseUrl + chapter.url
override fun pageListParse(response: Response): List<Page> {
val result = response.parseAs<PageListResponse>()
return result.chapter.chapterData.webtoon.mapIndexed { i, img ->
Page(i, "", cdnUrl + img)
}
}
override fun imageUrlParse(response: Response) = throw UnsupportedOperationException("Not Used")
private inline fun <reified T> Response.parseAs(): T =
use { body.string() }.let(json::decodeFromString)
companion object {
private val JSON_MEDIA_TYPE = "application/json; charset=utf-8".toMediaTypeOrNull()
const val cdnUrl = "https://media.flixscans.net/"
}
}

View File

@ -0,0 +1,145 @@
package eu.kanade.tachiyomi.extension.en.flixscans
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import java.text.SimpleDateFormat
import java.util.Locale
@Serializable
data class ApiResponse<T>(
val data: List<T>,
val meta: PageInfo,
)
@Serializable
data class PageInfo(
@SerialName("last_page") val lastPage: Int,
)
@Serializable
data class HomeDto(
val hot: List<BrowseSeries>,
val topWeek: List<BrowseSeries>,
val topMonth: List<BrowseSeries>,
val topAll: List<BrowseSeries>,
)
@Serializable
data class BrowseSeries(
val id: Int,
val title: String,
val slug: String,
val prefix: Int,
val thumbnail: String?,
) {
fun toSManga() = SManga.create().apply {
title = this@BrowseSeries.title
url = "/series/$prefix-$id-$slug"
thumbnail_url = thumbnail?.let { FlixScans.cdnUrl + it }
}
}
@Serializable
data class SearchInput(
val title: String,
)
@Serializable
data class GenreHolder(
val name: String,
val id: Int,
)
@Serializable
data class SeriesResponse(
val serie: Series,
)
@Serializable
data class Series(
val id: Int,
val title: String,
val slug: String,
val prefix: Int,
val thumbnail: String?,
val story: String?,
val serieType: String?,
val mainGenres: String?,
val otherNames: List<String>? = emptyList(),
val status: String?,
val type: String?,
val authors: List<GenreHolder>? = emptyList(),
val artists: List<GenreHolder>? = emptyList(),
val genres: List<GenreHolder>? = emptyList(),
) {
fun toSManga() = SManga.create().apply {
title = this@Series.title
url = "/series/$prefix-$id-$slug"
thumbnail_url = FlixScans.cdnUrl + thumbnail
author = authors?.joinToString { it.name.trim() }
artist = artists?.joinToString { it.name.trim() }
genre = (otherGenres + genres?.map { it.name.trim() }.orEmpty())
.distinct().joinToString { it.trim() }
description = story
if (otherNames?.isNotEmpty() == true) {
if (description.isNullOrEmpty()) {
description = "Alternative Names:\n"
} else {
description += "\n\nAlternative Names:\n"
}
description += otherNames.joinToString("\n") { "${it.trim()}" }
}
status = when (this@Series.status?.trim()) {
"ongoing" -> SManga.ONGOING
"completed" -> SManga.COMPLETED
"onhold" -> SManga.ON_HIATUS
else -> SManga.UNKNOWN
}
}
private val otherGenres = listOfNotNull(serieType, mainGenres, type)
.map { word ->
word.trim().replaceFirstChar {
if (it.isLowerCase()) {
it.titlecase(Locale.getDefault())
} else {
it.toString()
}
}
}
}
@Serializable
data class Chapter(
val id: Int,
val name: String,
val slug: String,
val createdAt: String? = null,
) {
fun toSChapter() = SChapter.create().apply {
url = "/read/webtoon/$id-$slug"
name = this@Chapter.name
date_upload = runCatching { dateFormat.parse(createdAt!!)!!.time }.getOrDefault(0L)
}
companion object {
val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSSSS'Z'", Locale.ENGLISH)
}
}
@Serializable
data class PageListResponse(
val chapter: ChapterPages,
)
@Serializable
data class ChapterPages(
val chapterData: ChapterPageData,
)
@Serializable
data class ChapterPageData(
val webtoon: List<String>,
)

View File

@ -0,0 +1,62 @@
package eu.kanade.tachiyomi.extension.en.flixscans
import eu.kanade.tachiyomi.source.model.Filter
abstract class SelectFilter(
name: String,
private val options: List<String>,
) : Filter.Select<String>(
name,
options.toTypedArray(),
) {
val selected get() = options[state]
}
class CheckBoxFilter(
name: String,
val id: String,
) : Filter.CheckBox(name)
class GenreFilter(
name: String,
private val genres: List<GenreHolder>,
) : Filter.Group<CheckBoxFilter>(
name,
genres.map { CheckBoxFilter(it.name.trim(), it.id.toString()) },
) {
val checked get() = state.filter { it.state }.map { it.id }
}
class MainGenreFilter : SelectFilter(
"Main Genre",
listOf(
"",
"fantasy",
"romance",
"action",
"drama",
),
)
class TypeFilter : SelectFilter(
"Type",
listOf(
"",
"manhwa",
"manhua",
"manga",
"comic",
),
)
class StatusFilter : SelectFilter(
"Status",
listOf(
"",
"ongoing",
"completed",
"droped",
"onhold",
"soon",
),
)