Comick (unoriginal) (#11074)

* Comick (Unoriginal)

* icon

* factory languages

* no need for custom disk cache

* unused
This commit is contained in:
AwkwardPeak7 2025-10-16 17:09:04 +05:00 committed by Draff
parent cbaf26bf4d
commit 89c380c808
Signed by: Draff
GPG Key ID: E8A89F3211677653
10 changed files with 733 additions and 0 deletions

View File

@ -0,0 +1,12 @@
ext {
extName = 'Comick (Unoriginal)'
extClass = '.ComickFactory'
extVersionCode = 1
isNsfw = true
}
apply from: "$rootDir/common.gradle"
dependencies {
compileOnly("com.squareup.okhttp3:okhttp-brotli:5.0.0-alpha.11")
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View File

@ -0,0 +1,382 @@
package eu.kanade.tachiyomi.extension.all.comicklive
import android.util.Log
import androidx.preference.ListPreference
import androidx.preference.PreferenceScreen
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.await
import eu.kanade.tachiyomi.source.ConfigurableSource
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 eu.kanade.tachiyomi.util.asJsoup
import keiyoushi.utils.firstInstance
import keiyoushi.utils.firstInstanceOrNull
import keiyoushi.utils.getPreferences
import keiyoushi.utils.parseAs
import keiyoushi.utils.tryParse
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import okhttp3.CacheControl
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Request
import okhttp3.Response
import okhttp3.brotli.BrotliInterceptor
import okhttp3.internal.closeQuietly
import org.jsoup.Jsoup
import java.text.SimpleDateFormat
import java.util.Locale
class Comick(
override val lang: String,
private val siteLang: String = lang,
) : HttpSource(), ConfigurableSource {
override val name = "Comick (Unoriginal)"
override val supportsLatest = true
private val preferences = getPreferences()
override val baseUrl: String
get() {
val index = preferences.getString(DOMAIN_PREF, "0")!!.toInt()
.coerceAtMost(domains.size - 1)
return domains[index]
}
override val client = network.cloudflareClient.newBuilder()
// Referer in interceptor due to domain change preference
.addNetworkInterceptor { chain ->
val request = chain.request().newBuilder()
.header("Referer", "$baseUrl/")
.build()
chain.proceed(request)
}
// fix disk cache
.apply {
val index = networkInterceptors().indexOfFirst { it is BrotliInterceptor }
if (index >= 0) interceptors().add(networkInterceptors().removeAt(index))
}
.build()
override fun popularMangaRequest(page: Int): Request {
val url = "$baseUrl/api/comics/top".toHttpUrl().newBuilder().apply {
val days = when (page) {
1, 4 -> 7
2, 5 -> 30
3, 6 -> 90
else -> throw UnsupportedOperationException()
}
val type = when (page) {
1, 2, 3 -> "follow"
4, 5, 6 -> "most_follow_new"
else -> throw UnsupportedOperationException()
}
addQueryParameter("days", days.toString())
addQueryParameter("type", type)
fragment(page.toString())
}.build()
return GET(url, headers)
}
override fun popularMangaParse(response: Response): MangasPage {
val data = response.parseAs<Data<List<BrowseComic>>>()
val page = response.request.url.fragment!!.toInt()
return MangasPage(
mangas = data.data.map(BrowseComic::toSManga),
hasNextPage = page < 6,
)
}
override fun latestUpdatesRequest(page: Int) =
GET("$baseUrl/api/chapters/latest?order=new&page=$page", headers)
override fun latestUpdatesParse(response: Response): MangasPage {
val data = response.parseAs<Data<List<BrowseComic>>>()
return MangasPage(
mangas = data.data.map(BrowseComic::toSManga),
hasNextPage = data.data.size == 100,
)
}
private var nextCursor: String? = null
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
if (page == 1) {
nextCursor = null
}
val url = "$baseUrl/api/search".toHttpUrl().newBuilder().apply {
addQueryParameter("order_by", filters.firstInstance<SortFilter>().selected)
addQueryParameter("order_direction", "desc")
filters.firstInstanceOrNull<GenreFilter>()?.let { genre ->
genre.included.forEach {
addQueryParameter("genres[]", it)
}
genre.excluded.forEach {
addQueryParameter("excludes[]", it)
}
}
filters.firstInstanceOrNull<TagFilter>()?.let { tag ->
tag.included.forEach {
addQueryParameter("tags[]", it)
}
tag.excluded.forEach {
addQueryParameter("excluded_tags[]", it)
}
}
filters.firstInstance<DemographicFilter>().checked.forEach {
addQueryParameter("demographic[]", it)
}
filters.firstInstance<CreatedAtFilter>().selected?.let {
addQueryParameter("time", it)
}
filters.firstInstance<TypeFilter>().checked.forEach {
addQueryParameter("country[]", it)
}
filters.firstInstance<MinimumChaptersFilter>().state.let {
if (it.isNotBlank()) {
if (it.toIntOrNull() == null) {
throw Exception("Invalid minimum chapters value: $it")
}
addQueryParameter("minimum", it)
}
}
filters.firstInstance<StatusFilter>().selected?.let {
addQueryParameter("status", it)
}
filters.firstInstance<ReleaseFrom>().selected?.let {
addQueryParameter("from", it)
}
filters.firstInstance<ReleaseTo>().selected?.let {
addQueryParameter("to", it)
}
filters.firstInstance<ContentRatingFilter>().selected?.let {
addQueryParameter("content_rating", it)
}
addQueryParameter("showAll", "false")
addQueryParameter("exclude_mylist", "false")
if (query.isNotBlank()) {
if (query.trim().length < 3) {
throw Exception("Query must be at least 3 characters")
}
addQueryParameter("q", query.trim())
}
addQueryParameter("type", "comic")
if (page > 1) {
addQueryParameter("cursor", nextCursor)
}
}.build()
return GET(url, headers)
}
override fun searchMangaParse(response: Response): MangasPage {
val data = response.parseAs<SearchResponse>()
nextCursor = data.cursor
return MangasPage(
mangas = data.data.map(BrowseComic::toSManga),
hasNextPage = data.cursor != null,
)
}
private val metadataClient = client.newBuilder()
.addNetworkInterceptor { chain ->
chain.proceed(chain.request()).newBuilder()
.header("Cache-Control", "max-age=${24 * 60 * 60}")
.removeHeader("Pragma")
.removeHeader("Expires")
.build()
}.build()
override fun getFilterList(): FilterList = runBlocking(Dispatchers.IO) {
val filters: MutableList<Filter<*>> = mutableListOf(
SortFilter(),
DemographicFilter(),
CreatedAtFilter(),
TypeFilter(),
MinimumChaptersFilter(),
StatusFilter(),
ContentRatingFilter(),
ReleaseFrom(),
ReleaseTo(),
)
val response = metadataClient.newCall(
GET("$baseUrl/api/metadata", headers, CacheControl.FORCE_CACHE),
).await()
// the cache only request fails if it was not cached already
if (!response.isSuccessful) {
CoroutineScope(Dispatchers.IO).launch {
metadataClient.newCall(
GET("$baseUrl/api/metadata", headers, CacheControl.FORCE_NETWORK),
).await().closeQuietly()
}
filters.addAll(
index = 0,
listOf(
Filter.Header("Press 'reset' to load genres and tags"),
Filter.Separator(),
),
)
return@runBlocking FilterList(filters)
}
val data = try {
response.parseAs<Metadata>()
} catch (e: Throwable) {
Log.e(name, "Unable to parse filters", e)
filters.addAll(
index = 0,
listOf(
Filter.Header("Failed to parse genres and tags"),
Filter.Separator(),
),
)
return@runBlocking FilterList(filters)
}
filters.addAll(
index = 1,
listOf(
GenreFilter(data.genres),
TagFilter(data.tags),
),
)
return@runBlocking FilterList(filters)
}
override fun mangaDetailsRequest(manga: SManga) =
GET("$baseUrl/comic/${manga.url}", headers)
override fun mangaDetailsParse(response: Response): SManga {
val data = response.asJsoup()
.selectFirst("#comic-data")!!.data()
.parseAs<ComicData>()
return SManga.create().apply {
title = data.title
url = data.slug
thumbnail_url = data.thumbnail
status = when (data.status) {
1 -> SManga.ONGOING
2 -> if (data.translationCompleted) SManga.COMPLETED else SManga.PUBLISHING_FINISHED
3 -> SManga.CANCELLED
4 -> SManga.ON_HIATUS
else -> SManga.UNKNOWN
}
author = data.authors.joinToString { it.name }
artist = data.artists.joinToString { it.name }
description = buildString {
append(
Jsoup.parseBodyFragment(data.desc).wholeText(),
)
if (data.titles.isNotEmpty()) {
append("\n\n Alternative Titles: \n")
data.titles.forEach {
append(it.title, "\n")
}
}
}.trim()
genre = buildList {
when (data.country) {
"jp" -> add("Manga")
"cn" -> add("Manhua")
"ko" -> add("Manhwa")
}
when (data.contentRating) {
"suggestive" -> add("Content Rating: Suggestive")
"erotica" -> add("Content Rating: Erotica")
}
addAll(data.genres.map { it.genres.name })
}.joinToString()
}
}
override fun chapterListRequest(manga: SManga) =
GET("$baseUrl/api/comics/${manga.url}/chapter-list?lang=$siteLang", headers)
override fun chapterListParse(response: Response): List<SChapter> {
var data = response.parseAs<ChapterList>()
var page = 2
val chapters = data.data.toMutableList()
while (data.hasNextPage()) {
val url = response.request.url.newBuilder()
.addQueryParameter("page", page.toString())
.build()
data = client.newCall(GET(url, headers)).execute()
.parseAs()
chapters += data.data
page++
}
val mangaSlug = response.request.url.pathSegments[2]
return chapters.map {
SChapter.create().apply {
url = "/comic/$mangaSlug/${it.hid}-chapter-${it.chap}-${it.lang}"
name = buildString {
if (!it.vol.isNullOrBlank()) {
append("Vol. ", it.vol, " ")
}
append("Ch. ", it.chap)
if (!it.title.isNullOrBlank()) {
append(": ", it.title)
}
}
date_upload = dateFormat.tryParse(it.createdAt)
scanlator = it.groups.joinToString()
}
}
}
private val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSSSS'Z'", Locale.ENGLISH)
override fun pageListParse(response: Response): List<Page> {
val data = response.asJsoup()
.selectFirst("#sv-data")!!.data()
.parseAs<PageListData>()
return data.chapter.images.mapIndexed { index, image ->
Page(index, imageUrl = image.url)
}
}
override fun imageUrlParse(response: Response): String {
throw UnsupportedOperationException()
}
override fun setupPreferenceScreen(screen: PreferenceScreen) {
ListPreference(screen.context).apply {
key = DOMAIN_PREF
title = "Preferred Domain"
entries = domains
entryValues = Array(domains.size) { it.toString() }
summary = "%s"
setDefaultValue("0")
}.also(screen::addPreference)
}
}
private val domains = arrayOf("https://comick.live", "https://comick.art")
private const val DOMAIN_PREF = "domain_pref"

View File

@ -0,0 +1,74 @@
package eu.kanade.tachiyomi.extension.all.comicklive
import eu.kanade.tachiyomi.source.SourceFactory
class ComickFactory : SourceFactory {
// as of 2025-10-15, the commented languages have 0 chapters uploaded
// from: /api/languages
override fun createSources() = listOf(
Comick("en"),
// Comick("pt-br", "pt-BR"),
// Comick("es-419", "es-la"),
Comick("ru"),
Comick("vi"),
Comick("fr"),
Comick("pl"),
Comick("id"),
Comick("tr"),
Comick("it"),
Comick("es"),
Comick("uk"),
// Comick("ar"),
// Comick("zh-hk", "zh-Hant"),
// Comick("hu"),
// Comick("zh", "zh-Hans"),
Comick("de"),
Comick("ko"),
Comick("th"),
// Comick("ca"),
// Comick("bg"),
// Comick("fa"),
Comick("ro"),
// Comick("cs"),
// Comick("mn"),
// Comick("pt"),
// Comick("he"),
// Comick("hi"),
// Comick("tl"),
Comick("ms"),
// Comick("fi"),
// Comick("eu"),
// Comick("kk"),
// Comick("sr"),
// Comick("my"),
Comick("ja"),
// Comick("el"),
// Comick("nl"),
// Comick("bn"),
// Comick("uz"),
// Comick("eo"),
// Comick("ka"),
// Comick("lt"),
// Comick("da"),
// Comick("ta"),
Comick("sv"),
// Comick("be"),
// Comick("gl"),
// Comick("cv"),
// Comick("hr"),
// Comick("la"),
// Comick("ur"),
// Comick("ne"),
Comick("no"),
// Comick("sq"),
// Comick("ga"),
// Comick("jv"),
// Comick("te"),
// Comick("sl"),
// Comick("et"),
// Comick("az"),
// Comick("sk"),
// Comick("af"),
// Comick("lv"),
)
}

View File

@ -0,0 +1,124 @@
package eu.kanade.tachiyomi.extension.all.comicklive
import eu.kanade.tachiyomi.source.model.SManga
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
class Data<T>(
val data: T,
)
@Serializable
class SearchResponse(
val data: List<BrowseComic>,
@SerialName("next_cursor")
val cursor: String? = null,
)
@Serializable
class BrowseComic(
@SerialName("default_thumbnail")
private val thumbnail: String,
private val slug: String,
private val title: String,
) {
fun toSManga() = SManga.create().apply {
url = slug
title = this@BrowseComic.title
thumbnail_url = thumbnail
}
}
@Serializable
class Metadata(
val genres: List<Name>,
val tags: List<Name>,
) {
@Serializable
class Name(
val name: String,
val slug: String,
)
}
@Serializable
class ComicData(
val title: String,
val slug: String,
@SerialName("default_thumbnail")
val thumbnail: String,
val status: Int,
@SerialName("translation_completed")
val translationCompleted: Boolean,
val artists: List<Name>,
val authors: List<Name>,
val desc: String,
@SerialName("content_rating")
val contentRating: String,
val country: String,
@SerialName("md_comic_md_genres")
val genres: List<Genres>,
@SerialName("md_titles")
val titles: List<Title>,
) {
@Serializable
class Name(
val name: String,
)
@Serializable
class Title(
val title: String,
)
@Serializable
class Genres(
@SerialName("md_genres")
val genres: Name,
)
}
@Serializable
class ChapterList(
val data: List<Chapter>,
private val pagination: Pagination,
) {
fun hasNextPage() = pagination.page < pagination.lastPage
@Serializable
class Chapter(
val hid: String,
val chap: String,
val vol: String?,
val lang: String,
val title: String?,
@SerialName("created_at")
val createdAt: String,
@SerialName("group_name")
val groups: List<String>,
)
@Serializable
class Pagination(
@SerialName("current_page")
val page: Int,
@SerialName("last_page")
val lastPage: Int,
)
}
@Serializable
class PageListData(
val chapter: ChapterData,
) {
@Serializable
class ChapterData(
val images: List<Image>,
) {
@Serializable
class Image(
val url: String,
)
}
}

View File

@ -0,0 +1,141 @@
package eu.kanade.tachiyomi.extension.all.comicklive
import eu.kanade.tachiyomi.source.model.Filter
import java.util.Calendar
abstract class SelectFilter(
name: String,
private val options: List<Pair<String, String>>,
) : Filter.Select<String>(
name,
options.map { it.first }.toTypedArray(),
) {
val selected get() = options[state].second.takeIf { it.isNotEmpty() }
}
class CheckBoxFilter(name: String, val value: String) : Filter.CheckBox(name)
abstract class CheckBoxGroup(
name: String,
options: List<Pair<String, String>>,
) : Filter.Group<CheckBoxFilter>(
name,
options.map { CheckBoxFilter(it.first, it.second) },
) {
val checked get() = state.filter { it.state }.map { it.value }
}
class TriStateFilter(name: String, val slug: String) : Filter.TriState(name)
abstract class TriStateGroupFilter(
name: String,
options: List<Pair<String, String>>,
) : Filter.Group<TriStateFilter>(
name,
options.map { TriStateFilter(it.first, it.second) },
) {
val included get() = state.filter { it.isIncluded() }.map { it.slug }
val excluded get() = state.filter { it.isExcluded() }.map { it.slug }
}
class SortFilter : SelectFilter(
name = "Sort",
options = listOf(
"Latest" to "created_at",
"Popular" to "user_follow_count",
"Highest Rating" to "rating",
"Last Uploaded" to "uploaded",
),
)
class GenreFilter(genres: List<Metadata.Name>) : TriStateGroupFilter(
name = "Genre",
options = genres.map { it.name to it.slug },
)
class TagFilter(tags: List<Metadata.Name>) : TriStateGroupFilter(
name = "Tags",
options = tags.map { it.name to it.slug },
)
class DemographicFilter : CheckBoxGroup(
name = "Demographic",
options = listOf(
"Shounen" to "1",
"Josei" to "2",
"Seinen" to "3",
"Shoujo" to "4",
"None" to "0",
),
)
class CreatedAtFilter : SelectFilter(
name = "Created At",
options = listOf(
"" to "",
"3 days ago" to "3",
"7 days ago" to "7",
"30 days ago" to "30",
"3 months ago" to "90",
"6 months ago" to "180",
"1 year ago" to "365",
"2 years ago" to "730",
),
)
class TypeFilter : CheckBoxGroup(
name = "Type",
options = listOf(
"Manga" to "jp",
"Manhwa" to "kr",
"Manhua" to "cn",
"Others" to "others",
),
)
class MinimumChaptersFilter : Filter.Text(
name = "Minimum Chapters",
)
class StatusFilter : SelectFilter(
name = "Status",
options = listOf(
"" to "",
"Ongoing" to "1",
"Completed" to "2",
"Cancelled" to "3",
"Hiatus" to "4",
),
)
class ContentRatingFilter : SelectFilter(
name = "Content Rating",
options = listOf(
"" to "",
"Safe" to "safe",
"Suggestive" to "suggestive",
"Erotica" to "erotica",
),
)
class ReleaseFrom : SelectFilter(
name = "Release From",
options = buildList {
add(("" to ""))
Calendar.getInstance().get(Calendar.YEAR).downTo(1990).mapTo(this) {
("$it" to it.toString())
}
add(("Before 1990" to "0"))
},
)
class ReleaseTo : SelectFilter(
name = "Release To",
options = buildList {
add(("" to ""))
Calendar.getInstance().get(Calendar.YEAR).downTo(1990).mapTo(this) {
("$it" to it.toString())
}
add(("Before 1990" to "0"))
},
)