diff --git a/src/pt/mangavibe/AndroidManifest.xml b/src/pt/mangavibe/AndroidManifest.xml new file mode 100644 index 000000000..30deb7f79 --- /dev/null +++ b/src/pt/mangavibe/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/src/pt/mangavibe/build.gradle b/src/pt/mangavibe/build.gradle new file mode 100644 index 000000000..3be4dab1b --- /dev/null +++ b/src/pt/mangavibe/build.gradle @@ -0,0 +1,18 @@ +apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' +apply plugin: 'kotlinx-serialization' + +ext { + extName = 'MangaVibe' + pkgNameSuffix = 'pt.mangavibe' + extClass = '.MangaVibe' + extVersionCode = 1 + libVersion = '1.2' + containsNsfw = true +} + +dependencies { + implementation project(':lib-ratelimit') +} + +apply from: "$rootDir/common.gradle" diff --git a/src/pt/mangavibe/res/mipmap-hdpi/ic_launcher.png b/src/pt/mangavibe/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..f938f17d4 Binary files /dev/null and b/src/pt/mangavibe/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/pt/mangavibe/res/mipmap-mdpi/ic_launcher.png b/src/pt/mangavibe/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..efcd145ed Binary files /dev/null and b/src/pt/mangavibe/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/pt/mangavibe/res/mipmap-xhdpi/ic_launcher.png b/src/pt/mangavibe/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..bd5328abc Binary files /dev/null and b/src/pt/mangavibe/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/pt/mangavibe/res/mipmap-xxhdpi/ic_launcher.png b/src/pt/mangavibe/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..2062d5391 Binary files /dev/null and b/src/pt/mangavibe/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/pt/mangavibe/res/mipmap-xxxhdpi/ic_launcher.png b/src/pt/mangavibe/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..723065302 Binary files /dev/null and b/src/pt/mangavibe/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/pt/mangavibe/res/web_hi_res_512.png b/src/pt/mangavibe/res/web_hi_res_512.png new file mode 100644 index 000000000..b003cc435 Binary files /dev/null and b/src/pt/mangavibe/res/web_hi_res_512.png differ diff --git a/src/pt/mangavibe/src/eu/kanade/tachiyomi/extension/pt/mangavibe/MangaVibe.kt b/src/pt/mangavibe/src/eu/kanade/tachiyomi/extension/pt/mangavibe/MangaVibe.kt new file mode 100644 index 000000000..3cb7bb1df --- /dev/null +++ b/src/pt/mangavibe/src/eu/kanade/tachiyomi/extension/pt/mangavibe/MangaVibe.kt @@ -0,0 +1,350 @@ +package eu.kanade.tachiyomi.extension.pt.mangavibe + +import eu.kanade.tachiyomi.annotations.Nsfw +import eu.kanade.tachiyomi.lib.ratelimit.RateLimitInterceptor +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 kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import okhttp3.Headers +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.Interceptor +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.OkHttpClient +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody +import rx.Observable +import uy.kohesive.injekt.injectLazy +import java.text.Normalizer +import java.text.SimpleDateFormat +import java.util.Locale +import java.util.concurrent.TimeUnit +import kotlin.math.ceil + +@Nsfw +class MangaVibe : HttpSource() { + + override val name = "MangaVibe" + + override val baseUrl = "https://mangavibe.top" + + override val lang = "pt-BR" + + override val supportsLatest = true + + override val client: OkHttpClient = network.cloudflareClient.newBuilder() + .addInterceptor(RateLimitInterceptor(1, 2, TimeUnit.SECONDS)) + .addInterceptor(::directoryCacheIntercept) + .build() + + override fun headersBuilder(): Headers.Builder = Headers.Builder() + .add("Referer", "$baseUrl/") + + private val json: Json by injectLazy() + + private val directoryCache: MutableMap = mutableMapOf() + + override fun popularMangaRequest(page: Int): Request { + val newHeaders = headersBuilder() + .add("Accept", ACCEPT_JSON) + .set("Referer", "$baseUrl/mangas?Ordem=Populares") + .add("X-Page", page.toString()) + .build() + + return GET("$baseUrl/$API_PATH/data?page=medias", newHeaders) + } + + override fun popularMangaParse(response: Response): MangasPage { + val result = json.decodeFromString(response.body!!.string()) + + if (result.data.isNullOrEmpty()) { + return MangasPage(emptyList(), hasNextPage = false) + } + + val totalPages = ceil(result.data.size.toDouble() / ITEMS_PER_PAGE) + val currentPage = response.request.header("X-Page")!!.toInt() + + val mangaList = result.data + .sortedByDescending { it.views } + .drop(ITEMS_PER_PAGE * (currentPage - 1)) + .take(ITEMS_PER_PAGE) + .map(::popularMangaFromObject) + + return MangasPage(mangaList, hasNextPage = currentPage < totalPages) + } + + private fun popularMangaFromObject(comic: MangaVibeComicDto): SManga = SManga.create().apply { + title = comic.title["romaji"] ?: comic.title["english"] ?: comic.title["native"]!! + thumbnail_url = comic.id.toThumbnailUrl() + url = "/manga/${comic.id}/${title.toSlug()}" + } + + override fun latestUpdatesRequest(page: Int): Request { + val newHeaders = headersBuilder() + .add("Accept", ACCEPT_JSON) + .set("Referer", "$baseUrl/mangas?Ordem=Atualizados") + .add("X-Page", page.toString()) + .build() + + return GET("$baseUrl/$API_PATH/data?page=medias&Ordem=Atualizados", newHeaders) + } + + override fun latestUpdatesParse(response: Response): MangasPage { + val result = json.decodeFromString(response.body!!.string()) + + if (result.data.isNullOrEmpty()) { + return MangasPage(emptyList(), hasNextPage = false) + } + + val totalPages = ceil(result.data.size.toDouble() / ITEMS_PER_PAGE) + val currentPage = response.request.header("X-Page")!!.toInt() + + val mangaList = result.data + .asSequence() + .distinctBy { it.title } + .filter { it.mediaID.isNullOrBlank().not() } + .drop(ITEMS_PER_PAGE * (currentPage - 1)) + .take(ITEMS_PER_PAGE) + .map(::latestMangaFromObject) + .toList() + + return MangasPage(mangaList, hasNextPage = currentPage < totalPages) + } + + private fun latestMangaFromObject(chapter: MangaVibeLatestChapterDto): SManga = SManga.create().apply { + title = chapter.title!! + thumbnail_url = chapter.mediaID!!.toInt().toThumbnailUrl() + url = "/manga/${chapter.mediaID}/${chapter.title.toSlug()}" + } + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + val newHeaders = headersBuilder() + .add("Accept", ACCEPT_JSON) + .add("X-Page", page.toString()) + .build() + + val apiUrl = "$baseUrl/$API_PATH/data".toHttpUrl().newBuilder() + .addQueryParameter("page", "medias") + .addQueryParameter("st", query) + .toString() + + return GET(apiUrl, newHeaders) + } + + override fun searchMangaParse(response: Response): MangasPage { + val result = json.decodeFromString(response.body!!.string()) + + if (result.data.isNullOrEmpty()) { + return MangasPage(emptyList(), hasNextPage = false) + } + + val searchTerm = response.request.url.queryParameter("st")!! + + val mangaList = result.data + .filter { + it.title.values.any { title -> + title?.contains(searchTerm, ignoreCase = true) ?: false + } + } + .sortedByDescending { it.views } + .map(::searchMangaFromObject) + + return MangasPage(mangaList, hasNextPage = false) + } + + private fun searchMangaFromObject(comic: MangaVibeComicDto): SManga = popularMangaFromObject(comic) + + // Workaround to allow "Open in browser" use the real URL. + override fun fetchMangaDetails(manga: SManga): Observable { + return client.newCall(mangaDetailsApiRequest(manga)) + .asObservableSuccess() + .map { response -> + mangaDetailsParse(response).apply { initialized = true } + } + } + + private fun mangaDetailsApiRequest(manga: SManga): Request { + val comicId = manga.url.substringAfter("/manga/") + .substringBefore("/") + + val newHeaders = headersBuilder() + .add("Accept", ACCEPT_JSON) + .set("Referer", "$baseUrl/mangas?Ordem=Populares") + .add("X-Id", comicId) + .build() + + return GET("$baseUrl/$API_PATH/data?page=medias", newHeaders) + } + + override fun mangaDetailsParse(response: Response): SManga { + val result = json.decodeFromString(response.body!!.string()) + + if (result.data.isNullOrEmpty()) { + throw Exception(COULD_NOT_PARSE_THE_MANGA) + } + + val comicId = response.request.header("X-Id")!!.toInt() + val comic = result.data.find { it.id == comicId } + ?: throw Exception(COULD_NOT_PARSE_THE_MANGA) + + return SManga.create().apply { + title = comic.title["romaji"] ?: comic.title["english"] ?: comic.title["native"]!! + description = comic.description.orEmpty() + genre = comic.genres?.joinToString(", ") + status = comic.status?.toStatus() ?: SManga.UNKNOWN + thumbnail_url = comic.id.toThumbnailUrl() + } + } + + // Chapters are available in the same url of the manga details. + override fun chapterListRequest(manga: SManga): Request { + val comicId = manga.url.substringAfter("/manga/") + .substringBefore("/") + + val newHeaders = headersBuilder() + .add("Accept", ACCEPT_JSON) + .set("Referer", baseUrl + manga.url) + .build() + + return GET("$baseUrl/$API_PATH/data?page=chapter&mediaID=$comicId", newHeaders) + } + + override fun chapterListParse(response: Response): List { + val result = json.decodeFromString(response.body!!.string()) + + if (result.data.isNullOrEmpty()) { + return emptyList() + } + + return result.data + .map(::chapterFromObject) + .reversed() + } + + private fun chapterFromObject(chapter: MangaVibeChapterDto): SChapter = SChapter.create().apply { + name = "Capítulo #" + chapter.number.toString().replace(".0", "") + chapter_number = chapter.number + date_upload = chapter.datePublished?.toDate() ?: 0L + + val chapterUrl = "$baseUrl/chapter".toHttpUrl().newBuilder() + .addPathSegment(chapter.mediaID.toString()) + .addPathSegment(chapter.title?.toSlug() ?: "null") + .addPathSegment(chapter.number.toString().replace(".0", "")) + .addQueryParameter("pgn", chapter.pages.toString()) + .toString() + setUrlWithoutDomain(chapterUrl) + } + + override fun fetchPageList(chapter: SChapter): Observable> { + val chapterUrlPaths = chapter.url + .removePrefix("/") + .split("/") + + val comicId = chapterUrlPaths[1] + val chapterNumber = chapterUrlPaths[3].substringBefore("?") + val pageCount = chapter.url.substringAfterLast("?pgn=").toInt() + + val pages = List(pageCount) { i -> + val pageUrl = "$CDN_URL/img/media/$comicId/chapter/$chapterNumber/${i + 1}.jpg" + Page(i, baseUrl, pageUrl) + } + + return Observable.just(pages) + } + + override fun pageListParse(response: Response): List = + throw Exception("This method should not be called!") + + override fun fetchImageUrl(page: Page): Observable = Observable.just(page.imageUrl!!) + + override fun imageUrlParse(response: Response): String = "" + + override fun imageRequest(page: Page): Request { + val newHeaders = headersBuilder() + .add("Accept", ACCEPT_IMAGE) + .set("Referer", page.url) + .build() + + return GET(page.imageUrl!!, newHeaders) + } + + private fun directoryCacheIntercept(chain: Interceptor.Chain): Response { + if (!chain.request().url.toString().contains("data?page=medias")) { + return chain.proceed(chain.request()) + } + + val directoryType = if (chain.request().url.queryParameter("Ordem") == null) + POPULAR_KEY else LATEST_KEY + val page = chain.request().header("X-Page")?.toInt() + + if (directoryCache.containsKey(directoryType) && page != null && page > 1) { + val jsonContentType = "application/json; charset=UTF-8".toMediaTypeOrNull() + val responseBody = directoryCache[directoryType]!!.toResponseBody(jsonContentType) + + return Response.Builder() + .code(200) + .protocol(Protocol.HTTP_1_1) + .request(chain.request()) + .message("OK") + .body(responseBody) + .build() + } + + val response = chain.proceed(chain.request()) + val responseContentType = response.body!!.contentType() + val responseString = response.body!!.string() + + directoryCache[directoryType] = responseString + + return response.newBuilder() + .body(responseString.toResponseBody(responseContentType)) + .build() + } + + private fun Int.toThumbnailUrl(): String = "$CDN_URL/img/media/$this/cover/l.jpg" + + private fun String.toDate(): Long { + return runCatching { DATE_FORMATTER.parse(substringBefore("T"))?.time } + .getOrNull() ?: 0L + } + + private fun String.toStatus(): Int = when (this) { + "Em lançamento" -> SManga.ONGOING + "Completo" -> SManga.COMPLETED + else -> SManga.UNKNOWN + } + + private fun String.toSlug(): String { + return Normalizer + .normalize(this, Normalizer.Form.NFD) + .replace("[^\\p{ASCII}]".toRegex(), "") + .replace("[^a-zA-Z0-9\\s]+".toRegex(), "").trim() + .replace("\\s+".toRegex(), "-") + .toLowerCase(Locale("pt", "BR")) + } + + companion object { + private const val API_PATH = "mangavibe/api/v1" + private const val CDN_URL = "https://cdn.mangavibe.top" + + private const val ACCEPT_JSON = "application/json, text/plain, */*" + private const val ACCEPT_IMAGE = "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8" + + private const val ITEMS_PER_PAGE = 24 + + private const val COULD_NOT_PARSE_THE_MANGA = "Ocorreu um erro ao obter as informações." + + private const val POPULAR_KEY = "popular" + private const val LATEST_KEY = "latest" + + private val DATE_FORMATTER by lazy { SimpleDateFormat("yyyy-MM-dd", Locale.ENGLISH) } + } +} diff --git a/src/pt/mangavibe/src/eu/kanade/tachiyomi/extension/pt/mangavibe/MangaVibeDto.kt b/src/pt/mangavibe/src/eu/kanade/tachiyomi/extension/pt/mangavibe/MangaVibeDto.kt new file mode 100644 index 000000000..5228e41be --- /dev/null +++ b/src/pt/mangavibe/src/eu/kanade/tachiyomi/extension/pt/mangavibe/MangaVibeDto.kt @@ -0,0 +1,37 @@ +package eu.kanade.tachiyomi.extension.pt.mangavibe + +import kotlinx.serialization.Serializable + +typealias MangaVibePopularDto = MangaVibeResultDto> +typealias MangaVibeLatestDto = MangaVibeResultDto> +typealias MangaVibeChapterListDto = MangaVibeResultDto> + +@Serializable +data class MangaVibeResultDto( + val data: T? = null +) + +@Serializable +data class MangaVibeComicDto( + val description: String? = "", + val genres: List? = emptyList(), + val id: Int, + val status: String? = "", + val title: Map = emptyMap(), + val views: Int = -1 +) + +@Serializable +data class MangaVibeLatestChapterDto( + val mediaID: String? = "", + val title: String? = "" +) + +@Serializable +data class MangaVibeChapterDto( + val datePublished: String? = "", + val mediaID: Int = -1, + val number: Float = -1f, + val pages: Int = -1, + val title: String? = "" +)