Migration of PhoenixScans (#8191)
* PhenixScans: Add support for new site * Search, Filter, Genres * Cleaning Co-authored-by: Vetle Ledaal <vetle.ledaal@gmail.com> Co-authored-by: Luqman <16263232+Riztard@users.noreply.github.com> Co-authored-by: AwkwardPeak7 <48650614+AwkwardPeak7@users.noreply.github.com> * Status * Variable formatting * Move Filters to a separate file --------- Co-authored-by: Vetle Ledaal <vetle.ledaal@gmail.com> Co-authored-by: Luqman <16263232+Riztard@users.noreply.github.com> Co-authored-by: AwkwardPeak7 <48650614+AwkwardPeak7@users.noreply.github.com>
This commit is contained in:
parent
09d9b33080
commit
9efc599e9c
@ -1,9 +1,8 @@
|
|||||||
ext {
|
ext {
|
||||||
extName = 'PhenixScans'
|
extName = 'PhenixScans'
|
||||||
extClass = '.PhenixScans'
|
extClass = '.PhenixScans'
|
||||||
themePkg = 'mangathemesia'
|
baseUrl = 'https://phenix-scans.com'
|
||||||
baseUrl = 'https://phenixscans.fr'
|
extVersionCode = 33
|
||||||
overrideVersionCode = 2
|
|
||||||
isNsfw = false
|
isNsfw = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,24 +1,198 @@
|
|||||||
package eu.kanade.tachiyomi.extension.fr.phenixscans
|
package eu.kanade.tachiyomi.extension.fr.phenixscans
|
||||||
|
|
||||||
import eu.kanade.tachiyomi.multisrc.mangathemesia.MangaThemesia
|
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.model.SManga
|
||||||
import org.jsoup.nodes.Document
|
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||||
|
import keiyoushi.utils.parseAs
|
||||||
|
import keiyoushi.utils.tryParse
|
||||||
|
import kotlinx.serialization.json.float
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.Response
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
class PhenixScans : MangaThemesia("PhenixScans", "https://phenixscans.fr", "fr", dateFormat = SimpleDateFormat("dd MMMM yyyy", Locale.FRENCH)) {
|
class PhenixScans : HttpSource() {
|
||||||
override val seriesAuthorSelector = ".imptdt:contains(Auteur) i, .fmed b:contains(Auteur)+span"
|
override val baseUrl = "https://phenix-scans.com"
|
||||||
override val seriesStatusSelector = ".imptdt:contains(Statut) i"
|
private val apiBaseUrl = "https://api.phenix-scans.com"
|
||||||
|
override val lang = "fr"
|
||||||
|
override val name = "Phenix Scans"
|
||||||
|
override val supportsLatest = true
|
||||||
|
override val versionId = 2
|
||||||
|
|
||||||
override fun String?.parseStatus(): Int = when {
|
private val simpleDateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.FRENCH)
|
||||||
this == null -> SManga.UNKNOWN
|
|
||||||
this.contains("En Cours", ignoreCase = true) -> SManga.ONGOING
|
// ============================== Popular ===============================
|
||||||
this.contains("Terminé", ignoreCase = true) -> SManga.COMPLETED
|
override fun popularMangaRequest(page: Int): Request = GET("$apiBaseUrl/front/homepage?section=top", headers)
|
||||||
|
|
||||||
|
override fun popularMangaParse(response: Response): MangasPage {
|
||||||
|
val data = response.parseAs<TopMangaDto>()
|
||||||
|
|
||||||
|
val mangas = data.top.map {
|
||||||
|
SManga.create().apply {
|
||||||
|
title = it.title
|
||||||
|
thumbnail_url = "$apiBaseUrl/${it.coverImage}" // Possibility of using ?width=75 and cdn.[...]/?url=
|
||||||
|
url = it.slug
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return MangasPage(mangas, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================== Latest ===============================
|
||||||
|
override fun latestUpdatesRequest(page: Int): Request {
|
||||||
|
val apiUrl = "$apiBaseUrl/front/homepage?page=$page§ion=latest&limit=12"
|
||||||
|
|
||||||
|
return GET(apiUrl, headers)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseMangaList(mangaList: List<LatestMangaItemDto>): List<SManga> {
|
||||||
|
return mangaList.map {
|
||||||
|
SManga.create().apply {
|
||||||
|
title = it.title
|
||||||
|
thumbnail_url = "$apiBaseUrl/${it.coverImage}" // Possibility of using ?width=75
|
||||||
|
url = it.slug
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun latestUpdatesParse(response: Response): MangasPage {
|
||||||
|
val data = response.parseAs<LatestMangaDto>()
|
||||||
|
|
||||||
|
val mangas = parseMangaList(data.latest)
|
||||||
|
|
||||||
|
val hasNextPage = data.pagination.currentPage < data.pagination.totalPages
|
||||||
|
|
||||||
|
return MangasPage(mangas, hasNextPage)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================== Search ===============================
|
||||||
|
|
||||||
|
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||||
|
if (query.isNotEmpty()) {
|
||||||
|
// No limits here
|
||||||
|
val apiUrl = "$apiBaseUrl/front/manga/search".toHttpUrl().newBuilder()
|
||||||
|
.addQueryParameter("query", query)
|
||||||
|
.build()
|
||||||
|
return GET(apiUrl, headers)
|
||||||
|
}
|
||||||
|
|
||||||
|
val url = "$apiBaseUrl/front/manga".toHttpUrl().newBuilder()
|
||||||
|
filters.forEach { filter ->
|
||||||
|
when (filter) {
|
||||||
|
is SortFilter -> {
|
||||||
|
url.addQueryParameter("sort", filter.toUriPart())
|
||||||
|
}
|
||||||
|
is GenreFilter -> {
|
||||||
|
val genres = filter.state
|
||||||
|
.filter { it.state }
|
||||||
|
.map { it.id }
|
||||||
|
|
||||||
|
url.addQueryParameter("genre", genres.joinToString(","))
|
||||||
|
}
|
||||||
|
is TypeFilter -> {
|
||||||
|
url.addQueryParameter("type", filter.toUriPart())
|
||||||
|
}
|
||||||
|
is StatusFilter -> {
|
||||||
|
url.addQueryParameter("status", filter.toUriPart())
|
||||||
|
}
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
url.addQueryParameter("limit", "18") // Be cool on the API
|
||||||
|
url.addQueryParameter("page", page.toString())
|
||||||
|
|
||||||
|
return GET(url.build(), headers)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun searchMangaParse(response: Response): MangasPage {
|
||||||
|
val data = response.parseAs<SearchResultsDto>()
|
||||||
|
|
||||||
|
val hasNextPage = (data.pagination?.page ?: 0) < (data.pagination?.totalPages ?: 0)
|
||||||
|
|
||||||
|
val mangas = parseMangaList(data.mangas)
|
||||||
|
|
||||||
|
return MangasPage(mangas, hasNextPage)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getFilterList(): FilterList = getGlobalFilterList(apiBaseUrl, client, headers)
|
||||||
|
|
||||||
|
// =============================== Manga ==================================
|
||||||
|
|
||||||
|
override fun mangaDetailsRequest(manga: SManga): Request {
|
||||||
|
val apiUrl = "$apiBaseUrl/front/manga/${manga.url}"
|
||||||
|
|
||||||
|
return GET(apiUrl, headers)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun mangaDetailsParse(response: Response): SManga {
|
||||||
|
val data = response.parseAs<MangaDetailDto>()
|
||||||
|
|
||||||
|
return SManga.create().apply {
|
||||||
|
title = data.manga.title
|
||||||
|
thumbnail_url = "$apiBaseUrl/${data.manga.coverImage}"
|
||||||
|
url = data.manga.slug
|
||||||
|
description = data.manga.synopsis
|
||||||
|
status = when (data.manga.status) {
|
||||||
|
"Ongoing" -> SManga.ONGOING
|
||||||
|
"Hiatus" -> SManga.ON_HIATUS
|
||||||
|
"Completed" -> SManga.COMPLETED
|
||||||
else -> SManga.UNKNOWN
|
else -> SManga.UNKNOWN
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun mangaDetailsParse(document: Document): SManga =
|
override fun getMangaUrl(manga: SManga): String {
|
||||||
super.mangaDetailsParse(document).apply {
|
return "$baseUrl/manga/${manga.url}"
|
||||||
status = document.select(seriesStatusSelector).text().parseStatus()
|
}
|
||||||
|
|
||||||
|
// ============================== Chapters ==============================
|
||||||
|
|
||||||
|
override fun chapterListRequest(manga: SManga): Request = mangaDetailsRequest(manga)
|
||||||
|
|
||||||
|
override fun chapterListParse(response: Response): List<SChapter> {
|
||||||
|
val data = response.parseAs<MangaDetailDto>()
|
||||||
|
|
||||||
|
return data.chapters.map {
|
||||||
|
SChapter.create().apply {
|
||||||
|
chapter_number = it.number.float
|
||||||
|
date_upload = simpleDateFormat.tryParse(it.createdAt)
|
||||||
|
name = "Chapter ${it.number}"
|
||||||
|
url = "${data.manga.slug}/${it.number}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getChapterUrl(chapter: SChapter): String {
|
||||||
|
val slug = chapter.url.substringBeforeLast("/")
|
||||||
|
val chapterNumber = chapter.url.substringAfterLast("/")
|
||||||
|
return "$baseUrl/manga/$slug/chapitre/$chapterNumber"
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================== Pages ================================
|
||||||
|
|
||||||
|
override fun imageUrlParse(response: Response): String {
|
||||||
|
throw UnsupportedOperationException()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun pageListRequest(chapter: SChapter): Request {
|
||||||
|
val slug = chapter.url.substringBeforeLast("/")
|
||||||
|
val chapterNumber = chapter.url.substringAfterLast("/")
|
||||||
|
|
||||||
|
val apiUrl = "$apiBaseUrl/front/manga/$slug/chapter/$chapterNumber"
|
||||||
|
|
||||||
|
return GET(apiUrl, headers)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun pageListParse(response: Response): List<Page> {
|
||||||
|
val data = response.parseAs<ChapterContentDto>()
|
||||||
|
|
||||||
|
return data.chapter.images.mapIndexed { index, url ->
|
||||||
|
Page(index, imageUrl = "$apiBaseUrl/$url")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,98 @@
|
|||||||
|
package eu.kanade.tachiyomi.extension.fr.phenixscans
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
|
||||||
|
// ---------------------------
|
||||||
|
// 1. SEARCH & PAGINATION DTOs
|
||||||
|
// ---------------------------
|
||||||
|
@Serializable
|
||||||
|
class SearchResultsDto(
|
||||||
|
val mangas: List<LatestMangaItemDto>,
|
||||||
|
val pagination: PaginationFilterDto? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class PaginationFilterDto(
|
||||||
|
val page: Int,
|
||||||
|
val totalPages: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---------------------------
|
||||||
|
// 2. MANGA DETAILS & CHAPTER DTOs
|
||||||
|
// ---------------------------
|
||||||
|
@Serializable
|
||||||
|
class MangaInfoDto(
|
||||||
|
val title: String,
|
||||||
|
val coverImage: String? = null,
|
||||||
|
val slug: String,
|
||||||
|
val synopsis: String? = "",
|
||||||
|
val status: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class ChapterInfoDto(
|
||||||
|
val number: JsonPrimitive,
|
||||||
|
val createdAt: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class MangaDetailDto(
|
||||||
|
val manga: MangaInfoDto,
|
||||||
|
val chapters: List<ChapterInfoDto>,
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---------------------------
|
||||||
|
// 3. LATEST & TOP MANGA DTOs
|
||||||
|
// ---------------------------
|
||||||
|
@Serializable
|
||||||
|
class LatestMangaItemDto(
|
||||||
|
val title: String,
|
||||||
|
val coverImage: String,
|
||||||
|
val slug: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class PaginationDto(
|
||||||
|
val currentPage: Int,
|
||||||
|
val totalPages: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class LatestMangaDto(
|
||||||
|
val pagination: PaginationDto,
|
||||||
|
val latest: List<LatestMangaItemDto>,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class TopMangaDto(
|
||||||
|
val top: List<MangaInfoDto>,
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---------------------------
|
||||||
|
// 4. CHAPTER READING DTOs
|
||||||
|
// ---------------------------
|
||||||
|
@Serializable
|
||||||
|
class ChapterImagesDto(
|
||||||
|
val images: List<String>,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class ChapterContentDto(
|
||||||
|
val chapter: ChapterImagesDto,
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---------------------------
|
||||||
|
// 5. GENRE DTOs
|
||||||
|
// ---------------------------
|
||||||
|
@Serializable
|
||||||
|
class GenreDto(
|
||||||
|
@SerialName("_id") val id: String,
|
||||||
|
val name: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class GenreListDto(
|
||||||
|
val data: List<GenreDto>,
|
||||||
|
)
|
@ -0,0 +1,112 @@
|
|||||||
|
package eu.kanade.tachiyomi.extension.fr.phenixscans
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.network.GET
|
||||||
|
import eu.kanade.tachiyomi.source.model.Filter
|
||||||
|
import eu.kanade.tachiyomi.source.model.FilterList
|
||||||
|
import keiyoushi.utils.parseAs
|
||||||
|
import okhttp3.Headers
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import kotlin.concurrent.thread
|
||||||
|
|
||||||
|
// ========================= Sorting & Filtering ==========================
|
||||||
|
|
||||||
|
class SortFilter : UriPartFilter(
|
||||||
|
"Sort by",
|
||||||
|
arrayOf(
|
||||||
|
Pair("Alphabetic", "title"),
|
||||||
|
Pair("Rating", "rating"),
|
||||||
|
Pair("Last updated", "updatedAt"),
|
||||||
|
Pair("Chapter number", "chapters"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
class Tag(name: String, val id: String) : Filter.CheckBox(name)
|
||||||
|
|
||||||
|
class GenreFilter(genres: List<Tag>) : Filter.Group<Tag>(
|
||||||
|
"Genres",
|
||||||
|
genres,
|
||||||
|
)
|
||||||
|
|
||||||
|
class StatusFilter : UriPartFilter(
|
||||||
|
"Status",
|
||||||
|
arrayOf(
|
||||||
|
Pair("All status", ""),
|
||||||
|
Pair("Ongoing", "Ongoing"),
|
||||||
|
Pair("On Hiatus", "Hiatus"),
|
||||||
|
Pair("Completed", "Completed"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
class TypeFilter : UriPartFilter(
|
||||||
|
"Type",
|
||||||
|
arrayOf(
|
||||||
|
Pair("Any type", ""),
|
||||||
|
Pair("Manga", "Manga"),
|
||||||
|
Pair("Manhwa", "Manhwa"),
|
||||||
|
Pair("Manhua", "Manhua"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun getGlobalFilterList(apiBaseUrl: String, client: OkHttpClient, headers: Headers): FilterList {
|
||||||
|
fetchFilters(apiBaseUrl, client, headers)
|
||||||
|
val filters = mutableListOf<Filter<*>>(
|
||||||
|
Filter.Header("Filters are not compatible with text-based search"),
|
||||||
|
Filter.Separator(),
|
||||||
|
|
||||||
|
Filter.Header("Type"),
|
||||||
|
TypeFilter(),
|
||||||
|
Filter.Separator(),
|
||||||
|
|
||||||
|
Filter.Header("Sort by"),
|
||||||
|
SortFilter(),
|
||||||
|
Filter.Separator(),
|
||||||
|
|
||||||
|
Filter.Header("Status"),
|
||||||
|
StatusFilter(),
|
||||||
|
Filter.Separator(),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (filtersState == FiltersState.FETCHED) {
|
||||||
|
filters += listOf(
|
||||||
|
Filter.Separator(),
|
||||||
|
Filter.Header("Filter by genres"),
|
||||||
|
GenreFilter(genresList),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
filters += listOf(
|
||||||
|
Filter.Separator(),
|
||||||
|
Filter.Header("Click on 'Reset' to load missing filters"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return FilterList(filters)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var genresList: List<Tag> = emptyList()
|
||||||
|
private var fetchFiltersAttempts = 0
|
||||||
|
private var filtersState = FiltersState.NOT_FETCHED
|
||||||
|
|
||||||
|
private fun fetchFilters(apiBaseUrl: String, client: OkHttpClient, headers: Headers) {
|
||||||
|
if (filtersState != FiltersState.NOT_FETCHED || fetchFiltersAttempts >= 3) return
|
||||||
|
filtersState = FiltersState.FETCHING
|
||||||
|
fetchFiltersAttempts++
|
||||||
|
thread {
|
||||||
|
try {
|
||||||
|
val response = client.newCall(GET("$apiBaseUrl/genres", headers)).execute()
|
||||||
|
val filters = response.parseAs<GenreListDto>()
|
||||||
|
|
||||||
|
genresList = filters.data.map { Tag(it.name, it.id) }
|
||||||
|
|
||||||
|
filtersState = FiltersState.FETCHED
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
filtersState = FiltersState.NOT_FETCHED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
open class UriPartFilter(displayName: String, val vals: Array<Pair<String, String>>) :
|
||||||
|
Filter.Select<String>(displayName, vals.map { it.first }.toTypedArray()) {
|
||||||
|
fun toUriPart() = vals[state].second
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum class FiltersState { NOT_FETCHED, FETCHING, FETCHED }
|
Loading…
x
Reference in New Issue
Block a user