diff --git a/src/en/flixscans/AndroidManifest.xml b/src/en/flixscans/AndroidManifest.xml new file mode 100644 index 000000000..30deb7f79 --- /dev/null +++ b/src/en/flixscans/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/src/en/flixscans/build.gradle b/src/en/flixscans/build.gradle new file mode 100644 index 000000000..77b14d881 --- /dev/null +++ b/src/en/flixscans/build.gradle @@ -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" diff --git a/src/en/flixscans/res/mipmap-hdpi/ic_launcher.png b/src/en/flixscans/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..96992a185 Binary files /dev/null and b/src/en/flixscans/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/en/flixscans/res/mipmap-mdpi/ic_launcher.png b/src/en/flixscans/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..bf4daa417 Binary files /dev/null and b/src/en/flixscans/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/en/flixscans/res/mipmap-xhdpi/ic_launcher.png b/src/en/flixscans/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..8beccb9bc Binary files /dev/null and b/src/en/flixscans/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/en/flixscans/res/mipmap-xxhdpi/ic_launcher.png b/src/en/flixscans/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..d09cf7a56 Binary files /dev/null and b/src/en/flixscans/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/en/flixscans/res/mipmap-xxxhdpi/ic_launcher.png b/src/en/flixscans/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..9067ecd9c Binary files /dev/null and b/src/en/flixscans/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/en/flixscans/res/web_hi_res_512.png b/src/en/flixscans/res/web_hi_res_512.png new file mode 100644 index 000000000..8ac2f0947 Binary files /dev/null and b/src/en/flixscans/res/web_hi_res_512.png differ diff --git a/src/en/flixscans/src/eu/kanade/tachiyomi/extension/en/flixscans/FlixScans.kt b/src/en/flixscans/src/eu/kanade/tachiyomi/extension/en/flixscans/FlixScans.kt new file mode 100644 index 000000000..f23c88dc1 --- /dev/null +++ b/src/en/flixscans/src/eu/kanade/tachiyomi/extension/en/flixscans/FlixScans.kt @@ -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 { + 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() + + 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 { + 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>() + 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 = 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 { + return response.parseAs>() + } + + override fun getFilterList(): FilterList { + val filters: MutableList> = 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 { + 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() + + return result.serie.toSManga() + } + + override fun fetchChapterList(manga: SManga): Observable> { + 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 { + val result = response.parseAs>() + + 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>() + + 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 { + val result = response.parseAs() + + return result.chapter.chapterData.webtoon.mapIndexed { i, img -> + Page(i, "", cdnUrl + img) + } + } + + override fun imageUrlParse(response: Response) = throw UnsupportedOperationException("Not Used") + + private inline fun 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/" + } +} diff --git a/src/en/flixscans/src/eu/kanade/tachiyomi/extension/en/flixscans/FlixScansDto.kt b/src/en/flixscans/src/eu/kanade/tachiyomi/extension/en/flixscans/FlixScansDto.kt new file mode 100644 index 000000000..b706977e8 --- /dev/null +++ b/src/en/flixscans/src/eu/kanade/tachiyomi/extension/en/flixscans/FlixScansDto.kt @@ -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( + val data: List, + val meta: PageInfo, +) + +@Serializable +data class PageInfo( + @SerialName("last_page") val lastPage: Int, +) + +@Serializable +data class HomeDto( + val hot: List, + val topWeek: List, + val topMonth: List, + val topAll: List, +) + +@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? = emptyList(), + val status: String?, + val type: String?, + val authors: List? = emptyList(), + val artists: List? = emptyList(), + val genres: List? = 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, +) diff --git a/src/en/flixscans/src/eu/kanade/tachiyomi/extension/en/flixscans/FlixScansGenre.kt b/src/en/flixscans/src/eu/kanade/tachiyomi/extension/en/flixscans/FlixScansGenre.kt new file mode 100644 index 000000000..aaa91cb0b --- /dev/null +++ b/src/en/flixscans/src/eu/kanade/tachiyomi/extension/en/flixscans/FlixScansGenre.kt @@ -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, +) : Filter.Select( + 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, +) : Filter.Group( + 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", + ), +)