diff --git a/src/pt/nixmangas/AndroidManifest.xml b/src/pt/nixmangas/AndroidManifest.xml new file mode 100644 index 000000000..389c51256 --- /dev/null +++ b/src/pt/nixmangas/AndroidManifest.xml @@ -0,0 +1,3 @@ + + + diff --git a/src/pt/nixmangas/build.gradle b/src/pt/nixmangas/build.gradle new file mode 100644 index 000000000..6b0324e54 --- /dev/null +++ b/src/pt/nixmangas/build.gradle @@ -0,0 +1,13 @@ +apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' +apply plugin: 'kotlinx-serialization' + +ext { + extName = 'Nix Mangás' + pkgNameSuffix = 'pt.nixmangas' + extClass = '.NixMangas' + extVersionCode = 1 + isNsfw = true +} + +apply from: "$rootDir/common.gradle" diff --git a/src/pt/nixmangas/res/mipmap-hdpi/ic_launcher.png b/src/pt/nixmangas/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..84ed29191 Binary files /dev/null and b/src/pt/nixmangas/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/pt/nixmangas/res/mipmap-mdpi/ic_launcher.png b/src/pt/nixmangas/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..008d349da Binary files /dev/null and b/src/pt/nixmangas/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/pt/nixmangas/res/mipmap-xhdpi/ic_launcher.png b/src/pt/nixmangas/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..f7d413ee6 Binary files /dev/null and b/src/pt/nixmangas/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/pt/nixmangas/res/mipmap-xxhdpi/ic_launcher.png b/src/pt/nixmangas/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..cc0a1bd6f Binary files /dev/null and b/src/pt/nixmangas/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/pt/nixmangas/res/mipmap-xxxhdpi/ic_launcher.png b/src/pt/nixmangas/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..1e4dc7152 Binary files /dev/null and b/src/pt/nixmangas/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/pt/nixmangas/res/web_hi_res_512.png b/src/pt/nixmangas/res/web_hi_res_512.png new file mode 100644 index 000000000..3f20f665a Binary files /dev/null and b/src/pt/nixmangas/res/web_hi_res_512.png differ diff --git a/src/pt/nixmangas/src/eu/kanade/tachiyomi/extension/pt/nixmangas/NixMangas.kt b/src/pt/nixmangas/src/eu/kanade/tachiyomi/extension/pt/nixmangas/NixMangas.kt new file mode 100644 index 000000000..7c8b4ac01 --- /dev/null +++ b/src/pt/nixmangas/src/eu/kanade/tachiyomi/extension/pt/nixmangas/NixMangas.kt @@ -0,0 +1,166 @@ +package eu.kanade.tachiyomi.extension.pt.nixmangas + +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.asObservableSuccess +import eu.kanade.tachiyomi.network.interceptor.rateLimitHost +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.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import rx.Observable +import uy.kohesive.injekt.injectLazy +import java.util.concurrent.TimeUnit + +class NixMangas : HttpSource() { + + override val name = "Nix Mangás" + + override val baseUrl = "https://nixmangas.com" + + override val lang = "pt-BR" + + override val supportsLatest = true + + override val client: OkHttpClient = network.cloudflareClient.newBuilder() + .rateLimitHost(baseUrl.toHttpUrl(), 1, 1, TimeUnit.SECONDS) + .rateLimitHost(API_URL.toHttpUrl(), 1, 1, TimeUnit.SECONDS) + .rateLimitHost(CDN_URL.toHttpUrl(), 1, 2, TimeUnit.SECONDS) + .build() + + private val json: Json by injectLazy() + + private val apiHeaders: Headers by lazy { apiHeadersBuilder().build() } + + override fun headersBuilder(): Headers.Builder = Headers.Builder() + .add("Origin", baseUrl) + .add("Referer", baseUrl) + + private fun apiHeadersBuilder(): Headers.Builder = headersBuilder() + .add("Accept", ACCEPT_JSON) + + /** + * The site doesn't have a popular section, so we use latest instead. + */ + override fun popularMangaRequest(page: Int) = latestUpdatesRequest(page) + + override fun popularMangaParse(response: Response) = latestUpdatesParse(response) + + override fun latestUpdatesRequest(page: Int): Request { + return GET("$API_URL/mangas?page=$page", apiHeaders) + } + + override fun latestUpdatesParse(response: Response): MangasPage { + val result = response.parseAs>() + val workList = result.data.map(NixMangasWorkDto::toSManga) + + return MangasPage(workList, result.hasNextPage) + } + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + // The search with query isn't working in their direct API for some reason, + // so we use their site wrapped API instead for now. + val apiUrl = "$baseUrl/obras".toHttpUrl().newBuilder() + .addQueryParameter("page", page.toString()) + .addQueryParameter("q", query) + .addQueryParameter("_data", "routes/__app/obras/index") + .toString() + + return GET(apiUrl, apiHeaders) + } + + override fun searchMangaParse(response: Response): MangasPage { + val result = response.parseAs() + val workList = result.mangas.data.map(NixMangasWorkDto::toSManga) + + return MangasPage(workList, result.mangas.hasNextPage) + } + + // Workaround to allow "Open in browser" use the real URL. + override fun fetchMangaDetails(manga: SManga): Observable { + return client.newCall(mangaDetailsApiRequest(manga.url)) + .asObservableSuccess() + .map { response -> + mangaDetailsParse(response).apply { initialized = true } + } + } + + private fun mangaDetailsApiRequest(mangaUrl: String): Request { + // Their API doesn't have an endpoint for the manga details, so we + // use their site wrapped API instead for now. + val apiUrl = (baseUrl + mangaUrl).toHttpUrl().newBuilder() + .addQueryParameter("_data", "routes/__app/obras/\$slug") + .toString() + + return GET(apiUrl, apiHeaders) + } + + override fun mangaDetailsParse(response: Response): SManga { + val result = response.parseAs() + + return result.manga.toSManga() + } + + override fun chapterListRequest(manga: SManga): Request = mangaDetailsApiRequest(manga.url) + + override fun chapterListParse(response: Response): List { + val result = response.parseAs() + val currentTimeStamp = System.currentTimeMillis() + + return result.manga.chapters + .filter { it.isPublished } + .map(NixMangasChapterDto::toSChapter) + .filter { it.date_upload <= currentTimeStamp } + .sortedByDescending(SChapter::chapter_number) + } + + override fun pageListRequest(chapter: SChapter): Request { + val apiUrl = (baseUrl + chapter.url).toHttpUrl().newBuilder() + .addQueryParameter("_data", "routes/__leitor/ler.\$manga.\$chapter") + .toString() + + return GET(apiUrl, apiHeaders) + } + + override fun pageListParse(response: Response): List { + val result = response.parseAs() + val chapterUrl = "$baseUrl/ler/${result.chapter.slug}" + + return result.chapter.pages.mapIndexed { i, pageDto -> + Page(i, chapterUrl, pageDto.pageUrl) + } + } + + 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 inline fun Response.parseAs(): T = use { + json.decodeFromString(it.body?.string().orEmpty()) + } + + companion object { + private const val ACCEPT_IMAGE = "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8" + private const val ACCEPT_JSON = "application/json" + + private const val API_URL = "https://api.nixmangas.com/v1" + private const val CDN_URL = "https://cdn.nixmangas.com" + } +} diff --git a/src/pt/nixmangas/src/eu/kanade/tachiyomi/extension/pt/nixmangas/NixMangasDto.kt b/src/pt/nixmangas/src/eu/kanade/tachiyomi/extension/pt/nixmangas/NixMangasDto.kt new file mode 100644 index 000000000..38a54fb82 --- /dev/null +++ b/src/pt/nixmangas/src/eu/kanade/tachiyomi/extension/pt/nixmangas/NixMangasDto.kt @@ -0,0 +1,87 @@ +package eu.kanade.tachiyomi.extension.pt.nixmangas + +import eu.kanade.tachiyomi.source.model.SChapter +import eu.kanade.tachiyomi.source.model.SManga +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import java.text.SimpleDateFormat +import java.util.Locale +import java.util.TimeZone + +@Serializable +data class NixMangasPaginatedContent( + @SerialName("current_page") val currentPage: Int, + val data: List = emptyList(), + @SerialName("last_page") val lastPage: Int, +) { + + val hasNextPage: Boolean + get() = currentPage < lastPage +} + +@Serializable +data class NixMangasSearchDto(val mangas: NixMangasPaginatedContent) + +@Serializable +data class NixMangasDetailsDto(val manga: NixMangasWorkDto) + +@Serializable +data class NixMangasReaderDto(val chapter: NixMangasChapterDto) + +@Serializable +data class NixMangasWorkDto( + val id: String, + val chapters: List = emptyList(), + val cover: String? = null, + val genres: List = emptyList(), + @SerialName("is_adult") val isAdult: Boolean = false, + val slug: String, + val status: String? = null, + @SerialName("synopses") val synopsis: String? = null, + val thumbnail: String, + val title: String, +) { + + fun toSManga(): SManga = SManga.create().apply { + title = this@NixMangasWorkDto.title + description = synopsis + genre = genres.joinToString { it.name } + status = when (this@NixMangasWorkDto.status) { + "ACTIVE" -> SManga.ONGOING + else -> SManga.UNKNOWN + } + thumbnail_url = cover + url = "/obras/$slug" + } +} + +@Serializable +data class NixMangasGenreDto(val name: String) + +@Serializable +data class NixMangasChapterDto( + @SerialName("is_published") val isPublished: Boolean, + val number: Float, + val pages: List = emptyList(), + val slug: String, + @SerialName("published_at") val publishedAt: String? = null, +) { + + fun toSChapter(): SChapter = SChapter.create().apply { + name = "Capítulo ${number.toString().replace(".0", "")}" + chapter_number = number + date_upload = runCatching { DATE_FORMATTER.parse(publishedAt!!)?.time } + .getOrNull() ?: 0L + url = "/ler/$slug" + } + + companion object { + private val DATE_FORMATTER by lazy { + SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.S", Locale.US) + .apply { timeZone = TimeZone.getTimeZone("UTC") } + } + } +} + +@Serializable +data class NixMangasPageDto(@SerialName("page_url") val pageUrl: String)