diff --git a/src/all/comicklive/build.gradle b/src/all/comicklive/build.gradle new file mode 100644 index 000000000..bf9ccba8b --- /dev/null +++ b/src/all/comicklive/build.gradle @@ -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") +} diff --git a/src/all/comicklive/res/mipmap-hdpi/ic_launcher.png b/src/all/comicklive/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..f8a3106a5 Binary files /dev/null and b/src/all/comicklive/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/all/comicklive/res/mipmap-mdpi/ic_launcher.png b/src/all/comicklive/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..81c0f670a Binary files /dev/null and b/src/all/comicklive/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/all/comicklive/res/mipmap-xhdpi/ic_launcher.png b/src/all/comicklive/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..5b0c0098e Binary files /dev/null and b/src/all/comicklive/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/all/comicklive/res/mipmap-xxhdpi/ic_launcher.png b/src/all/comicklive/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..9057665f9 Binary files /dev/null and b/src/all/comicklive/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/all/comicklive/res/mipmap-xxxhdpi/ic_launcher.png b/src/all/comicklive/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..64aad4954 Binary files /dev/null and b/src/all/comicklive/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/all/comicklive/src/eu/kanade/tachiyomi/extension/all/comicklive/Comick.kt b/src/all/comicklive/src/eu/kanade/tachiyomi/extension/all/comicklive/Comick.kt new file mode 100644 index 000000000..30f9a0963 --- /dev/null +++ b/src/all/comicklive/src/eu/kanade/tachiyomi/extension/all/comicklive/Comick.kt @@ -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>>() + 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>>() + + 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().selected) + addQueryParameter("order_direction", "desc") + filters.firstInstanceOrNull()?.let { genre -> + genre.included.forEach { + addQueryParameter("genres[]", it) + } + genre.excluded.forEach { + addQueryParameter("excludes[]", it) + } + } + filters.firstInstanceOrNull()?.let { tag -> + tag.included.forEach { + addQueryParameter("tags[]", it) + } + tag.excluded.forEach { + addQueryParameter("excluded_tags[]", it) + } + } + filters.firstInstance().checked.forEach { + addQueryParameter("demographic[]", it) + } + filters.firstInstance().selected?.let { + addQueryParameter("time", it) + } + filters.firstInstance().checked.forEach { + addQueryParameter("country[]", it) + } + filters.firstInstance().state.let { + if (it.isNotBlank()) { + if (it.toIntOrNull() == null) { + throw Exception("Invalid minimum chapters value: $it") + } + addQueryParameter("minimum", it) + } + } + filters.firstInstance().selected?.let { + addQueryParameter("status", it) + } + filters.firstInstance().selected?.let { + addQueryParameter("from", it) + } + filters.firstInstance().selected?.let { + addQueryParameter("to", it) + } + filters.firstInstance().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() + + 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> = 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() + } 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() + + 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 { + var data = response.parseAs() + 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 { + val data = response.asJsoup() + .selectFirst("#sv-data")!!.data() + .parseAs() + + 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" diff --git a/src/all/comicklive/src/eu/kanade/tachiyomi/extension/all/comicklive/ComickFactory.kt b/src/all/comicklive/src/eu/kanade/tachiyomi/extension/all/comicklive/ComickFactory.kt new file mode 100644 index 000000000..a52118240 --- /dev/null +++ b/src/all/comicklive/src/eu/kanade/tachiyomi/extension/all/comicklive/ComickFactory.kt @@ -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"), + ) +} diff --git a/src/all/comicklive/src/eu/kanade/tachiyomi/extension/all/comicklive/Dto.kt b/src/all/comicklive/src/eu/kanade/tachiyomi/extension/all/comicklive/Dto.kt new file mode 100644 index 000000000..4ba941be9 --- /dev/null +++ b/src/all/comicklive/src/eu/kanade/tachiyomi/extension/all/comicklive/Dto.kt @@ -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( + val data: T, +) + +@Serializable +class SearchResponse( + val data: List, + @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, + val tags: List, +) { + @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, + val authors: List, + val desc: String, + @SerialName("content_rating") + val contentRating: String, + val country: String, + @SerialName("md_comic_md_genres") + val genres: List, + @SerialName("md_titles") + val titles: List, +) { + @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, + ) + } +} diff --git a/src/all/comicklive/src/eu/kanade/tachiyomi/extension/all/comicklive/Filters.kt b/src/all/comicklive/src/eu/kanade/tachiyomi/extension/all/comicklive/Filters.kt new file mode 100644 index 000000000..83ec15a3e --- /dev/null +++ b/src/all/comicklive/src/eu/kanade/tachiyomi/extension/all/comicklive/Filters.kt @@ -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")) + }, +)