diff --git a/src/fr/bigsolo/build.gradle b/src/fr/bigsolo/build.gradle new file mode 100644 index 000000000..9c7da1d17 --- /dev/null +++ b/src/fr/bigsolo/build.gradle @@ -0,0 +1,8 @@ +ext { + extName = 'BigSolo' + extClass = '.BigSolo' + extVersionCode = 1 + isNsfw = false +} + +apply from: "$rootDir/common.gradle" diff --git a/src/fr/bigsolo/res/mipmap-hdpi/ic_launcher.png b/src/fr/bigsolo/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..c149f5a03 Binary files /dev/null and b/src/fr/bigsolo/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/fr/bigsolo/res/mipmap-mdpi/ic_launcher.png b/src/fr/bigsolo/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..80192387c Binary files /dev/null and b/src/fr/bigsolo/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/fr/bigsolo/res/mipmap-xhdpi/ic_launcher.png b/src/fr/bigsolo/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..ad0e72d5e Binary files /dev/null and b/src/fr/bigsolo/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/fr/bigsolo/res/mipmap-xxhdpi/ic_launcher.png b/src/fr/bigsolo/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..fcf65fe14 Binary files /dev/null and b/src/fr/bigsolo/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/fr/bigsolo/res/mipmap-xxxhdpi/ic_launcher.png b/src/fr/bigsolo/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..7956b97a9 Binary files /dev/null and b/src/fr/bigsolo/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/fr/bigsolo/src/eu/kanade/tachiyomi/extension/fr/bigsolo/BigSolo.kt b/src/fr/bigsolo/src/eu/kanade/tachiyomi/extension/fr/bigsolo/BigSolo.kt new file mode 100644 index 000000000..9cfe51b24 --- /dev/null +++ b/src/fr/bigsolo/src/eu/kanade/tachiyomi/extension/fr/bigsolo/BigSolo.kt @@ -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() + + // 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() + val mangaList = mutableListOf() + + 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() + return seriesData.toDetailedSManga() + } + + override fun pageListParse(response: Response): List { + 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 { + val document = response.asJsoup() + val jsonData = document.selectFirst(SERIES_DATA_SELECTOR)!!.html() + + val seriesData = jsonData.parseAs() + 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() + + 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() + 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 { + val chapters = seriesData.chapters ?: return emptyList() + val chapterList = mutableListOf() + 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 { + val pagesResponse = + client.newCall(GET("$baseUrl/api/imgchest-chapter-pages?id=$chapterId", headers)) + .execute() + val pages = pagesResponse.parseAs>() + return pages.mapIndexed { index, pageData -> + Page(index, imageUrl = pageData.link) + } + } +} diff --git a/src/fr/bigsolo/src/eu/kanade/tachiyomi/extension/fr/bigsolo/BigSoloDto.kt b/src/fr/bigsolo/src/eu/kanade/tachiyomi/extension/fr/bigsolo/BigSoloDto.kt new file mode 100644 index 000000000..197ea5e1a --- /dev/null +++ b/src/fr/bigsolo/src/eu/kanade/tachiyomi/extension/fr/bigsolo/BigSoloDto.kt @@ -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, +) + +@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?, + @SerialName("release_status") + val releaseStatus: String?, + val chapters: Map?, +) + +@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?, +) + +@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('-') +}