diff --git a/src/es/ikigaimangas/AndroidManifest.xml b/src/es/ikigaimangas/AndroidManifest.xml new file mode 100644 index 000000000..8072ee00d --- /dev/null +++ b/src/es/ikigaimangas/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/src/es/ikigaimangas/build.gradle b/src/es/ikigaimangas/build.gradle new file mode 100644 index 000000000..6330b3757 --- /dev/null +++ b/src/es/ikigaimangas/build.gradle @@ -0,0 +1,14 @@ +apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' +apply plugin: 'kotlinx-serialization' + + +ext { + extName = 'Ikigai Mangas' + pkgNameSuffix = 'es.ikigaimangas' + extClass = '.IkigaiMangas' + extVersionCode = 1 + isNsfw = true +} + +apply from: "$rootDir/common.gradle" diff --git a/src/es/ikigaimangas/res/mipmap-hdpi/ic_launcher.png b/src/es/ikigaimangas/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..0322746f5 Binary files /dev/null and b/src/es/ikigaimangas/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/es/ikigaimangas/res/mipmap-mdpi/ic_launcher.png b/src/es/ikigaimangas/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..8429c965e Binary files /dev/null and b/src/es/ikigaimangas/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/es/ikigaimangas/res/mipmap-xhdpi/ic_launcher.png b/src/es/ikigaimangas/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..71b0772ad Binary files /dev/null and b/src/es/ikigaimangas/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/es/ikigaimangas/res/mipmap-xxhdpi/ic_launcher.png b/src/es/ikigaimangas/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..eae14b811 Binary files /dev/null and b/src/es/ikigaimangas/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/es/ikigaimangas/res/mipmap-xxxhdpi/ic_launcher.png b/src/es/ikigaimangas/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..75cf47522 Binary files /dev/null and b/src/es/ikigaimangas/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/es/ikigaimangas/res/web_hi_res_512.png b/src/es/ikigaimangas/res/web_hi_res_512.png new file mode 100644 index 000000000..8c38e53c3 Binary files /dev/null and b/src/es/ikigaimangas/res/web_hi_res_512.png differ diff --git a/src/es/ikigaimangas/src/eu/kanade/tachiyomi/extension/es/ikigaimangas/IkigaiMangas.kt b/src/es/ikigaimangas/src/eu/kanade/tachiyomi/extension/es/ikigaimangas/IkigaiMangas.kt new file mode 100644 index 000000000..be2aa874a --- /dev/null +++ b/src/es/ikigaimangas/src/eu/kanade/tachiyomi/extension/es/ikigaimangas/IkigaiMangas.kt @@ -0,0 +1,216 @@ +package eu.kanade.tachiyomi.extension.es.ikigaimangas + +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.interceptor.rateLimitHost +import eu.kanade.tachiyomi.source.model.Filter +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.HttpUrl.Companion.toHttpUrl +import okhttp3.Request +import okhttp3.Response +import uy.kohesive.injekt.injectLazy +import java.text.SimpleDateFormat +import java.util.Locale +import java.util.TimeZone + +class IkigaiMangas : HttpSource() { + + override val baseUrl: String = "https://ikigaimangas.com" + private val apiBaseUrl: String = "https://panel.ikigaimangas.com" + private val pageViewerUrl: String = "https://ikigaitoon.com" + + override val lang: String = "es" + override val name: String = "Ikigai Mangas" + + override val supportsLatest: Boolean = true + + override val client = super.client.newBuilder() + .rateLimitHost(baseUrl.toHttpUrl(), 1, 2) + .rateLimitHost(apiBaseUrl.toHttpUrl(), 2, 1) + .build() + + override fun headersBuilder() = super.headersBuilder() + .add("Referer", "$baseUrl/") + + private val json: Json by injectLazy() + + private val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSSSS'Z'", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + + override fun popularMangaRequest(page: Int): Request { + val apiUrl = "$apiBaseUrl/api/swf/series?page=$page&column=view_count&direction=desc" + return GET(apiUrl, headers) + } + + override fun popularMangaParse(response: Response): MangasPage = searchMangaParse(response) + + override fun latestUpdatesRequest(page: Int): Request { + val apiUrl = "$apiBaseUrl/api/swf/series?page=$page&column=last_chapter_date&direction=desc" + return GET(apiUrl, headers) + } + + override fun latestUpdatesParse(response: Response): MangasPage = searchMangaParse(response) + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + val sortByFilter = filters.firstInstanceOrNull() + + val apiUrl = "$apiBaseUrl/api/swf/series".toHttpUrl().newBuilder() + + if (query.isNotEmpty()) apiUrl.addQueryParameter("search", query) + + apiUrl.addQueryParameter("page", page.toString()) + + val genres = filters.firstInstanceOrNull()?.state.orEmpty() + .filter(Genre::state) + .map(Genre::id) + .joinToString(",") + + val statuses = filters.firstInstanceOrNull()?.state.orEmpty() + .filter(Status::state) + .map(Status::id) + .joinToString(",") + + if (genres.isNotEmpty()) apiUrl.addQueryParameter("genres", genres) + if (statuses.isNotEmpty()) apiUrl.addQueryParameter("status", statuses) + + apiUrl.addQueryParameter("column", sortByFilter?.selected ?: "name") + apiUrl.addQueryParameter("direction", if (sortByFilter?.state?.ascending == true) "asc" else "desc") + apiUrl.addQueryParameter("type", "comic") + + return GET(apiUrl.build(), headers) + } + + override fun searchMangaParse(response: Response): MangasPage { + runCatching { fetchFilters() } + val result = json.decodeFromString(response.body.string()) + val mangaList = result.data.filter { it.type == "comic" }.map { + SManga.create().apply { + url = "/series/comic-${it.slug}#${it.id}" + title = it.name + thumbnail_url = it.cover + } + } + val hasNextPage = result.currentPage < result.lastPage + return MangasPage(mangaList, hasNextPage) + } + + override fun mangaDetailsParse(response: Response): SManga { + val slug = response.request.url + .toString() + .substringAfter("/series/comic-") + .substringBefore("#") + val apiUrl = "$apiBaseUrl/api/swf/series/$slug".toHttpUrl() + val newResponse = client.newCall(GET(url = apiUrl, headers = headers)).execute() + val result = json.decodeFromString(newResponse.body.string()) + return SManga.create().apply { + title = result.series.name + thumbnail_url = result.series.cover + description = result.series.summary + status = parseStatus(result.series.status?.id) + genre = result.series.genres?.joinToString { it.name.trim() } + } + } + + override fun getChapterUrl(chapter: SChapter): String = pageViewerUrl + chapter.url + + override fun chapterListRequest(manga: SManga): Request { + val id = manga.url.substringAfterLast("#") + return GET("$apiBaseUrl/api/swf/series/$id/chapter-list") + } + + override fun chapterListParse(response: Response): List { + val result = json.decodeFromString(response.body.string()) + return result.data.map { + SChapter.create().apply { + url = "/capitulo/${it.id}" + name = "Capítulo ${it.name}" + date_upload = runCatching { dateFormat.parse(it.date)?.time } + .getOrNull() ?: 0L + } + }.reversed() + } + + override fun pageListRequest(chapter: SChapter): Request { + val id = chapter.url.substringAfter("/capitulo/") + return GET("$apiBaseUrl/api/swf/chapters/$id") + } + + override fun pageListParse(response: Response): List { + return json.decodeFromString(response.body.string()).chapter.pages.mapIndexed { i, img -> + Page(i, "", img) + } + } + + override fun imageUrlParse(response: Response): String = throw Exception("Not used") + + private fun parseStatus(statusId: Long?) = when (statusId) { + 906397890812182531, 911437469204086787 -> SManga.ONGOING + 906409397258190851 -> SManga.ON_HIATUS + 906409532796731395, 911793517664960513 -> SManga.COMPLETED + 906426661911756802, 906428048651190273, 911793767845265410, 911793856861798402 -> SManga.CANCELLED + else -> SManga.UNKNOWN + } + + data class SortProperty(val name: String, val value: String) { + override fun toString(): String = name + } + + private fun getSortProperties(): List = listOf( + SortProperty("Nombre", "name"), + SortProperty("Creado en", "created_at"), + SortProperty("Actualización más reciente", "last_chapter_date"), + SortProperty("Número de favoritos", "bookmark_count"), + SortProperty("Número de valoración", "rating_count"), + SortProperty("Número de vistas", "view_count"), + ) + + override fun getFilterList(): FilterList { + val filters = mutableListOf>( + SortByFilter("Ordenar por", getSortProperties()), + ) + + filters += if (genresList.isNotEmpty() || statusesList.isNotEmpty()) { + listOf( + StatusFilter("Estados", getStatusFilters()), + GenreFilter("Géneros", getGenreFilters()), + ) + } else { + listOf( + Filter.Header("Presione 'Restablecer' para intentar cargar los filtros"), + ) + } + + return FilterList(filters) + } + + private fun getGenreFilters(): List = genresList.map { Genre(it.first, it.second) } + private fun getStatusFilters(): List = statusesList.map { Status(it.first, it.second) } + + private var genresList: List> = emptyList() + private var statusesList: List> = emptyList() + private var fetchFiltersAttempts = 0 + private var fetchFiltersFailed = false + + private fun fetchFilters() { + if (fetchFiltersAttempts <= 3 && ((genresList.isEmpty() && statusesList.isEmpty()) || fetchFiltersFailed)) { + val filters = runCatching { + val response = client.newCall(GET("$apiBaseUrl/api/swf/filter-options", headers)).execute() + json.decodeFromString(response.body.string()) + } + + fetchFiltersFailed = filters.isFailure + genresList = filters.getOrNull()?.data?.genres?.map { it.name.trim() to it.id } ?: emptyList() + statusesList = filters.getOrNull()?.data?.statuses?.map { it.name.trim() to it.id } ?: emptyList() + } + } + + private inline fun List<*>.firstInstanceOrNull(): R? = + filterIsInstance().firstOrNull() +} diff --git a/src/es/ikigaimangas/src/eu/kanade/tachiyomi/extension/es/ikigaimangas/IkigaiMangasDto.kt b/src/es/ikigaimangas/src/eu/kanade/tachiyomi/extension/es/ikigaimangas/IkigaiMangasDto.kt new file mode 100644 index 000000000..41899f36e --- /dev/null +++ b/src/es/ikigaimangas/src/eu/kanade/tachiyomi/extension/es/ikigaimangas/IkigaiMangasDto.kt @@ -0,0 +1,73 @@ +package eu.kanade.tachiyomi.extension.es.ikigaimangas + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class PayloadSeriesDto( + val data: List, + @SerialName("current_page")val currentPage: Int = 0, + @SerialName("last_page") val lastPage: Int = 0, +) + +@Serializable +data class SeriesDto( + val id: Long, + val name: String, + val slug: String, + val cover: String? = null, + val type: String? = null, + val summary: String? = null, + val status: SeriesStatusDto? = null, + val genres: List? = null, +) + +@Serializable +data class PayloadSeriesDetailsDto( + val series: SeriesDto, +) + +@Serializable +data class PayloadChaptersDto( + var data: List, +) + +@Serializable +data class ChapterDto( + val id: Long, + val name: String, + @SerialName("published_at") val date: String, +) + +@Serializable +data class PayloadPagesDto( + val chapter: PageDto, +) + +@Serializable +data class PageDto( + val pages: List, +) + +@Serializable +data class SeriesStatusDto( + val id: Long, + val name: String, +) + +@Serializable +data class PayloadFiltersDto( + val data: GenresStatusesDto, +) + +@Serializable +data class GenresStatusesDto( + val genres: List, + val statuses: List, +) + +@Serializable +data class FilterDto( + val id: Long, + val name: String, +) diff --git a/src/es/ikigaimangas/src/eu/kanade/tachiyomi/extension/es/ikigaimangas/IkigaiMangasFilters.kt b/src/es/ikigaimangas/src/eu/kanade/tachiyomi/extension/es/ikigaimangas/IkigaiMangasFilters.kt new file mode 100644 index 000000000..e34f90192 --- /dev/null +++ b/src/es/ikigaimangas/src/eu/kanade/tachiyomi/extension/es/ikigaimangas/IkigaiMangasFilters.kt @@ -0,0 +1,18 @@ +package eu.kanade.tachiyomi.extension.es.ikigaimangas + +import eu.kanade.tachiyomi.source.model.Filter + +class Genre(title: String, val id: Long) : Filter.CheckBox(title) +class GenreFilter(title: String, genres: List) : Filter.Group(title, genres) + +class Status(title: String, val id: Long) : Filter.CheckBox(title) +class StatusFilter(title: String, statuses: List) : Filter.Group(title, statuses) + +class SortByFilter(title: String, private val sortProperties: List) : Filter.Sort( + title, + sortProperties.map { it.name }.toTypedArray(), + Selection(0, ascending = true), +) { + val selected: String + get() = sortProperties[state!!.index].value +}