Add Les Poroiniens (#11156)

* Add ScanR multi-source

Adds the ScanR multi-source template, adapting code from the BigSolo source

* Migrate BigSolo to ScanR multi-source

Uses the ScanR multi-source template for the extension

* Add Les Poroiniens source

Adds the Les Poroiniens source, which uses the ScanR multisource.

* Use named parameters
This commit is contained in:
Emixam 2025-10-22 07:27:37 +02:00 committed by Draff
parent 80a5052273
commit fa899de7d9
Signed by: Draff
GPG Key ID: E8A89F3211677653
13 changed files with 375 additions and 275 deletions

View File

@ -0,0 +1,5 @@
plugins {
id("lib-multisrc")
}
baseVersionCode = 1

View File

@ -0,0 +1,182 @@
package eu.kanade.tachiyomi.multisrc.scanr
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.online.HttpSource
import eu.kanade.tachiyomi.util.asJsoup
import keiyoushi.utils.parseAs
import okhttp3.Request
import okhttp3.Response
import org.jsoup.nodes.Document
import kotlin.collections.iterator
abstract class ScanR(
override val name: String,
override val baseUrl: String,
final override val lang: String,
private val useHighLowQualityCover: Boolean = false,
private val slugSeparator: String = "-",
) : HttpSource() {
companion object {
private const val SERIES_DATA_SELECTOR = "#series-data-placeholder"
}
override val supportsLatest = false
private val seriesDataCache = mutableMapOf<String, SeriesData>()
// Popular
override fun popularMangaRequest(page: Int): Request {
return GET("$baseUrl/data/config.json", headers)
}
override fun popularMangaParse(response: Response): MangasPage {
return searchMangaParse(response)
}
// Latest
override fun latestUpdatesRequest(page: Int): Request {
throw UnsupportedOperationException()
}
override fun latestUpdatesParse(response: Response): MangasPage {
throw UnsupportedOperationException()
}
// Search
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
val url = if (query.isNotBlank()) {
"$baseUrl/data/config.json#$query"
} else {
"$baseUrl/data/config.json"
}
return GET(url, headers)
}
override fun searchMangaParse(response: Response): MangasPage {
val config = response.parseAs<ConfigResponse>()
val mangaList = mutableListOf<SManga>()
val fragment = response.request.url.fragment
val searchQuery = fragment ?: ""
for (fileName in config.localSeriesFiles) {
val seriesData = fetchSeriesData(fileName)
if (searchQuery.isBlank() || seriesData.title.contains(
searchQuery,
ignoreCase = true,
)
) {
mangaList.add(seriesData.toSManga(useHighLowQualityCover, slugSeparator))
}
}
return MangasPage(mangaList, false)
}
// Details
override fun mangaDetailsParse(response: Response): SManga {
val document = response.asJsoup()
val jsonData = document.selectFirst(SERIES_DATA_SELECTOR)!!.html()
val seriesData = jsonData.parseAs<SeriesData>()
return seriesData.toDetailedSManga(useHighLowQualityCover, slugSeparator)
}
override fun pageListParse(response: Response): List<Page> {
val document = response.asJsoup()
val chapterNumber = document.location().substringAfterLast("/")
val chapterId = extractChapterId(document, chapterNumber)
return fetchChapterPages(chapterId)
}
override fun imageUrlParse(response: Response): String {
throw UnsupportedOperationException()
}
// Chapters
override fun chapterListParse(response: Response): List<SChapter> {
val document = response.asJsoup()
val jsonData = document.selectFirst(SERIES_DATA_SELECTOR)!!.html()
val seriesData = jsonData.parseAs<SeriesData>()
return buildChapterList(seriesData)
}
private fun fetchSeriesData(fileName: String): SeriesData {
val cachedData = seriesDataCache[fileName]
if (cachedData != null) {
return cachedData
}
val fileUrl = "$baseUrl/data/series/$fileName"
val response = client.newCall(GET(fileUrl, headers)).execute()
val seriesData = response.parseAs<SeriesData>()
seriesDataCache[fileName] = seriesData
return seriesData
}
private fun extractChapterId(document: Document, chapterNumber: String): String {
val jsonData = document.selectFirst("#reader-data-placeholder")!!.html()
val readerData = jsonData.parseAs<ReaderData>()
return readerData.series.chapters
?.get(chapterNumber)
?.groups
?.values
?.firstOrNull()
?.substringAfterLast("/")
?: throw NoSuchElementException("Chapter data not found for chapter $chapterNumber")
}
private fun buildChapterList(seriesData: SeriesData): List<SChapter> {
val chapters = seriesData.chapters ?: return emptyList()
val chapterList = mutableListOf<SChapter>()
val multipleChapters = chapters.size > 1
for ((chapterNumber, chapterData) in chapters) {
if (chapterData.licencied) continue
val title = chapterData.title ?: ""
val volumeNumber = chapterData.volume ?: ""
val baseName = if (multipleChapters) {
buildString {
if (volumeNumber.isNotBlank()) append("Vol. $volumeNumber ")
append("Ch. $chapterNumber")
if (title.isNotBlank()) append(" $title")
}
} else {
if (title.isNotBlank()) "One Shot $title" else "One Shot"
}
val chapter = SChapter.create().apply {
name = baseName
url = "/${toSlug(seriesData.title)}/$chapterNumber"
chapter_number = chapterNumber.toFloatOrNull() ?: -1f
date_upload = chapterData.lastUpdated * 1000L
}
chapterList.add(chapter)
}
return chapterList.sortedByDescending { it.chapter_number }
}
private fun fetchChapterPages(chapterId: String): List<Page> {
val pagesResponse =
client.newCall(GET("$baseUrl/api/imgchest-chapter-pages?id=$chapterId", headers))
.execute()
val pages = pagesResponse.parseAs<List<PageData>>()
return pages.mapIndexed { index, pageData ->
Page(index, imageUrl = pageData.link)
}
}
}

View File

@ -0,0 +1,157 @@
package eu.kanade.tachiyomi.multisrc.scanr
import eu.kanade.tachiyomi.source.model.SManga
import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.JsonDecoder
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.longOrNull
object SafeLongDeserializer : KSerializer<Long> {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("SafeLong", PrimitiveKind.LONG)
override fun serialize(encoder: Encoder, value: Long) {
encoder.encodeLong(value)
}
override fun deserialize(decoder: Decoder): Long {
val jsonDecoder = decoder as? JsonDecoder ?: return try {
decoder.decodeLong()
} catch (_: Exception) {
0L
}
return try {
val element = jsonDecoder.decodeJsonElement()
when (element) {
is JsonPrimitive -> {
element.longOrNull ?: element.content.toLongOrNull() ?: 0L
}
else -> 0L
}
} catch (_: Exception) {
0L
}
}
}
@Serializable
data class ConfigResponse(
@SerialName("LOCAL_SERIES_FILES")
val localSeriesFiles: List<String>,
)
@Serializable
data class SeriesData(
val title: String,
val description: String?,
val artist: String?,
val author: String?,
val cover: String?,
@SerialName("cover_low")
val coverLow: String?,
@SerialName("cover_hq")
val coverHq: String?,
val tags: List<String>?,
@SerialName("release_status")
val releaseStatus: String?,
@SerialName("alternative_titles")
val alternativeTitles: List<String>?,
val chapters: Map<String, ChapterData>?,
)
@Serializable
data class ReaderData(
val series: SeriesData,
)
@Serializable
data class ChapterData(
val title: String?,
val volume: String?,
@SerialName("last_updated")
@Serializable(with = SafeLongDeserializer::class)
val lastUpdated: Long = 0L,
val licencied: Boolean = false,
val groups: Map<String, String>?,
)
@Serializable
data class PageData(
val link: String,
)
// DTO to SManga extension functions
fun SeriesData.toSManga(useLowQuality: Boolean = false, slugSeparator: String): SManga =
SManga.create().apply {
title = this@toSManga.title
artist = this@toSManga.artist
author = this@toSManga.author
thumbnail_url = if (useLowQuality) this@toSManga.coverHq else this@toSManga.cover
url = "/${toSlug(this@toSManga.title, slugSeparator)}"
}
fun SeriesData.toDetailedSManga(useHighQuality: Boolean = false, slugSeparator: String): SManga =
SManga.create().apply {
title = this@toDetailedSManga.title
val baseDescription = this@toDetailedSManga.description.let {
if (it?.contains("Pas de synopsis", ignoreCase = true) == true) null else it
}
val altTitles = this@toDetailedSManga.alternativeTitles
description = if (!altTitles.isNullOrEmpty()) {
buildString {
if (!baseDescription.isNullOrBlank()) {
append(baseDescription)
append("\n\n")
}
append("Alternative Titles:\n")
append(altTitles.joinToString("\n") { "$it" })
}
} else {
baseDescription
}
artist = this@toDetailedSManga.artist
author = this@toDetailedSManga.author
genre = this@toDetailedSManga.tags?.joinToString(", ") ?: ""
status = when (this@toDetailedSManga.releaseStatus) {
"En cours" -> SManga.ONGOING
"Finis", "Fini" -> SManga.COMPLETED
else -> SManga.UNKNOWN
}
thumbnail_url =
if (useHighQuality) this@toDetailedSManga.coverHq else this@toDetailedSManga.cover
url = "/${toSlug(this@toDetailedSManga.title, slugSeparator)}"
}
// Utility function for slug generation
// URLs are manually calculated using a slugify function
fun toSlug(input: String?, slugSeparator: String = "-"): String {
if (input == null) return ""
val accentsMap = mapOf(
'à' to 'a', 'á' to 'a', 'â' to 'a', 'ä' to 'a', 'ã' to 'a',
'è' to 'e', 'é' to 'e', 'ê' to 'e', 'ë' to 'e',
'ì' to 'i', 'í' to 'i', 'î' to 'i', 'ï' to 'i',
'ò' to 'o', 'ó' to 'o', 'ô' to 'o', 'ö' to 'o', 'õ' to 'o',
'ù' to 'u', 'ú' to 'u', 'û' to 'u', 'ü' to 'u',
'ç' to 'c', 'ñ' to 'n',
)
return input
.lowercase()
.map { accentsMap[it] ?: it }
.joinToString("")
.replace("[^a-z0-9\\s-]".toRegex(), "")
.replace("\\s".toRegex(), slugSeparator)
}

View File

@ -1,7 +1,9 @@
ext { ext {
extName = 'BigSolo' extName = 'BigSolo'
extClass = '.BigSolo' extClass = '.BigSolo'
extVersionCode = 1 themePkg = 'scanr'
baseUrl = 'https://www.bigsolo.org'
overrideVersionCode = 1
isNsfw = false isNsfw = false
} }

View File

@ -1,178 +1,10 @@
package eu.kanade.tachiyomi.extension.fr.bigsolo package eu.kanade.tachiyomi.extension.fr.bigsolo
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.multisrc.scanr.ScanR
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 eu.kanade.tachiyomi.util.asJsoup
import keiyoushi.utils.parseAs
import okhttp3.Request
import okhttp3.Response
import org.jsoup.nodes.Document
class BigSolo : HttpSource() { class BigSolo : ScanR(
name = "Big Solo",
companion object { baseUrl = "https://bigsolo.org",
private const val SERIES_DATA_SELECTOR = "#series-data-placeholder" lang = "fr",
} useHighLowQualityCover = true,
)
override val name = "BigSolo"
override val baseUrl = "https://bigsolo.org"
override val lang = "fr"
override val supportsLatest = false
private val seriesDataCache = mutableMapOf<String, SeriesData>()
// Popular
override fun popularMangaRequest(page: Int): Request {
return GET("$baseUrl/data/config.json", headers)
}
override fun popularMangaParse(response: Response): MangasPage {
return searchMangaParse(response)
}
// Latest
override fun latestUpdatesRequest(page: Int): Request {
throw UnsupportedOperationException()
}
override fun latestUpdatesParse(response: Response): MangasPage {
throw UnsupportedOperationException()
}
// Search
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
val url = if (query.isNotBlank()) {
"$baseUrl/data/config.json#$query"
} else {
"$baseUrl/data/config.json"
}
return GET(url, headers)
}
override fun searchMangaParse(response: Response): MangasPage {
val config = response.parseAs<ConfigResponse>()
val mangaList = mutableListOf<SManga>()
val fragment = response.request.url.fragment
val searchQuery = fragment ?: ""
for (fileName in config.localSeriesFiles) {
val seriesData = fetchSeriesData(fileName)
if (searchQuery.isBlank() || seriesData.title.contains(
searchQuery,
ignoreCase = true,
)
) {
mangaList.add(seriesData.toSManga())
}
}
return MangasPage(mangaList, false)
}
// Details
override fun mangaDetailsParse(response: Response): SManga {
val document = response.asJsoup()
val jsonData = document.selectFirst(SERIES_DATA_SELECTOR)!!.html()
val seriesData = jsonData.parseAs<SeriesData>()
return seriesData.toDetailedSManga()
}
override fun pageListParse(response: Response): List<Page> {
val document = response.asJsoup()
val chapterNumber = document.location().substringAfterLast("/")
val chapterId = extractChapterId(document, chapterNumber)
return fetchChapterPages(chapterId)
}
override fun imageUrlParse(response: Response): String {
throw UnsupportedOperationException()
}
// Chapters
override fun chapterListParse(response: Response): List<SChapter> {
val document = response.asJsoup()
val jsonData = document.selectFirst(SERIES_DATA_SELECTOR)!!.html()
val seriesData = jsonData.parseAs<SeriesData>()
return buildChapterList(seriesData)
}
private fun fetchSeriesData(fileName: String): SeriesData {
val cachedData = seriesDataCache[fileName]
if (cachedData != null) {
return cachedData
}
val fileUrl = "$baseUrl/data/series/$fileName"
val response = client.newCall(GET(fileUrl, headers)).execute()
val seriesData = response.parseAs<SeriesData>()
seriesDataCache[fileName] = seriesData
return seriesData
}
private fun extractChapterId(document: Document, chapterNumber: String): String {
val jsonData = document.selectFirst("#reader-data-placeholder")!!.html()
val readerData = jsonData.parseAs<ReaderData>()
return readerData.series.chapters
?.get(chapterNumber)
?.groups
?.values
?.firstOrNull()
?.substringAfterLast("/")
?: throw NoSuchElementException("Chapter data not found for chapter $chapterNumber")
}
private fun buildChapterList(seriesData: SeriesData): List<SChapter> {
val chapters = seriesData.chapters ?: return emptyList()
val chapterList = mutableListOf<SChapter>()
val multipleChapters = chapters.size > 1
for ((chapterNumber, chapterData) in chapters) {
if (chapterData.licencied) continue
val title = chapterData.title ?: ""
val volumeNumber = chapterData.volume ?: ""
val baseName = if (multipleChapters) {
buildString {
if (volumeNumber.isNotBlank()) append("Vol. $volumeNumber ")
append("Ch. $chapterNumber")
if (title.isNotBlank()) append(" $title")
}
} else {
if (title.isNotBlank()) "One Shot $title" else "One Shot"
}
val chapter = SChapter.create().apply {
name = baseName
url = "/${toSlug(seriesData.title)}/$chapterNumber"
chapter_number = chapterNumber.toFloatOrNull() ?: -1f
date_upload = (chapterData.lastUpdated ?: 0) * 1000L
}
chapterList.add(chapter)
}
return chapterList.sortedByDescending { it.chapter_number }
}
private fun fetchChapterPages(chapterId: String): List<Page> {
val pagesResponse =
client.newCall(GET("$baseUrl/api/imgchest-chapter-pages?id=$chapterId", headers))
.execute()
val pages = pagesResponse.parseAs<List<PageData>>()
return pages.mapIndexed { index, pageData ->
Page(index, imageUrl = pageData.link)
}
}
}

View File

@ -1,99 +0,0 @@
package eu.kanade.tachiyomi.extension.fr.bigsolo
import eu.kanade.tachiyomi.source.model.SManga
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Data Transfer Objects for BigSolo extension
*/
@Serializable
data class ConfigResponse(
@SerialName("LOCAL_SERIES_FILES")
val localSeriesFiles: List<String>,
)
@Serializable
data class SeriesData(
val title: String,
val description: String?,
val artist: String?,
val author: String?,
@SerialName("cover_low")
val coverLow: String?,
@SerialName("cover_hq")
val coverHq: String?,
val tags: List<String>?,
@SerialName("release_status")
val releaseStatus: String?,
val chapters: Map<String, ChapterData>?,
)
@Serializable
data class ReaderData(
val series: SeriesData,
)
@Serializable
data class ChapterData(
val title: String?,
val volume: String?,
@SerialName("last_updated")
val lastUpdated: Long?,
val licencied: Boolean = false,
val groups: Map<String, String>?,
)
@Serializable
data class PageData(
val link: String,
)
// DTO to SManga extension functions
fun SeriesData.toSManga(): SManga = SManga.create().apply {
title = this@toSManga.title
artist = this@toSManga.artist
author = this@toSManga.author
thumbnail_url = this@toSManga.coverLow
url = "/${toSlug(this@toSManga.title)}"
}
fun SeriesData.toDetailedSManga(): SManga = SManga.create().apply {
title = this@toDetailedSManga.title
description = this@toDetailedSManga.description
artist = this@toDetailedSManga.artist
author = this@toDetailedSManga.author
genre = this@toDetailedSManga.tags?.joinToString(", ") ?: ""
status = when (this@toDetailedSManga.releaseStatus) {
"En cours" -> SManga.ONGOING
"Finis", "Fini" -> SManga.COMPLETED
else -> SManga.UNKNOWN
}
thumbnail_url = this@toDetailedSManga.coverHq
url = "/${toSlug(this@toDetailedSManga.title)}"
}
// Utility function for slug generation
// URLs are manually calculated using a slugify function
fun toSlug(input: String?): String {
if (input == null) return ""
val accentsMap = mapOf(
'à' to 'a', 'á' to 'a', 'â' to 'a', 'ä' to 'a', 'ã' to 'a',
'è' to 'e', 'é' to 'e', 'ê' to 'e', 'ë' to 'e',
'ì' to 'i', 'í' to 'i', 'î' to 'i', 'ï' to 'i',
'ò' to 'o', 'ó' to 'o', 'ô' to 'o', 'ö' to 'o', 'õ' to 'o',
'ù' to 'u', 'ú' to 'u', 'û' to 'u', 'ü' to 'u',
'ç' to 'c', 'ñ' to 'n',
)
return input
.lowercase()
.map { accentsMap[it] ?: it }
.joinToString("")
.replace("[^a-z0-9\\s-]".toRegex(), "")
.replace("\\s+".toRegex(), "-")
.replace("-+".toRegex(), "-")
.trim('-')
}

View File

@ -0,0 +1,10 @@
ext {
extName = 'Les Poroiniens'
extClass = '.LesPoroiniens'
themePkg = 'scanr'
baseUrl = 'https://lesporoiniens.org'
overrideVersionCode = 0
isNsfw = true
}
apply from: "$rootDir/common.gradle"

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

View File

@ -0,0 +1,11 @@
package eu.kanade.tachiyomi.extension.fr.lesporoiniens
import eu.kanade.tachiyomi.multisrc.scanr.ScanR
class LesPoroiniens : ScanR(
name = "Les Poroiniens",
baseUrl = "https://lesporoiniens.org",
lang = "fr",
useHighLowQualityCover = false,
slugSeparator = "_",
)