Add BigSolo (#10992)

* Adds BigSolo source extension

* Improves BigSolo extension

Improves the BigSolo extension by introducing DTOs for data handling.

Enhances error handling and streamlines the parsing of data from JSON responses.

* Refactors BigSolo extension

Migrates BigSolo extension from ParsedHttpSource to HttpSource.

Utilizes keiyoushi.utils.parseAs for JSON parsing.

Implements in-memory caching for series data to reduce redundant calls.

Passes the search query as URL fragment.

* Simplifies DTO and uses non-null assertions
This commit is contained in:
Emixsan 2025-10-15 14:45:02 +02:00 committed by Draff
parent 24d5d8920b
commit b838d8b34b
Signed by: Draff
GPG Key ID: E8A89F3211677653
8 changed files with 285 additions and 0 deletions

View File

@ -0,0 +1,8 @@
ext {
extName = 'BigSolo'
extClass = '.BigSolo'
extVersionCode = 1
isNsfw = false
}
apply from: "$rootDir/common.gradle"

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

View File

@ -0,0 +1,178 @@
package eu.kanade.tachiyomi.extension.fr.bigsolo
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
class BigSolo : HttpSource() {
companion object {
private const val SERIES_DATA_SELECTOR = "#series-data-placeholder"
}
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

@ -0,0 +1,99 @@
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('-')
}