feat: add GlobalComix (#8637)

* feat: add GlobalComix

Closes #3726

* fix: parse comic URLs correctly

* style: cleanup

* refactor: rename PageListDataDto to PageDataDto

* fix: sort search results by relevancy

* fix: improve premium chapter detection

* refactor: add chapter number to SChapter

* refactor: remove unused fields and allow some fields to be nullable

* refactor: minor cleanup

* Update src/all/globalcomix/src/eu/kanade/tachiyomi/extension/all/globalcomix/GlobalComix.kt

Co-authored-by: AwkwardPeak7 <48650614+AwkwardPeak7@users.noreply.github.com>

* Update src/all/globalcomix/src/eu/kanade/tachiyomi/extension/all/globalcomix/GlobalComix.kt

Co-authored-by: AwkwardPeak7 <48650614+AwkwardPeak7@users.noreply.github.com>

* Update src/all/globalcomix/src/eu/kanade/tachiyomi/extension/all/globalcomix/GlobalComix.kt

Co-authored-by: AwkwardPeak7 <48650614+AwkwardPeak7@users.noreply.github.com>

* Update src/all/globalcomix/src/eu/kanade/tachiyomi/extension/all/globalcomix/dto/ChapterDto.kt

Co-authored-by: AwkwardPeak7 <48650614+AwkwardPeak7@users.noreply.github.com>

* refactor: remove CacheControl

* refactor: move constants of out object

* refactor: add new imports & remove 204 check

* refactor: remove chapter list 204 check

---------

Co-authored-by: AwkwardPeak7 <48650614+AwkwardPeak7@users.noreply.github.com>
This commit is contained in:
Tim Schneeberger 2025-04-27 04:07:47 +02:00 committed by Draff
parent 1a6774af59
commit 0da807ff70
No known key found for this signature in database
GPG Key ID: E8A89F3211677653
18 changed files with 631 additions and 0 deletions

View File

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:tools="http://schemas.android.com/tools"
xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity
android:name=".all.globalcomix.GlobalComixUrlActivity"
android:excludeFromRecents="true"
android:exported="true"
android:theme="@android:style/Theme.NoDisplay">
<intent-filter
android:autoVerify="false"
tools:targetApi="23">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:host="globalcomix.com" />
<data android:scheme="https" />
<data android:pathPattern="/c/..*" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@ -0,0 +1,5 @@
data_saver=Data saver
data_saver_summary=Enables smaller, more compressed images
invalid_manga_id=Not a valid comic ID
show_locked_chapters=Show chapters with pay-walled pages
show_locked_chapters_summary=Display chapters that require an account with a premium subscription

View File

@ -0,0 +1,12 @@
ext {
extName = 'GlobalComix'
extClass = '.GlobalComixFactory'
extVersionCode = 1
isNsfw = true
}
apply from: "$rootDir/common.gradle"
dependencies {
implementation(project(":lib:i18n"))
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@ -0,0 +1,234 @@
package eu.kanade.tachiyomi.extension.all.globalcomix
import android.content.SharedPreferences
import androidx.preference.PreferenceScreen
import androidx.preference.SwitchPreferenceCompat
import eu.kanade.tachiyomi.extension.all.globalcomix.dto.ChapterDataDto.Companion.createChapter
import eu.kanade.tachiyomi.extension.all.globalcomix.dto.ChapterDto
import eu.kanade.tachiyomi.extension.all.globalcomix.dto.ChaptersDto
import eu.kanade.tachiyomi.extension.all.globalcomix.dto.EntityDto
import eu.kanade.tachiyomi.extension.all.globalcomix.dto.MangaDataDto.Companion.createManga
import eu.kanade.tachiyomi.extension.all.globalcomix.dto.MangaDto
import eu.kanade.tachiyomi.extension.all.globalcomix.dto.MangasDto
import eu.kanade.tachiyomi.extension.all.globalcomix.dto.UnknownEntity
import eu.kanade.tachiyomi.lib.i18n.Intl
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.getPreferencesLazy
import keiyoushi.utils.parseAs
import kotlinx.serialization.json.Json
import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.plus
import kotlinx.serialization.modules.polymorphic
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Request
import okhttp3.Response
import java.text.SimpleDateFormat
import java.util.Locale
import java.util.TimeZone
abstract class GlobalComix(final override val lang: String, private val extLang: String = lang) :
ConfigurableSource, HttpSource() {
override val name = "GlobalComix"
override val baseUrl = webUrl
override val supportsLatest = true
private val preferences: SharedPreferences by getPreferencesLazy()
private val json = Json {
isLenient = true
ignoreUnknownKeys = true
serializersModule += SerializersModule {
polymorphic(EntityDto::class) {
defaultDeserializer { UnknownEntity.serializer() }
}
}
}
private val intl = Intl(
language = lang,
baseLanguage = english,
availableLanguages = setOf(english),
classLoader = this::class.java.classLoader!!,
createMessageFileName = { lang -> Intl.createDefaultMessageFileName(lang) },
)
final override fun headersBuilder() = super.headersBuilder().apply {
set("Referer", "$baseUrl/")
set("Origin", baseUrl)
set("x-gc-client", clientId)
set("x-gc-identmode", "cookie")
}
override val client = network.client.newBuilder()
.rateLimit(3)
.build()
private fun simpleQueryRequest(page: Int, orderBy: String?, query: String?): Request {
val url = apiSearchUrl.toHttpUrl().newBuilder()
.addQueryParameter("lang_id[]", extLang)
.addQueryParameter("p", page.toString())
orderBy?.let { url.addQueryParameter("sort", it) }
query?.let { url.addQueryParameter("q", it) }
return GET(url.build(), headers)
}
override fun popularMangaRequest(page: Int): Request =
simpleQueryRequest(page, orderBy = null, query = null)
override fun popularMangaParse(response: Response): MangasPage =
mangaListParse(response)
override fun latestUpdatesRequest(page: Int): Request =
simpleQueryRequest(page, "recent", query = null)
override fun latestUpdatesParse(response: Response): MangasPage =
mangaListParse(response)
private fun mangaListParse(response: Response): MangasPage {
val isSingleItemLookup = response.request.url.toString().startsWith(apiMangaUrl)
return if (!isSingleItemLookup) {
// Normally, the response is a paginated list of mangas
// The results property will be a JSON array
response.parseAs<MangasDto>().payload!!.let { dto ->
MangasPage(
dto.results.map { it -> it.createManga() },
dto.pagination.hasNextPage,
)
}
} else {
// However, when using the 'id:' query prefix (via the UrlActivity for example),
// the response is a single manga and the results property will be a JSON object
MangasPage(
listOf(
response.parseAs<MangaDto>().payload!!
.results
.createManga(),
),
false,
)
}
}
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
// If the query is a slug ID, return the manga directly
if (query.startsWith(prefixIdSearch)) {
val mangaSlugId = query.removePrefix(prefixIdSearch)
if (mangaSlugId.isEmpty()) {
throw Exception(intl["invalid_manga_id"])
}
val url = apiMangaUrl.toHttpUrl().newBuilder()
.addPathSegment(mangaSlugId)
.build()
return GET(url, headers)
}
return simpleQueryRequest(page, orderBy = "relevance", query)
}
override fun searchMangaParse(response: Response): MangasPage = popularMangaParse(response)
override fun getMangaUrl(manga: SManga): String = "$webComicUrl/${titleToSlug(manga.title)}"
override fun mangaDetailsRequest(manga: SManga): Request {
val url = apiMangaUrl.toHttpUrl().newBuilder()
.addPathSegment(titleToSlug(manga.title))
.build()
return GET(url, headers)
}
override fun mangaDetailsParse(response: Response): SManga =
response.parseAs<MangaDto>().payload!!
.results
.createManga()
override fun chapterListRequest(manga: SManga): Request {
val url = apiSearchUrl.toHttpUrl().newBuilder()
.addPathSegment(manga.url) // manga.url contains the the comic id
.addPathSegment("releases")
.addQueryParameter("lang_id", extLang)
.addQueryParameter("all", "true")
.toString()
return GET(url, headers)
}
override fun chapterListParse(response: Response): List<SChapter> =
response.parseAs<ChaptersDto>().payload!!.results.filterNot { dto ->
dto.isPremium && !preferences.showLockedChapters
}.map { it.createChapter() }
override fun getChapterUrl(chapter: SChapter): String =
"$baseUrl/read/${chapter.url}"
override fun pageListRequest(chapter: SChapter): Request {
val chapterKey = chapter.url
val url = "$apiChapterUrl/$chapterKey"
return GET(url, headers)
}
override fun pageListParse(response: Response): List<Page> {
val chapterKey = response.request.url.pathSegments.last()
val chapterWebUrl = "$webChapterUrl/$chapterKey"
return response.parseAs<ChapterDto>()
.payload!!
.results
.page_objects!!
.map { dto -> if (preferences.useDataSaver) dto.mobile_image_url else dto.desktop_image_url }
.mapIndexed { index, url -> Page(index, "$chapterWebUrl/$index", url) }
}
override fun imageUrlParse(response: Response): String = ""
override fun setupPreferenceScreen(screen: PreferenceScreen) {
val dataSaverPref = SwitchPreferenceCompat(screen.context).apply {
key = getDataSaverPreferenceKey(extLang)
title = intl["data_saver"]
summary = intl["data_saver_summary"]
setDefaultValue(false)
}
val showLockedChaptersPref = SwitchPreferenceCompat(screen.context).apply {
key = getShowLockedChaptersPreferenceKey(extLang)
title = intl["show_locked_chapters"]
summary = intl["show_locked_chapters_summary"]
setDefaultValue(true)
}
screen.addPreference(dataSaverPref)
screen.addPreference(showLockedChaptersPref)
}
private inline fun <reified T> Response.parseAs(): T = parseAs(json)
private val SharedPreferences.useDataSaver
get() = getBoolean(getDataSaverPreferenceKey(extLang), false)
private val SharedPreferences.showLockedChapters
get() = getBoolean(getShowLockedChaptersPreferenceKey(extLang), true)
companion object {
fun titleToSlug(title: String) = title.trim()
.lowercase(Locale.US)
.replace(titleSpecialCharactersRegex, "-")
val titleSpecialCharactersRegex = "[^a-z0-9]+".toRegex()
val dateFormatter = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US)
.apply { timeZone = TimeZone.getTimeZone("UTC") }
}
}

View File

@ -0,0 +1,30 @@
package eu.kanade.tachiyomi.extension.all.globalcomix
const val lockSymbol = "🔒"
// Language codes used for translations
const val english = "en"
// JSON discriminators
const val release = "Release"
const val comic = "Comic"
const val artist = "Artist"
const val releasePage = "ReleasePage"
// Web requests
const val webUrl = "https://globalcomix.com"
const val webComicUrl = "$webUrl/c"
const val webChapterUrl = "$webUrl/read"
const val apiUrl = "https://api.globalcomix.com/v1"
const val apiMangaUrl = "$apiUrl/read"
const val apiChapterUrl = "$apiUrl/readV2"
const val apiSearchUrl = "$apiUrl/comics"
const val clientId = "gck_d0f170d5729446dcb3b55e6b3ebc7bf6"
// Search prefix for title ids
const val prefixIdSearch = "id:"
// Preferences
fun getDataSaverPreferenceKey(extLang: String): String = "dataSaver_$extLang"
fun getShowLockedChaptersPreferenceKey(extLang: String): String = "showLockedChapters_$extLang"

View File

@ -0,0 +1,92 @@
package eu.kanade.tachiyomi.extension.all.globalcomix
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceFactory
class GlobalComixFactory : SourceFactory {
override fun createSources(): List<Source> = listOf(
GlobalComixAlbanian(),
GlobalComixArabic(),
GlobalComixBulgarian(),
GlobalComixBengali(),
GlobalComixBrazilianPortuguese(),
GlobalComixChineseMandarin(),
GlobalComixCzech(),
GlobalComixGerman(),
GlobalComixDanish(),
GlobalComixGreek(),
GlobalComixEnglish(),
GlobalComixSpanish(),
GlobalComixPersian(),
GlobalComixFinnish(),
GlobalComixFilipino(),
GlobalComixFrench(),
GlobalComixHindi(),
GlobalComixHungarian(),
GlobalComixIndonesian(),
GlobalComixItalian(),
GlobalComixHebrew(),
GlobalComixJapanese(),
GlobalComixKorean(),
GlobalComixLatvian(),
GlobalComixMalay(),
GlobalComixDutch(),
GlobalComixNorwegian(),
GlobalComixPolish(),
GlobalComixPortugese(),
GlobalComixRomanian(),
GlobalComixRussian(),
GlobalComixSwedish(),
GlobalComixSlovak(),
GlobalComixSlovenian(),
GlobalComixTamil(),
GlobalComixThai(),
GlobalComixTurkish(),
GlobalComixUkrainian(),
GlobalComixUrdu(),
GlobalComixVietnamese(),
GlobalComixChineseCantonese(),
)
}
class GlobalComixAlbanian : GlobalComix("al")
class GlobalComixArabic : GlobalComix("ar")
class GlobalComixBulgarian : GlobalComix("bg")
class GlobalComixBengali : GlobalComix("bn")
class GlobalComixBrazilianPortuguese : GlobalComix("pt-BR", "br")
class GlobalComixChineseMandarin : GlobalComix("zh-Hans", "cn")
class GlobalComixCzech : GlobalComix("cs", "cz")
class GlobalComixGerman : GlobalComix("de")
class GlobalComixDanish : GlobalComix("dk")
class GlobalComixGreek : GlobalComix("el")
class GlobalComixEnglish : GlobalComix("en")
class GlobalComixSpanish : GlobalComix("es")
class GlobalComixPersian : GlobalComix("fa")
class GlobalComixFinnish : GlobalComix("fi")
class GlobalComixFilipino : GlobalComix("fil", "fo")
class GlobalComixFrench : GlobalComix("fr")
class GlobalComixHindi : GlobalComix("hi")
class GlobalComixHungarian : GlobalComix("hu")
class GlobalComixIndonesian : GlobalComix("id")
class GlobalComixItalian : GlobalComix("it")
class GlobalComixHebrew : GlobalComix("he", "iw")
class GlobalComixJapanese : GlobalComix("ja", "jp")
class GlobalComixKorean : GlobalComix("ko", "kr")
class GlobalComixLatvian : GlobalComix("lv")
class GlobalComixMalay : GlobalComix("ms", "my")
class GlobalComixDutch : GlobalComix("nl")
class GlobalComixNorwegian : GlobalComix("no")
class GlobalComixPolish : GlobalComix("pl")
class GlobalComixPortugese : GlobalComix("pt")
class GlobalComixRomanian : GlobalComix("ro")
class GlobalComixRussian : GlobalComix("ru")
class GlobalComixSwedish : GlobalComix("sv", "se")
class GlobalComixSlovak : GlobalComix("sk")
class GlobalComixSlovenian : GlobalComix("sl")
class GlobalComixTamil : GlobalComix("ta")
class GlobalComixThai : GlobalComix("th")
class GlobalComixTurkish : GlobalComix("tr")
class GlobalComixUkrainian : GlobalComix("uk", "ua")
class GlobalComixUrdu : GlobalComix("ur")
class GlobalComixVietnamese : GlobalComix("vi")
class GlobalComixChineseCantonese : GlobalComix("zh-Hant", "zh")

View File

@ -0,0 +1,45 @@
package eu.kanade.tachiyomi.extension.all.globalcomix
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Intent
import android.os.Bundle
import android.util.Log
import android.widget.Toast
import kotlin.system.exitProcess
/**
* Springboard that accepts https://globalcomix.com/c/xxx intents and redirects them to
* the main tachiyomi process. The idea is to not install the intent filter unless
* you have this extension installed, but still let the main tachiyomi app control
* things.
*/
class GlobalComixUrlActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val pathSegments = intent?.data?.pathSegments
// Supported path: /c/title-slug
if (pathSegments != null && pathSegments.size > 1) {
val titleId = pathSegments[1]
val mainIntent = Intent().apply {
action = "eu.kanade.tachiyomi.SEARCH"
putExtra("query", prefixIdSearch + titleId)
putExtra("filter", packageName)
}
try {
startActivity(mainIntent)
} catch (e: ActivityNotFoundException) {
Log.e("GlobalComixUrlActivity", e.toString())
}
} else {
Log.e("GlobalComixUrlActivity", "Received data URL is unsupported: ${intent?.data}")
Toast.makeText(this, "This URL cannot be handled by the GlobalComix extension.", Toast.LENGTH_SHORT).show()
}
finish()
exitProcess(0)
}
}

View File

@ -0,0 +1,13 @@
package eu.kanade.tachiyomi.extension.all.globalcomix.dto
import eu.kanade.tachiyomi.extension.all.globalcomix.artist
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Suppress("PropertyName")
@Serializable
@SerialName(artist)
class ArtistDto(
val name: String, // Slug
val roman_name: String?,
) : EntityDto()

View File

@ -0,0 +1,63 @@
package eu.kanade.tachiyomi.extension.all.globalcomix.dto
import eu.kanade.tachiyomi.extension.all.globalcomix.GlobalComix.Companion.dateFormatter
import eu.kanade.tachiyomi.extension.all.globalcomix.lockSymbol
import eu.kanade.tachiyomi.extension.all.globalcomix.release
import eu.kanade.tachiyomi.source.model.SChapter
import keiyoushi.utils.tryParse
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
typealias ChapterDto = ResponseDto<ChapterDataDto>
typealias ChaptersDto = PaginatedResponseDto<ChapterDataDto>
@Suppress("PropertyName")
@Serializable
@SerialName(release)
class ChapterDataDto(
val title: String,
val chapter: String, // Stringified number
val key: String, // UUID, required for /readV2 endpoint
val premium_only: Int? = 0,
val published_time: String,
// Only available when calling the /readV2 endpoint
val page_objects: List<PageDataDto>?,
) : EntityDto() {
val isPremium: Boolean
get() = premium_only == 1
companion object {
/**
* Create an [SChapter] instance from the JSON DTO element.
*/
fun ChapterDataDto.createChapter(): SChapter {
val chapterName = mutableListOf<String>()
if (isPremium) {
chapterName.add(lockSymbol)
}
chapter.let {
if (it.isNotEmpty()) {
chapterName.add("Ch.$it")
}
}
title.let {
if (it.isNotEmpty()) {
if (chapterName.isNotEmpty()) {
chapterName.add("-")
}
chapterName.add(it)
}
}
return SChapter.create().apply {
url = key
name = chapterName.joinToString(" ")
chapter_number = chapter.toFloatOrNull() ?: 0f
date_upload = dateFormatter.tryParse(published_time)
}
}
}
}

View File

@ -0,0 +1,11 @@
package eu.kanade.tachiyomi.extension.all.globalcomix.dto
import kotlinx.serialization.Serializable
@Serializable
sealed class EntityDto {
val id: Long = -1
}
@Serializable
class UnknownEntity() : EntityDto()

View File

@ -0,0 +1,49 @@
package eu.kanade.tachiyomi.extension.all.globalcomix.dto
import eu.kanade.tachiyomi.extension.all.globalcomix.comic
import eu.kanade.tachiyomi.source.model.SManga
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
typealias MangaDto = ResponseDto<MangaDataDto>
typealias MangasDto = PaginatedResponseDto<MangaDataDto>
@Suppress("PropertyName")
@Serializable
@SerialName(comic)
class MangaDataDto(
val name: String,
val description: String?,
val status_name: String?,
val category_name: String?,
val image_url: String?,
val artist: ArtistDto,
) : EntityDto() {
companion object {
/**
* Create an [SManga] instance from the JSON DTO element.
*/
fun MangaDataDto.createManga(): SManga =
SManga.create().also {
it.initialized = true
it.url = id.toString()
it.description = description
it.author = artist.let { it.roman_name ?: it.name }
it.status = status_name?.let(::convertStatus) ?: SManga.UNKNOWN
it.genre = category_name
it.title = name
it.thumbnail_url = image_url
}
private fun convertStatus(status: String): Int {
return when (status) {
"Ongoing" -> SManga.ONGOING
"Preview" -> SManga.ONGOING
"Finished" -> SManga.COMPLETED
"On hold" -> SManga.ON_HIATUS
"Cancelled" -> SManga.CANCELLED
else -> SManga.UNKNOWN
}
}
}
}

View File

@ -0,0 +1,14 @@
package eu.kanade.tachiyomi.extension.all.globalcomix.dto
import eu.kanade.tachiyomi.extension.all.globalcomix.releasePage
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Suppress("PropertyName")
@Serializable
@SerialName(releasePage)
class PageDataDto(
val is_page_paid: Boolean,
val desktop_image_url: String,
val mobile_image_url: String,
) : EntityDto()

View File

@ -0,0 +1,36 @@
package eu.kanade.tachiyomi.extension.all.globalcomix.dto
import kotlinx.serialization.Serializable
@Serializable
class PaginatedResponseDto<T : EntityDto>(
val payload: PaginatedPayloadDto<T>? = null,
)
@Serializable
class PaginatedPayloadDto<T : EntityDto>(
val results: List<T> = emptyList(),
val pagination: PaginationStateDto,
)
@Serializable
class ResponseDto<T : EntityDto>(
val payload: PayloadDto<T>? = null,
)
@Serializable
class PayloadDto<T : EntityDto>(
val results: T,
)
@Suppress("PropertyName")
@Serializable
class PaginationStateDto(
val page: Int = 1,
val per_page: Int = 0,
val total_pages: Int = 0,
val total_results: Int = 0,
) {
val hasNextPage: Boolean
get() = page < total_pages
}