diff --git a/src/en/comix/build.gradle b/src/en/comix/build.gradle new file mode 100644 index 000000000..73ba4a18c --- /dev/null +++ b/src/en/comix/build.gradle @@ -0,0 +1,8 @@ +ext { + extName = 'Comix' + extClass = '.Comix' + extVersionCode = 1 + isNsfw = true +} + +apply from: "$rootDir/common.gradle" diff --git a/src/en/comix/res/mipmap-hdpi/ic_launcher.png b/src/en/comix/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..da3ddb746 Binary files /dev/null and b/src/en/comix/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/en/comix/res/mipmap-mdpi/ic_launcher.png b/src/en/comix/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..b0073354f Binary files /dev/null and b/src/en/comix/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/en/comix/res/mipmap-xhdpi/ic_launcher.png b/src/en/comix/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..307d053f2 Binary files /dev/null and b/src/en/comix/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/en/comix/res/mipmap-xxhdpi/ic_launcher.png b/src/en/comix/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..d2894c12a Binary files /dev/null and b/src/en/comix/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/en/comix/res/mipmap-xxxhdpi/ic_launcher.png b/src/en/comix/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..cb50749de Binary files /dev/null and b/src/en/comix/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/en/comix/src/eu/kanade/tachiyomi/extension/en/comix/Comix.kt b/src/en/comix/src/eu/kanade/tachiyomi/extension/en/comix/Comix.kt new file mode 100644 index 000000000..993a3b513 --- /dev/null +++ b/src/en/comix/src/eu/kanade/tachiyomi/extension/en/comix/Comix.kt @@ -0,0 +1,288 @@ +package eu.kanade.tachiyomi.extension.en.comix + +import android.content.SharedPreferences +import androidx.preference.ListPreference +import androidx.preference.PreferenceScreen +import androidx.preference.SwitchPreferenceCompat +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.interceptor.rateLimit +import eu.kanade.tachiyomi.source.ConfigurableSource +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.getPreferences +import keiyoushi.utils.parseAs +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.Request +import okhttp3.Response + +class Comix : HttpSource(), ConfigurableSource { + + override val name = "Comix" + override val baseUrl = "https://comix.to" + private val apiUrl = "https://comix.to/api/v2/" + override val lang = "en" + override val supportsLatest = true + + private val preferences: SharedPreferences = getPreferences() + override val client = network.cloudflareClient.newBuilder() + .rateLimit(5) + .build() + + override fun headersBuilder() = super.headersBuilder().add("Referer", "$baseUrl/") + + override fun imageUrlParse(response: Response) = + throw UnsupportedOperationException() + + /******************************* POPULAR MANGA ************************************/ + override fun popularMangaRequest(page: Int): Request { + val url = apiUrl.toHttpUrl().newBuilder() + .addPathSegment("manga") + .addQueryParameter("order[views_30d]", "desc") + .addQueryParameter("limit", "50") + .addQueryParameter("page", page.toString()) + .build() + + return GET(url, headers) + } + + override fun popularMangaParse(response: Response) = + searchMangaParse(response) + + /******************************* LATEST MANGA ************************************/ + override fun latestUpdatesRequest(page: Int): Request { + val url = apiUrl.toHttpUrl().newBuilder() + .addPathSegment("manga") + .addQueryParameter("order[chapter_updated_at]", "desc") + .addQueryParameter("limit", "50") + .addQueryParameter("page", page.toString()) + .build() + + return GET(url, headers) + } + + override fun latestUpdatesParse(response: Response) = + searchMangaParse(response) + + /******************************* SEARCHING ***************************************/ + override fun getFilterList() = ComixFilters().getFilterList() + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + val url = apiUrl.toHttpUrl().newBuilder() + .addPathSegment("manga") + + filters.filterIsInstance() + .forEach { it.addToUri(url) } + + // Make searches accurate + if (query.isNotBlank()) { + url.addQueryParameter("keyword", query) + url.removeAllQueryParameters("order[views_30d]") + url.setQueryParameter("order[relevance]", "desc") + } + + url.addQueryParameter("limit", "50") + .addQueryParameter("page", page.toString()) + + return GET(url.build(), headers) + } + + override fun searchMangaParse(response: Response): MangasPage { + val res: SearchResponse = response.parseAs() + val posterQuality = preferences.posterQuality() + + val manga = + res.result.items.map { manga -> manga.toBasicSManga(posterQuality) } + return MangasPage(manga, res.result.pagination.page < res.result.pagination.lastPage) + } + + /******************************* MANGA DETAILS ***************************************/ + override fun mangaDetailsRequest(manga: SManga): Request { + val url = apiUrl.toHttpUrl().newBuilder() + .addPathSegment("manga") + .addPathSegment(manga.url) + .addQueryParameter("includes[]", "demographic") + .addQueryParameter("includes[]", "genre") + .addQueryParameter("includes[]", "theme") + .addQueryParameter("includes[]", "author") + .addQueryParameter("includes[]", "artist") + .addQueryParameter("includes[]", "publisher") + .build() + + return GET(url, headers) + } + + override fun mangaDetailsParse(response: Response): SManga { + val mangaResponse: SingleMangaResponse = response.parseAs() + + return mangaResponse.result.toSManga( + preferences.posterQuality(), + preferences.alternativeNamesInDescription(), + ) + } + + override fun getMangaUrl(manga: SManga): String = + "$baseUrl/title${manga.url}" + + /******************************* Chapters List *******************************/ + override fun getChapterUrl(chapter: SChapter) = + "$baseUrl/${chapter.url}" + + override fun chapterListRequest(manga: SManga): Request { + return chapterListRequest(manga.url.removePrefix("/"), 1) + } + + private fun chapterListRequest(mangaHash: String, page: Int): Request { + val url = apiUrl.toHttpUrl().newBuilder() + .addPathSegment("manga") + .addPathSegment(mangaHash) + .addPathSegment("chapters") + .addQueryParameter("order[number]", "desc") + .addQueryParameter("limit", "100") + .addQueryParameter("page", page.toString()) + .build() + + return GET(url, headers) + } + + override fun chapterListParse(response: Response): List { + val deduplicate = preferences.deduplicateChapters() + val mangaHash = response.request.url.pathSegments[3] + var resp: ChapterDetailsResponse = response.parseAs() + + // When deduplication is enabled store only the best chapter per number. + var chapterMap: LinkedHashMap? = null + // When disabled just accumulate all. + var chapterList: ArrayList? = null + + if (deduplicate) { + chapterMap = LinkedHashMap() + deduplicateChapters(chapterMap, resp.result.items) + } else { + chapterList = ArrayList(resp.result.items) + } + + var page = 2 + var hasNext: Boolean + + do { + resp = client + .newCall(chapterListRequest(mangaHash, page++)) + .execute() + .parseAs() + + val items = resp.result.items + hasNext = resp.result.pagination.lastPage > resp.result.pagination.page + + if (deduplicate) { + deduplicateChapters(chapterMap!!, items) + } else { + chapterList!!.addAll(items) + } + } while (hasNext) + + val finalChapters: List = + if (deduplicate) { + chapterMap!!.values.toList() + } else { + chapterList!! + } + + return finalChapters.map { it.toSChapter(mangaHash) } + } + + private fun deduplicateChapters( + chapterMap: LinkedHashMap, + items: List, + ) { + for (ch in items) { + val key = ch.number + val current = chapterMap[key] + if (current == null) { + chapterMap[key] = ch + } else { + // Prefer official scan group + val officialNew = ch.scanlationGroupId == 9275 + val officialCurrent = current.scanlationGroupId == 9275 + val better = when { + officialNew && !officialCurrent -> true + !officialNew && officialCurrent -> false + // compare votes then updatedAt + else -> when { + ch.votes > current.votes -> true + ch.votes < current.votes -> false + else -> ch.updatedAt > current.updatedAt + } + } + if (better) chapterMap[key] = ch + } + } + } + + /******************************* Page List (Reader) ************************************/ + override fun pageListRequest(chapter: SChapter): Request { + val chapterId = chapter.url.substringAfterLast("/") + val url = "${apiUrl}chapters/$chapterId" + return GET(url, headers) + } + + override fun pageListParse(response: Response): List { + val res: ChapterResponse = response.parseAs() + val result = res.result ?: throw Exception("Chapter not found") + + if (result.images.isEmpty()) { + throw Exception("No images found for chapter ${result.chapterId}") + } + + return result.images.mapIndexed { index, url -> + Page(index, imageUrl = url) + } + } + + /******************************* PREFERENCES ************************************/ + override fun setupPreferenceScreen(screen: PreferenceScreen) { + ListPreference(screen.context).apply { + key = PREF_POSTER_QUALITY + title = "Thumbnail Quality" + summary = "Change the quality of the thumbnail. Current: %s." + entryValues = arrayOf("small", "medium", "large") + entries = arrayOf("Small", "Medium", "Large") + setDefaultValue("large") + }.let(screen::addPreference) + + SwitchPreferenceCompat(screen.context).apply { + key = DEDUPLICATE_CHAPTERS + title = "Deduplicate Chapters" + summary = "Remove duplicate chapters from the chapter list.\n" + + "Official chapters (Comix-marked) are preferred, followed by the highest-voted or most recent.\n" + + "Warning: It can be slow on large lists." + + setDefaultValue(false) + }.let(screen::addPreference) + + SwitchPreferenceCompat(screen.context).apply { + key = ALTERNATIVE_NAMES_IN_DESCRIPTION + title = "Show Alternative Names in Description" + + setDefaultValue(false) + }.let(screen::addPreference) + } + + private fun SharedPreferences.posterQuality() = + getString(PREF_POSTER_QUALITY, "large") + + private fun SharedPreferences.deduplicateChapters() = + getBoolean(DEDUPLICATE_CHAPTERS, false) + + private fun SharedPreferences.alternativeNamesInDescription() = + getBoolean(ALTERNATIVE_NAMES_IN_DESCRIPTION, false) + + companion object { + private const val PREF_POSTER_QUALITY = "pref_poster_quality" + private const val DEDUPLICATE_CHAPTERS = "pref_deduplicate_chapters" + private const val ALTERNATIVE_NAMES_IN_DESCRIPTION = "pref_alt_names_in_description" + } +} diff --git a/src/en/comix/src/eu/kanade/tachiyomi/extension/en/comix/ComixDto.kt b/src/en/comix/src/eu/kanade/tachiyomi/extension/en/comix/ComixDto.kt new file mode 100644 index 000000000..f16008170 --- /dev/null +++ b/src/en/comix/src/eu/kanade/tachiyomi/extension/en/comix/ComixDto.kt @@ -0,0 +1,177 @@ +package eu.kanade.tachiyomi.extension.en.comix + +import eu.kanade.tachiyomi.source.model.SChapter +import eu.kanade.tachiyomi.source.model.SManga +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class Term( + @SerialName("term_id") + private val termId: Int, + private val type: String, + val title: String, + private val slug: String, + private val count: Int?, +) + +@Serializable +class Manga( + @SerialName("hash_id") + private val hashId: String, + private val title: String, + @SerialName("alt_titles") + private val altTitles: List, + private val synopsis: String?, + private val type: String, + private val poster: Poster, + private val status: String, + @SerialName("is_nsfw") + private val isNsfw: Boolean, + private val author: List?, + private val artist: List?, + private val genre: List?, + private val theme: List?, + private val demographic: List?, +) { + @Serializable + class Poster( + private val small: String, + private val medium: String, + private val large: String, + ) { + fun from(quality: String?) = when (quality) { + "large" -> large + "small" -> small + else -> medium + } + } + + fun toSManga( + posterQuality: String?, + altTitlesInDesc: Boolean = false, + ) = SManga.create().apply { + url = "/$hashId" + title = this@Manga.title + author = this@Manga.author.takeUnless { it.isNullOrEmpty() }?.joinToString { it.title } + artist = this@Manga.artist.takeUnless { it.isNullOrEmpty() }?.joinToString { it.title } + description = buildString { + synopsis.takeUnless { it.isNullOrEmpty() } + ?.let { append(it) } + altTitles.takeIf { altTitlesInDesc && it.isNotEmpty() } + ?.let { altName -> + append("\n\n") + append("Alternative Names:\n") + append(altName.joinToString("\n")) + } + } + initialized = true + status = when (this@Manga.status) { + "releasing" -> SManga.ONGOING + "on_hiatus" -> SManga.ON_HIATUS + "finished" -> SManga.COMPLETED + "discontinued" -> SManga.CANCELLED + else -> SManga.UNKNOWN + } + thumbnail_url = this@Manga.poster.from(posterQuality) + genre = getGenres() + } + + fun toBasicSManga(posterQuality: String?) = SManga.create().apply { + url = "/$hashId" + title = this@Manga.title + thumbnail_url = this@Manga.poster.from(posterQuality) + } + + fun getGenres() = buildList { + when (type) { + "manhwa" -> add("Manhwa") + "manhua" -> add("Manhua") + "manga" -> add("Manga") + else -> add("Other") + } + genre.takeUnless { it.isNullOrEmpty() }?.map { it.title } + .let { addAll(it ?: emptyList()) } + theme.takeUnless { it.isNullOrEmpty() }?.map { it.title } + .let { addAll(it ?: emptyList()) } + demographic.takeUnless { it.isNullOrEmpty() }?.map { it.title } + .let { addAll(it ?: emptyList()) } + if (isNsfw) add("NSFW") + }.distinct().joinToString() +} + +@Serializable +class SingleMangaResponse( + val result: Manga, +) + +@Serializable +class Pagination( + @SerialName("current_page") val page: Int, + @SerialName("last_page") val lastPage: Int, +) + +@Serializable +class SearchResponse( + val result: Items, +) { + @Serializable + class Items( + val items: List, + val pagination: Pagination, + ) +} + +@Serializable +class ChapterDetailsResponse( + val result: Items, +) { + @Serializable + class Items( + val items: List, + val pagination: Pagination, + ) +} + +@Serializable +class Chapter( + @SerialName("chapter_id") + private val chapterId: Int, + @SerialName("scanlation_group_id") val scanlationGroupId: Int, + val number: Double, + private val name: String, + val votes: Int, + @SerialName("updated_at") + val updatedAt: Long, + @SerialName("scanlation_group") + private val scanlationGroup: ScanlationGroup?, +) { + @Serializable + class ScanlationGroup( + val name: String, + ) + + fun toSChapter(mangaId: String) = SChapter.create().apply { + url = "title/$mangaId/$chapterId" + name = buildString { + append("Chapter ") + append(this@Chapter.number.toString().removeSuffix(".0")) + this@Chapter.name.takeUnless { it.isEmpty() }?.let { append(": $it") } + } + date_upload = this@Chapter.updatedAt * 1000 + chapter_number = this@Chapter.number.toFloat() + scanlator = this@Chapter.scanlationGroup?.name ?: "Unknown" + } +} + +@Serializable +class ChapterResponse( + val result: Items?, +) { + @Serializable + class Items( + @SerialName("chapter_id") + val chapterId: Int, + val images: List, + ) +} diff --git a/src/en/comix/src/eu/kanade/tachiyomi/extension/en/comix/ComixFilters.kt b/src/en/comix/src/eu/kanade/tachiyomi/extension/en/comix/ComixFilters.kt new file mode 100644 index 000000000..96117a0dc --- /dev/null +++ b/src/en/comix/src/eu/kanade/tachiyomi/extension/en/comix/ComixFilters.kt @@ -0,0 +1,265 @@ +package eu.kanade.tachiyomi.extension.en.comix + +import eu.kanade.tachiyomi.source.model.Filter +import eu.kanade.tachiyomi.source.model.FilterList +import okhttp3.HttpUrl +import java.util.Calendar + +class ComixFilters { + interface UriFilter { + fun addToUri(builder: HttpUrl.Builder) + } + + companion object { + private val currentYear by lazy { + Calendar.getInstance()[Calendar.YEAR] + } + + private fun getYearsArray(includeOlder: Boolean): Array> { + val years = (currentYear downTo 1990).map { it.toString() to it.toString() } + return if (includeOlder) { + (years + ("Older" to "older")).toTypedArray() + } else { + years.toTypedArray() + } + } + + fun getGenres() = arrayOf( + Pair("Action", "6"), + Pair("Adult", "87264"), + Pair("Adventure", "7"), + Pair("Boys Love", "8"), + Pair("Comedy", "9"), + Pair("Crime", "10"), + Pair("Drama", "11"), + Pair("Ecchi", "87265"), + Pair("Fantasy", "12"), + Pair("Girls Love", "13"), + Pair("Hentai", "87266"), + Pair("Historical", "14"), + Pair("Horror", "15"), + Pair("Isekai", "16"), + Pair("Magical Girls", "17"), + Pair("Mature", "87267"), + Pair("Mecha", "18"), + Pair("Medical", "19"), + Pair("Mystery", "20"), + Pair("Philosophical", "21"), + Pair("Psychological", "22"), + Pair("Romance", "23"), + Pair("Sci-Fi", "24"), + Pair("Slice of Life", "25"), + Pair("Smut", "87268"), + Pair("Sports", "26"), + Pair("Superhero", "27"), + Pair("Thriller", "28"), + Pair("Tragedy", "29"), + Pair("Wuxia", "30"), + Pair("Aliens", "31"), + Pair("Animals", "32"), + Pair("Cooking", "33"), + Pair("Cross Dressing", "34"), + Pair("Delinquents", "35"), + Pair("Demons", "36"), + Pair("Genderswap", "37"), + Pair("Ghosts", "38"), + Pair("Gyaru", "39"), + Pair("Harem", "40"), + Pair("Incest", "41"), + Pair("Loli", "42"), + Pair("Mafia", "43"), + Pair("Magic", "44"), + Pair("Martial Arts", "45"), + Pair("Military", "46"), + Pair("Monster Girls", "47"), + Pair("Monsters", "48"), + Pair("Music", "49"), + Pair("Ninja", "50"), + Pair("Office Workers", "51"), + Pair("Police", "52"), + Pair("Post-Apocalyptic", "53"), + Pair("Reincarnation", "54"), + Pair("Reverse Harem", "55"), + Pair("Samurai", "56"), + Pair("School Life", "57"), + Pair("Shota", "58"), + Pair("Supernatural", "59"), + Pair("Survival", "60"), + Pair("Time Travel", "61"), + Pair("Traditional Games", "62"), + Pair("Vampires", "63"), + Pair("Video Games", "64"), + Pair("Villainess", "65"), + Pair("Virtual Reality", "66"), + Pair("Zombies", "67"), + ) + + fun getDemographics() = arrayOf( + Pair("Shoujo", "1"), + Pair("Shounen", "2"), + Pair("Josei", "3"), + Pair("Seinen", "4"), + ) + } + + fun getFilterList() = FilterList( + SortFilter(getSortables()), + StatusFilter(), + MinChapterFilter(), + GenreFilter(getGenres()), + TypeFilter(), + DemographicFilter(getDemographics()), + Filter.Separator(), + Filter.Header("Release Year"), + YearFromFilter(), + YearToFilter(), + ) + + private open class UriPartFilter( + name: String, + private val param: String, + private val vals: Array>, + defaultValue: String? = null, + ) : Filter.Select( + name, + vals.map { it.first }.toTypedArray(), + vals.indexOfFirst { it.second == defaultValue }.takeIf { it != -1 } ?: 0, + ), + UriFilter { + override fun addToUri(builder: HttpUrl.Builder) { + builder.addQueryParameter(param, vals[state].second) + } + } + + private open class UriMultiSelectOption(name: String, val value: String) : Filter.CheckBox(name) + + private open class UriMultiSelectFilter( + name: String, + private val param: String, + private val vals: Array>, + ) : Filter.Group( + name, + vals.map { UriMultiSelectOption(it.first, it.second) }, + ), + UriFilter { + override fun addToUri(builder: HttpUrl.Builder) { + val checked = state.filter { it.state } + checked.forEach { + builder.addQueryParameter(param, it.value) + } + } + } + + private open class UriTriSelectOption(name: String, val value: String) : Filter.TriState(name) + + private open class UriTriSelectFilter( + name: String, + private val param: String, + private val vals: Array>, + ) : Filter.Group( + name, + vals.map { UriTriSelectOption(it.first, it.second) }, + ), + UriFilter { + override fun addToUri(builder: HttpUrl.Builder) { + state.forEach { s -> + when (s.state) { + TriState.STATE_INCLUDE -> builder.addQueryParameter(param, s.value) + TriState.STATE_EXCLUDE -> builder.addQueryParameter(param, "-${s.value}") + } + } + } + } + + private class DemographicFilter(val demographics: Array>) : + UriTriSelectFilter( + "Demographic", + "demographics[]", + demographics, + ) + + private class TypeFilter : UriMultiSelectFilter( + "Type", + "type", + arrayOf( + Pair("Manga", "manga"), + Pair("Manhwa", "manhwa"), + Pair("Manhua", "manhua"), + Pair("Other", "other"), + ), + ) + + private class GenreFilter(genres: Array>) : UriTriSelectFilter( + "Genres", + "genres[]", + genres, + ) + + private class StatusFilter : UriMultiSelectFilter( + "Status", + "statuses[]", + arrayOf( + Pair("Finished", "finished"), + Pair("Releasing", "releasing"), + Pair("On Hiatus", "on_hiatus"), + Pair("Discontinued", "discontinued"), + Pair("Not Yet Released", "not_yet_released"), + ), + ) + + private class YearFromFilter : UriPartFilter( + "From", + "release_year[from]", + getYearsArray(includeOlder = true), + "older", + ) + + private class YearToFilter : UriPartFilter( + "To", + "release_year[to]", + getYearsArray(includeOlder = false), + ) + + private class MinChapterFilter : Filter.Text("Minimum Chapter Length"), UriFilter { + override fun addToUri(builder: HttpUrl.Builder) { + if (state.isNotEmpty()) { + val value = state.toIntOrNull()?.takeIf { it > 0 } + ?: throw IllegalArgumentException( + "Minimum chapter length must be a positive integer greater than 0", + ) + builder.addQueryParameter("min_chap", value.toString()) + } + } + } + + private data class Sortable(val title: String, val value: String) { + override fun toString(): String = title + } + + private fun getSortables() = arrayOf( + Sortable("Best Match", "relevance"), + Sortable("Popular", "views_30d"), + Sortable("Updated Date", "chapter_updated_at"), + Sortable("Created Date", "created_at"), + Sortable("Title", "title"), + Sortable("Year", "year"), + Sortable("Total Views", "total_views"), + Sortable("Most Follows", "followed_count"), + ) + + private class SortFilter(private val sortables: Array) : + Filter.Sort( + "Sort By", + sortables.map(Sortable::title).toTypedArray(), + Selection(1, false), + ), + UriFilter { + override fun addToUri(builder: HttpUrl.Builder) { + if (state != null) { + val query = sortables[state!!.index].value + val value = if (state!!.ascending) "asc" else "desc" + builder.addQueryParameter("order[$query]", value) + } + } + } +}