diff --git a/src/fr/phenixscans/build.gradle b/src/fr/phenixscans/build.gradle index 1407d2678..b346d4d47 100644 --- a/src/fr/phenixscans/build.gradle +++ b/src/fr/phenixscans/build.gradle @@ -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 } diff --git a/src/fr/phenixscans/src/eu/kanade/tachiyomi/extension/fr/phenixscans/PhenixScans.kt b/src/fr/phenixscans/src/eu/kanade/tachiyomi/extension/fr/phenixscans/PhenixScans.kt index 74050db07..38e8bdf30 100644 --- a/src/fr/phenixscans/src/eu/kanade/tachiyomi/extension/fr/phenixscans/PhenixScans.kt +++ b/src/fr/phenixscans/src/eu/kanade/tachiyomi/extension/fr/phenixscans/PhenixScans.kt @@ -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() + + 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§ion=latest&limit=12" + + return GET(apiUrl, headers) + } + + private fun parseMangaList(mangaList: List): List { + 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() + + 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() + + 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() + + 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 { + val data = response.parseAs() + + 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 { + val data = response.parseAs() + + return data.chapter.images.mapIndexed { index, url -> + Page(index, imageUrl = "$apiBaseUrl/$url") + } + } } diff --git a/src/fr/phenixscans/src/eu/kanade/tachiyomi/extension/fr/phenixscans/PhenixScansDto.kt b/src/fr/phenixscans/src/eu/kanade/tachiyomi/extension/fr/phenixscans/PhenixScansDto.kt new file mode 100644 index 000000000..69db5a731 --- /dev/null +++ b/src/fr/phenixscans/src/eu/kanade/tachiyomi/extension/fr/phenixscans/PhenixScansDto.kt @@ -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, + 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, +) + +// --------------------------- +// 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, +) + +@Serializable +class TopMangaDto( + val top: List, +) + +// --------------------------- +// 4. CHAPTER READING DTOs +// --------------------------- +@Serializable +class ChapterImagesDto( + val images: List, +) + +@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, +) diff --git a/src/fr/phenixscans/src/eu/kanade/tachiyomi/extension/fr/phenixscans/PhenixScansFilter.kt b/src/fr/phenixscans/src/eu/kanade/tachiyomi/extension/fr/phenixscans/PhenixScansFilter.kt new file mode 100644 index 000000000..00aed82ea --- /dev/null +++ b/src/fr/phenixscans/src/eu/kanade/tachiyomi/extension/fr/phenixscans/PhenixScansFilter.kt @@ -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) : Filter.Group( + "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.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 = 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() + + 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>) : + Filter.Select(displayName, vals.map { it.first }.toTypedArray()) { + fun toUriPart() = vals[state].second +} + +private enum class FiltersState { NOT_FETCHED, FETCHING, FETCHED }