Add MangaNova (#11260)

* MangaNova: V1 (Closes #11259)

* MangaNova: Bad isNsfw in build.gradle

* MangaNova: Changes asked by AwkwardPeak7

* MangaNova: Spelling Mistake
This commit is contained in:
CriosChan 2025-10-28 15:36:27 +01:00 committed by Draff
parent 6071f598f4
commit f5429b887f
Signed by: Draff
GPG Key ID: E8A89F3211677653
10 changed files with 372 additions and 0 deletions

View File

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity
android:name=".fr.manganova.MangaNovaUrlActivity"
android:excludeFromRecents="true"
android:exported="true"
android:theme="@android:style/Theme.NoDisplay">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="www.manga-nova.com"
android:pathPattern="/lecture-en-ligne/..*"
android:scheme="https" />
<data
android:host="www.manga-nova.com"
android:pathPattern="/manga/..*"
android:scheme="https" />
</intent-filter>
</activity>
</application>
</manifest>

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

View File

@ -0,0 +1,176 @@
package eu.kanade.tachiyomi.extension.fr.manganova
import android.webkit.CookieManager
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.asObservableSuccess
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 keiyoushi.utils.parseAs
import okhttp3.Headers
import okhttp3.Request
import okhttp3.Response
import rx.Observable
import java.net.URI
class MangaNova : HttpSource() {
override val name = "MangaNova"
override val baseUrl = "https://www.manga-nova.com"
val api = "https://api.manga-nova.com"
override val lang = "fr"
override val supportsLatest = true
private val webViewCookieManager: CookieManager by lazy { CookieManager.getInstance() }
// Default static token, shouldn't change
private val defaultToken = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJtZW1icmVfaWQiOjAsIm1lbWJyZV91c2VybmFtZSI6bnVsbCwiaWF0IjoxNzA1NTc5MDQ1fQ.51qivLd2l3OKbDaYYzlntZJNnreRSBWO7p5Nsa2mAsA"
override fun headersBuilder(): Headers.Builder {
val cookies = webViewCookieManager.getCookie(baseUrl)
var token = defaultToken
if (cookies != null && cookies.isNotEmpty()) {
val cookieHeaders = cookies.split("; ").toList()
val tokenCookie = cookieHeaders.firstOrNull { it.startsWith("token=") }
if (tokenCookie != null) {
token = tokenCookie.replace("token=", "")
}
}
return super.headersBuilder()
.add("Authorization", "Bearer $token")
}
// Popular
override fun popularMangaRequest(page: Int): Request {
return GET("$api/catalogue/", headers)
}
override fun popularMangaParse(response: Response): MangasPage = searchMangaParse(response)
override fun latestUpdatesRequest(page: Int): Request = popularMangaRequest(page)
override fun latestUpdatesParse(response: Response): MangasPage {
val catalogue = response.parseAs<Catalogue>()
val mangaList = mutableListOf<SManga>()
for (serie in catalogue.newSeries) {
mangaList.add(serie.toDetailedSManga())
}
return MangasPage(mangaList, false)
}
// Search
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
val url = if (query.isNotBlank()) {
"$api/catalogue/#$query"
} else {
"$api/catalogue/"
}
return GET(url, headers)
}
override fun searchMangaParse(response: Response): MangasPage {
val catalogue = response.parseAs<Catalogue>()
val mangaList = mutableListOf<SManga>()
val fragment = response.request.url.fragment
val searchQuery = fragment ?: ""
if (searchQuery.startsWith("SLUG:")) {
val serie = catalogue.series.find { it.slug == searchQuery.removePrefix("SLUG:") }
if (serie != null) {
mangaList.add(serie.toDetailedSManga())
}
return MangasPage(mangaList, false)
}
for (serie in catalogue.series) {
if (searchQuery.isBlank() ||
serie.title.contains(searchQuery, ignoreCase = true) ||
serie.titleJap.contains(searchQuery, ignoreCase = true)
) {
mangaList.add(serie.toDetailedSManga())
}
}
return MangasPage(mangaList, false)
}
// Details
override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
val splitedPath = URI(manga.url).path.split("/")
val slug = splitedPath[2]
return client.newCall(GET("$api/catalogue/", headers))
.asObservableSuccess()
.map { response ->
mangaDetailsParse(response, slug)
}
}
private fun mangaDetailsParse(response: Response, slug: String = ""): SManga {
val catalogue = response.parseAs<Catalogue>()
val series = catalogue.series
val serie = series.find { it.slug == slug }
if (serie == null) {
throw UnsupportedOperationException("Bad SLUG")
}
return serie.toDetailedSManga()
}
// Pages
override fun pageListRequest(chapter: SChapter): Request {
val splitedPath = URI(chapter.url).path.split("/")
val slug = splitedPath[2]
val chapterNumber = splitedPath[4]
return GET("$api/mangas/$slug/chapitres/$chapterNumber", headers)
}
override fun pageListParse(response: Response): List<Page> {
val images = response.parseAs<ChapterDetails>().images
return images.mapIndexed { index, pageData ->
Page(pageData.pageNumber, imageUrl = pageData.image)
}
}
// Chapters
override fun chapterListRequest(manga: SManga): Request {
val splitedPath = URI(manga.url).path.split("/")
val slug = splitedPath[2]
return GET("$api/mangas/$slug", headers)
}
override fun chapterListParse(response: Response): List<SChapter> {
val serie = response.parseAs<DetailedSerieContainer>().serie
val categories = serie.chapitres
val chapterList = mutableListOf<SChapter>()
val currentEpoch = System.currentTimeMillis()
for (category in categories) {
for (chapter in category.chapitres) {
if (chapter.amount != 0) continue
val chapter = SChapter.create().apply {
name = category.title + " - " + chapter.title + " - " + chapter.subTitle
setUrlWithoutDomain("$baseUrl/lecture-en-ligne/${serie.slug}/chapitre/${chapter.number}")
chapter_number = chapter.number
date_upload = currentEpoch + (chapter.availableTime * 1000L)
}
chapterList.add(chapter)
}
}
return chapterList.sortedByDescending { it.chapter_number }
}
// Unsupported stuff
override fun imageUrlParse(response: Response): String {
throw UnsupportedOperationException()
}
override fun mangaDetailsParse(response: Response): SManga {
throw UnsupportedOperationException()
}
}

View File

@ -0,0 +1,122 @@
package eu.kanade.tachiyomi.extension.fr.manganova
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.floatOrNull
/**
* Data Transfer Objects for MangaNova extension
*/
object SafeFloatDeserializer : KSerializer<Float> {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("SafeFloat", PrimitiveKind.FLOAT)
override fun serialize(encoder: Encoder, value: Float) {
encoder.encodeFloat(value)
}
override fun deserialize(decoder: Decoder): Float {
val jsonDecoder = decoder as? JsonDecoder ?: return try {
decoder.decodeFloat()
} catch (_: Exception) {
-1F
}
return try {
val element = jsonDecoder.decodeJsonElement()
when (element) {
is JsonPrimitive -> {
element.floatOrNull ?: element.content.toFloatOrNull() ?: -1F
}
else -> -1F
}
} catch (_: Exception) {
-1F
}
}
}
@Serializable
class Catalogue(
val series: List<Serie>,
@SerialName("new_series")
val newSeries: List<Serie>,
)
@Serializable
class Serie(
val title: String,
@SerialName("title_jap")
val titleJap: String,
val slug: String,
val description: String,
val genres: String,
val poster: String,
val author: String,
val dessinateur: String,
val running: Int,
)
@Serializable
class DetailedSerieContainer(
val serie: DetailedSerie,
)
@Serializable
class DetailedSerie(
val slug: String,
val chapitres: List<Category>,
)
@Serializable
class Category(
val title: String,
val chapitres: List<Chapter>,
)
@Serializable
class Chapter(
val title: String,
@SerialName("sub_title")
val subTitle: String,
@Serializable(with = SafeFloatDeserializer::class)
val number: Float,
@SerialName("available_time")
val availableTime: Long,
val amount: Int,
)
@Serializable
class ChapterDetails(
val images: List<Image>,
)
@Serializable
class Image(
val image: String,
@SerialName("page_number")
val pageNumber: Int,
)
// DTO to SManga extension functions
fun Serie.toDetailedSManga(): SManga = SManga.create().apply {
title = this@toDetailedSManga.title
description = this@toDetailedSManga.description
artist = this@toDetailedSManga.dessinateur
author = this@toDetailedSManga.author
status = if (this@toDetailedSManga.running == 0) SManga.COMPLETED else SManga.ONGOING
thumbnail_url = this@toDetailedSManga.poster
url = "/manga/${this@toDetailedSManga.slug}"
genre = this@toDetailedSManga.genres.replace(",", ", ")
}

View File

@ -0,0 +1,39 @@
package eu.kanade.tachiyomi.extension.fr.manganova
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Intent
import android.os.Bundle
import android.util.Log
import kotlin.system.exitProcess
/**
* Springboard that accepts https://www.manganova.fr/lecture-en-ligne/xxxxxx &&
* https://www.manganova.fr/manga/xxxxxx intents and redirects them to
* the main Tachiyomi process.
*/
class MangaNovaUrlActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val pathSegments = intent?.data?.pathSegments
if (pathSegments != null && pathSegments.size >= 2) {
val slug = pathSegments[1]
val mainIntent = Intent().apply {
action = "eu.kanade.tachiyomi.SEARCH"
putExtra("query", "SLUG:$slug")
putExtra("filter", packageName)
}
try {
startActivity(mainIntent)
} catch (e: ActivityNotFoundException) {
Log.e("MangaNovaUrlActivity", e.toString())
}
} else {
Log.e("MangaNovaUrlActivity", "could not parse uri from intent $intent")
}
finish()
exitProcess(0)
}
}