* Comix init

* Add genre, author and artist parsing

* add search and filters

* hardcode genres and themes

* Add the ability to retrieve chapter pages (#1)

Co-authored-by: EgoMaw <dev@egomaw.net>

* Add a pref for deduplicating chapters, cleanup code

* fix api path

* apparently sometimes the synopsis can be null, or an empty string

* changes according to feedback

* fix pagination, and dont call func inside map when not needed

* Fix chapter list parsing ignoring first page and remove unused fields from DTOs

* dont use custom Json instance

---------

Co-authored-by: Hiirbaf <42479509+Hiirbaf@users.noreply.github.com>
This commit is contained in:
EgoMaw 2025-11-16 10:04:29 +02:00 committed by Draff
parent 1e768ace94
commit 7bf99f4bd0
Signed by: Draff
GPG Key ID: E8A89F3211677653
9 changed files with 738 additions and 0 deletions

View File

@ -0,0 +1,8 @@
ext {
extName = 'Comix'
extClass = '.Comix'
extVersionCode = 1
isNsfw = true
}
apply from: "$rootDir/common.gradle"

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View File

@ -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<ComixFilters.UriFilter>()
.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<SChapter> {
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<Number, Chapter>? = null
// When disabled just accumulate all.
var chapterList: ArrayList<Chapter>? = 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<Chapter> =
if (deduplicate) {
chapterMap!!.values.toList()
} else {
chapterList!!
}
return finalChapters.map { it.toSChapter(mangaHash) }
}
private fun deduplicateChapters(
chapterMap: LinkedHashMap<Number, Chapter>,
items: List<Chapter>,
) {
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<Page> {
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"
}
}

View File

@ -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<String>,
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<Term>?,
private val artist: List<Term>?,
private val genre: List<Term>?,
private val theme: List<Term>?,
private val demographic: List<Term>?,
) {
@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<Manga>,
val pagination: Pagination,
)
}
@Serializable
class ChapterDetailsResponse(
val result: Items,
) {
@Serializable
class Items(
val items: List<Chapter>,
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<String>,
)
}

View File

@ -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<Pair<String, String>> {
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<Pair<String, String>>,
defaultValue: String? = null,
) : Filter.Select<String>(
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<Pair<String, String>>,
) : Filter.Group<UriMultiSelectOption>(
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<Pair<String, String>>,
) : Filter.Group<UriTriSelectOption>(
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<Pair<String, String>>) :
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<Pair<String, String>>) : 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<Sortable>) :
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)
}
}
}
}