diff --git a/src/pt/onereader/build.gradle.kts b/src/pt/onereader/build.gradle.kts new file mode 100644 index 00000000000..4538c60c166 --- /dev/null +++ b/src/pt/onereader/build.gradle.kts @@ -0,0 +1,22 @@ +import io.github.keiyoushi.gradle.api.ContentWarning + +plugins { + alias(kei.plugins.extension) +} + +keiyoushi { + name = "OneReader" + versionCode = 1 + contentWarning = ContentWarning.MIXED + libVersion = "1.6" + + source { + lang = "pt-BR" + baseUrl = "https://onereader.net" + } + + deeplink { + path("/manga-details") + path("/leitor") + } +} diff --git a/src/pt/onereader/res/mipmap-hdpi/ic_launcher.png b/src/pt/onereader/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 00000000000..69d2dcc7691 Binary files /dev/null and b/src/pt/onereader/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/pt/onereader/res/mipmap-mdpi/ic_launcher.png b/src/pt/onereader/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 00000000000..fe8b24b4bee Binary files /dev/null and b/src/pt/onereader/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/pt/onereader/res/mipmap-xhdpi/ic_launcher.png b/src/pt/onereader/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 00000000000..d38f800ef1e Binary files /dev/null and b/src/pt/onereader/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/pt/onereader/res/mipmap-xxhdpi/ic_launcher.png b/src/pt/onereader/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 00000000000..27d0be0d13b Binary files /dev/null and b/src/pt/onereader/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/pt/onereader/res/mipmap-xxxhdpi/ic_launcher.png b/src/pt/onereader/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 00000000000..eca6f497372 Binary files /dev/null and b/src/pt/onereader/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/pt/onereader/src/eu/kanade/tachiyomi/extension/pt/onereader/Dto.kt b/src/pt/onereader/src/eu/kanade/tachiyomi/extension/pt/onereader/Dto.kt new file mode 100644 index 00000000000..c282df1eada --- /dev/null +++ b/src/pt/onereader/src/eu/kanade/tachiyomi/extension/pt/onereader/Dto.kt @@ -0,0 +1,151 @@ +package eu.kanade.tachiyomi.extension.pt.onereader + +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 keiyoushi.utils.jsonInstance +import keiyoushi.utils.tryParse +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.JsonTransformingSerializer +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.put +import okhttp3.HttpUrl +import java.text.SimpleDateFormat +import java.util.Locale +import java.util.TimeZone + +@Serializable +internal class SearchDto( + private val data: List, + private val page: Int, + private val totalPages: Int, +) { + fun toMangasPage() = MangasPage( + data.map(MangaDto::toSManga), + page < totalPages, + ) +} + +@Serializable +internal class MangaDto( + @SerialName("manga_key") + private val mangaKey: String, + @SerialName("display_name") + private val displayName: String, + private val name: String, + @SerialName("japanese_name") + private val japaneseName: String?, + private val author: String? = null, + private val artist: String? = null, + private val synopsis: String?, + private val status: String, + private val type: String, + @SerialName("origin_country") + private val originCountry: String, + private val year: Int, + @Serializable(with = TagsSerializer::class) + private val tags: List, + @SerialName("cover_image") + private val coverImage: String, + @SerialName("publisher_name") + private val publisherName: String? = null, +) { + fun toSManga(details: Boolean = false): SManga = SManga.create().apply { + url = mangaKey + title = displayName + thumbnail_url = coverImage + author = this@MangaDto.author?.takeIf(String::isNotBlank) + artist = this@MangaDto.artist?.takeIf(String::isNotBlank) + genre = tags.takeIf(List::isNotEmpty)?.joinToString() + status = this@MangaDto.status.toStatus() + description = buildDescription() + initialized = details + } + + private fun buildDescription(): String? { + val metadata = buildList { + name.takeIf { it.isNotBlank() && !it.equals(displayName, ignoreCase = true) } + ?.let { add("Título original: $it") } + japaneseName?.takeIf { + it.isNotBlank() && + !it.equals(displayName, ignoreCase = true) && + !it.equals(name, ignoreCase = true) + }?.let { add("Título nativo: $it") } + type.takeIf(String::isNotBlank)?.let { add("Tipo: $it") } + originCountry.takeIf(String::isNotBlank)?.let { add("País: $it") } + add("Ano: $year") + publisherName?.takeIf(String::isNotBlank)?.let { add("Editora: $it") } + } + + return buildString { + synopsis?.takeIf(String::isNotBlank)?.let(::append) + if (isNotEmpty() && metadata.isNotEmpty()) append("\n\n") + append(metadata.joinToString("\n")) + }.ifBlank { null } + } +} + +@Serializable +internal class ChapterDto( + @SerialName("manga_key") + private val mangaKey: String, + @SerialName("posted_date") + private val postedDate: String, +) { + fun toSChapter(number: String): SChapter = SChapter.create().apply { + url = "$mangaKey/$number" + memo = buildJsonObject { + put("id", mangaKey) + put("number", number) + } + name = "Capítulo $number" + chapter_number = number.toFloatOrNull() ?: -1F + date_upload = chapterDateFormat.tryParse(postedDate) + } +} + +@Serializable +internal class PagesDto( + private val pages: List, +) { + fun toPages(apiBaseUrl: HttpUrl): List = pages.mapIndexed { index, path -> + Page( + index = index, + imageUrl = requireNotNull(apiBaseUrl.resolve(path)).toString(), + ) + } +} + +private object TagsSerializer : JsonTransformingSerializer>( + ListSerializer(String.serializer()), +) { + override fun transformDeserialize(element: JsonElement): JsonElement = when (element) { + JsonNull -> JsonArray(emptyList()) + is JsonPrimitive -> + element.contentOrNull + ?.let(jsonInstance::parseToJsonElement) + ?: element + else -> element + } +} + +private fun String.toStatus(): Int = when (trim().lowercase(Locale.ROOT)) { + "em lançamento", "lançando" -> SManga.ONGOING + "completo" -> SManga.COMPLETED + "hiatus", "hiato" -> SManga.ON_HIATUS + "cancelado" -> SManga.CANCELLED + else -> SManga.UNKNOWN +} + +private val chapterDateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.ROOT).apply { + timeZone = TimeZone.getTimeZone("UTC") +} diff --git a/src/pt/onereader/src/eu/kanade/tachiyomi/extension/pt/onereader/Filters.kt b/src/pt/onereader/src/eu/kanade/tachiyomi/extension/pt/onereader/Filters.kt new file mode 100644 index 00000000000..a315dd6b676 --- /dev/null +++ b/src/pt/onereader/src/eu/kanade/tachiyomi/extension/pt/onereader/Filters.kt @@ -0,0 +1,50 @@ +package eu.kanade.tachiyomi.extension.pt.onereader + +import eu.kanade.tachiyomi.source.model.Filter +import kotlinx.serialization.Serializable + +@Serializable +internal class FilterData( + val tags: List, + val types: List, +) + +internal open class SelectFilter( + name: String, + private val options: List>, +) : Filter.Select(name, options.map { it.first }.toTypedArray()) { + fun selected(): String = options[state].second +} + +internal class OrderFilter : + SelectFilter( + "Ordenar por", + listOf( + "A-Z" to "az", + "Atualizados recentemente" to "recent", + ), + ) + +internal class StatusFilter : + SelectFilter( + "Status", + listOf( + "Todos" to "", + "Em lançamento" to "Em Lançamento", + "Completo" to "Completo", + "Hiato" to "Hiatus", + "Cancelado" to "Cancelado", + ), + ) + +internal class TypeFilter(types: List) : + SelectFilter( + "Tipo", + listOf("Todos" to "") + types.map { it to it }, + ) + +internal class TagFilter(tags: List) : + SelectFilter( + "Tag", + listOf("Todas" to "") + tags.map { it to it }, + ) diff --git a/src/pt/onereader/src/eu/kanade/tachiyomi/extension/pt/onereader/OneReader.kt b/src/pt/onereader/src/eu/kanade/tachiyomi/extension/pt/onereader/OneReader.kt new file mode 100644 index 00000000000..8c4fe38ec9a --- /dev/null +++ b/src/pt/onereader/src/eu/kanade/tachiyomi/extension/pt/onereader/OneReader.kt @@ -0,0 +1,188 @@ +package eu.kanade.tachiyomi.extension.pt.onereader + +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.model.SMangaUpdate +import keiyoushi.annotation.Source +import keiyoushi.network.get +import keiyoushi.network.rateLimit +import keiyoushi.source.KeiSource +import keiyoushi.utils.firstInstanceOrNull +import keiyoushi.utils.parseAs +import keiyoushi.utils.string +import keiyoushi.utils.toJsonElement +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.serialization.json.JsonElement +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.OkHttpClient + +@Source +abstract class OneReader : KeiSource() { + + override fun OkHttpClient.Builder.configureClient(): OkHttpClient.Builder = apply { + rateLimit(3) + } + + private val apiBaseUrl = "https://api.onereader.net".toHttpUrl() + + override suspend fun getPopularManga(page: Int): MangasPage = search( + page = page, + query = "", + order = "az", + genre = "", + type = "", + status = "", + ) + + override suspend fun getLatestUpdates(page: Int): MangasPage = search( + page = page, + query = "", + order = "recent", + genre = "", + type = "", + status = "", + ) + + override suspend fun getSearchMangaList(page: Int, query: String, filters: FilterList): MangasPage { + val order = filters.firstInstanceOrNull()?.selected() ?: "az" + val genre = filters.firstInstanceOrNull()?.selected().orEmpty() + val type = filters.firstInstanceOrNull()?.selected().orEmpty() + val status = filters.firstInstanceOrNull()?.selected().orEmpty() + + return search(page, query, order, genre, type, status) + } + + private suspend fun search( + page: Int, + query: String, + order: String, + genre: String, + type: String, + status: String, + ): MangasPage { + val url = apiUrl("api", "mangas", "search").newBuilder() + .addQueryParameter("page", page.toString()) + .addQueryParameter("limit", PAGE_SIZE.toString()) + .addQueryParameter("order", order) + .addQueryParameter("role", "standard") + .addQueryParameter("search", query.trim()) + .addQueryParameter("genre", genre) + .addQueryParameter("type", type) + .addQueryParameter("status", status) + .build() + + return client.get(url).parseAs().toMangasPage() + } + + override suspend fun getMangaByUrl(url: HttpUrl): SManga? { + if (url.host != baseUrl.toHttpUrl().host) return null + val mangaKey = url.queryParameter("id") ?: return null + + val manga = SManga.create().apply { this.url = mangaKey } + + return fetchMangaUpdate(manga, emptyList(), fetchDetails = true, fetchChapters = false) + .manga + .apply { initialized = true } + } + + override fun getMangaUrl(manga: SManga): String = "$baseUrl/manga-details".toHttpUrl().newBuilder() + .addQueryParameter("id", manga.url) + .build() + .toString() + + override suspend fun fetchMangaUpdate( + manga: SManga, + chapters: List, + fetchDetails: Boolean, + fetchChapters: Boolean, + ): SMangaUpdate = coroutineScope { + val updatedManga = if (fetchDetails) { + async { + client.get(apiUrl("api", "mangas", manga.url)).parseAs().toSManga(details = true) + } + } else { + null + } + + val updatedChapters = if (fetchChapters) { + async { + client.get(apiUrl("api", "chapters", manga.url)) + .parseAs>() + .entries + .sortedByDescending { it.key.toDoubleOrNull() ?: Double.NEGATIVE_INFINITY } + .map { it.value.toSChapter(it.key) } + } + } else { + null + } + + SMangaUpdate( + manga = updatedManga?.await() ?: manga, + chapters = updatedChapters?.await() ?: chapters, + ) + } + + override val supportsRelatedMangas = false + + override suspend fun fetchRelatedMangaList(manga: SManga): List = throw UnsupportedOperationException() + + override fun getChapterUrl(chapter: SChapter): String { + val mangaKey = chapter.memo["id"]!!.string + val number = chapter.memo["number"]!!.string + + return "$baseUrl/leitor".toHttpUrl().newBuilder() + .addQueryParameter("id", mangaKey) + .addQueryParameter("capitulo", number) + .build() + .toString() + } + + override suspend fun getPageList(chapter: SChapter): List { + val mangaKey = chapter.memo["id"]?.string ?: throw Exception("Atualize a lista de capítulos") + val chapterNumber = chapter.memo["number"]!!.string.replace('.', '_') + + return client.get(apiUrl("api", "chapters", mangaKey, chapterNumber)) + .parseAs() + .toPages(apiBaseUrl) + } + + override val supportsFilterFetching get() = true + + override suspend fun fetchFilterData(): JsonElement = coroutineScope { + val tags = async { client.get(apiUrl("api", "tags")).parseAs>() } + val types = async { client.get(apiUrl("api", "types")).parseAs>() } + + FilterData( + tags = tags.await().filter(String::isNotBlank), + types = types.await().filter(String::isNotBlank), + ).toJsonElement() + } + + override fun getFilterList(data: JsonElement?): FilterList { + val filterData = data?.parseAs() + + return FilterList( + buildList { + add(OrderFilter()) + add(StatusFilter()) + if (filterData != null) { + add(TypeFilter(filterData.types)) + add(TagFilter(filterData.tags)) + } + }, + ) + } + + private fun apiUrl(vararg pathSegments: String): HttpUrl = apiBaseUrl.newBuilder() + .apply { pathSegments.forEach { addPathSegment(it) } } + .build() + + companion object { + private const val PAGE_SIZE = 24 + } +}