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:
Romain 2025-03-28 02:00:29 +01:00 committed by Draff
parent 09d9b33080
commit 9efc599e9c
No known key found for this signature in database
GPG Key ID: E8A89F3211677653
4 changed files with 399 additions and 16 deletions

View File

@ -1,9 +1,8 @@
ext {
extName = 'PhenixScans'
extClass = '.PhenixScans'
themePkg = 'mangathemesia'
baseUrl = 'https://phenixscans.fr'
overrideVersionCode = 2
baseUrl = 'https://phenix-scans.com'
extVersionCode = 33
isNsfw = false
}

View File

@ -1,24 +1,198 @@
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 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.util.Locale
class PhenixScans : MangaThemesia("PhenixScans", "https://phenixscans.fr", "fr", dateFormat = SimpleDateFormat("dd MMMM yyyy", Locale.FRENCH)) {
override val seriesAuthorSelector = ".imptdt:contains(Auteur) i, .fmed b:contains(Auteur)+span"
override val seriesStatusSelector = ".imptdt:contains(Statut) i"
class PhenixScans : HttpSource() {
override val baseUrl = "https://phenix-scans.com"
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 {
this == null -> SManga.UNKNOWN
this.contains("En Cours", ignoreCase = true) -> SManga.ONGOING
this.contains("Terminé", ignoreCase = true) -> SManga.COMPLETED
else -> SManga.UNKNOWN
private val simpleDateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.FRENCH)
// ============================== Popular ===============================
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)
}
override fun mangaDetailsParse(document: Document): SManga =
super.mangaDetailsParse(document).apply {
status = document.select(seriesStatusSelector).text().parseStatus()
// =============================== Latest ===============================
override fun latestUpdatesRequest(page: Int): Request {
val apiUrl = "$apiBaseUrl/front/homepage?page=$page&section=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
}
}
}
override fun getMangaUrl(manga: SManga): String {
return "$baseUrl/manga/${manga.url}"
}
// ============================== 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")
}
}
}

View File

@ -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>,
)

View File

@ -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 }