diff --git a/src/fr/manganova/AndroidManifest.xml b/src/fr/manganova/AndroidManifest.xml new file mode 100644 index 000000000..8ce70cc98 --- /dev/null +++ b/src/fr/manganova/AndroidManifest.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + diff --git a/src/fr/manganova/build.gradle b/src/fr/manganova/build.gradle new file mode 100644 index 000000000..a25b712ec --- /dev/null +++ b/src/fr/manganova/build.gradle @@ -0,0 +1,8 @@ +ext { + extName = 'MangaNova' + extClass = '.MangaNova' + extVersionCode = 1 + isNsfw = false +} + +apply from: "$rootDir/common.gradle" diff --git a/src/fr/manganova/res/mipmap-hdpi/ic_launcher.png b/src/fr/manganova/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..916f83016 Binary files /dev/null and b/src/fr/manganova/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/fr/manganova/res/mipmap-mdpi/ic_launcher.png b/src/fr/manganova/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..8e094cf07 Binary files /dev/null and b/src/fr/manganova/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/fr/manganova/res/mipmap-xhdpi/ic_launcher.png b/src/fr/manganova/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..02dc66e0f Binary files /dev/null and b/src/fr/manganova/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/fr/manganova/res/mipmap-xxhdpi/ic_launcher.png b/src/fr/manganova/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..f3965a447 Binary files /dev/null and b/src/fr/manganova/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/fr/manganova/res/mipmap-xxxhdpi/ic_launcher.png b/src/fr/manganova/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..ec8ebb87d Binary files /dev/null and b/src/fr/manganova/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/fr/manganova/src/eu/kanade/tachiyomi/extension/fr/manganova/MangaNova.kt b/src/fr/manganova/src/eu/kanade/tachiyomi/extension/fr/manganova/MangaNova.kt new file mode 100644 index 000000000..72a8de83d --- /dev/null +++ b/src/fr/manganova/src/eu/kanade/tachiyomi/extension/fr/manganova/MangaNova.kt @@ -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() + val mangaList = mutableListOf() + + 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() + val mangaList = mutableListOf() + + 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 { + 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() + 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 { + val images = response.parseAs().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 { + val serie = response.parseAs().serie + val categories = serie.chapitres + val chapterList = mutableListOf() + + 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() + } +} diff --git a/src/fr/manganova/src/eu/kanade/tachiyomi/extension/fr/manganova/MangaNovaDto.kt b/src/fr/manganova/src/eu/kanade/tachiyomi/extension/fr/manganova/MangaNovaDto.kt new file mode 100644 index 000000000..4dff6086a --- /dev/null +++ b/src/fr/manganova/src/eu/kanade/tachiyomi/extension/fr/manganova/MangaNovaDto.kt @@ -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 { + 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, + @SerialName("new_series") + val newSeries: List, +) + +@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, +) + +@Serializable +class Category( + val title: String, + val chapitres: List, +) + +@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, +) + +@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(",", ", ") +} diff --git a/src/fr/manganova/src/eu/kanade/tachiyomi/extension/fr/manganova/MangaNovaUrlActivity.kt b/src/fr/manganova/src/eu/kanade/tachiyomi/extension/fr/manganova/MangaNovaUrlActivity.kt new file mode 100644 index 000000000..61cb98ef5 --- /dev/null +++ b/src/fr/manganova/src/eu/kanade/tachiyomi/extension/fr/manganova/MangaNovaUrlActivity.kt @@ -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) + } +}