diff --git a/src/all/globalcomix/AndroidManifest.xml b/src/all/globalcomix/AndroidManifest.xml
new file mode 100644
index 000000000..a58173090
--- /dev/null
+++ b/src/all/globalcomix/AndroidManifest.xml
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/all/globalcomix/assets/i18n/messages_en.properties b/src/all/globalcomix/assets/i18n/messages_en.properties
new file mode 100644
index 000000000..af9af7967
--- /dev/null
+++ b/src/all/globalcomix/assets/i18n/messages_en.properties
@@ -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
diff --git a/src/all/globalcomix/build.gradle b/src/all/globalcomix/build.gradle
new file mode 100644
index 000000000..637969339
--- /dev/null
+++ b/src/all/globalcomix/build.gradle
@@ -0,0 +1,12 @@
+ext {
+ extName = 'GlobalComix'
+ extClass = '.GlobalComixFactory'
+ extVersionCode = 1
+ isNsfw = true
+}
+
+apply from: "$rootDir/common.gradle"
+
+dependencies {
+ implementation(project(":lib:i18n"))
+}
diff --git a/src/all/globalcomix/res/mipmap-hdpi/ic_launcher.png b/src/all/globalcomix/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 000000000..895825c12
Binary files /dev/null and b/src/all/globalcomix/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/src/all/globalcomix/res/mipmap-mdpi/ic_launcher.png b/src/all/globalcomix/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 000000000..6d7b6fdb3
Binary files /dev/null and b/src/all/globalcomix/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/src/all/globalcomix/res/mipmap-xhdpi/ic_launcher.png b/src/all/globalcomix/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 000000000..164d89c6e
Binary files /dev/null and b/src/all/globalcomix/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/src/all/globalcomix/res/mipmap-xxhdpi/ic_launcher.png b/src/all/globalcomix/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 000000000..ef35b26b7
Binary files /dev/null and b/src/all/globalcomix/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/src/all/globalcomix/res/mipmap-xxxhdpi/ic_launcher.png b/src/all/globalcomix/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 000000000..8cbd24aba
Binary files /dev/null and b/src/all/globalcomix/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/src/all/globalcomix/src/eu/kanade/tachiyomi/extension/all/globalcomix/GlobalComix.kt b/src/all/globalcomix/src/eu/kanade/tachiyomi/extension/all/globalcomix/GlobalComix.kt
new file mode 100644
index 000000000..dfa30f1bf
--- /dev/null
+++ b/src/all/globalcomix/src/eu/kanade/tachiyomi/extension/all/globalcomix/GlobalComix.kt
@@ -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().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().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().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 =
+ response.parseAs().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 {
+ val chapterKey = response.request.url.pathSegments.last()
+ val chapterWebUrl = "$webChapterUrl/$chapterKey"
+
+ return response.parseAs()
+ .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 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") }
+ }
+}
diff --git a/src/all/globalcomix/src/eu/kanade/tachiyomi/extension/all/globalcomix/GlobalComixConstants.kt b/src/all/globalcomix/src/eu/kanade/tachiyomi/extension/all/globalcomix/GlobalComixConstants.kt
new file mode 100644
index 000000000..93807cff6
--- /dev/null
+++ b/src/all/globalcomix/src/eu/kanade/tachiyomi/extension/all/globalcomix/GlobalComixConstants.kt
@@ -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"
diff --git a/src/all/globalcomix/src/eu/kanade/tachiyomi/extension/all/globalcomix/GlobalComixFactory.kt b/src/all/globalcomix/src/eu/kanade/tachiyomi/extension/all/globalcomix/GlobalComixFactory.kt
new file mode 100644
index 000000000..39cf5c950
--- /dev/null
+++ b/src/all/globalcomix/src/eu/kanade/tachiyomi/extension/all/globalcomix/GlobalComixFactory.kt
@@ -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 = 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")
diff --git a/src/all/globalcomix/src/eu/kanade/tachiyomi/extension/all/globalcomix/GlobalComixUrlActivity.kt b/src/all/globalcomix/src/eu/kanade/tachiyomi/extension/all/globalcomix/GlobalComixUrlActivity.kt
new file mode 100644
index 000000000..5de91fbff
--- /dev/null
+++ b/src/all/globalcomix/src/eu/kanade/tachiyomi/extension/all/globalcomix/GlobalComixUrlActivity.kt
@@ -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)
+ }
+}
diff --git a/src/all/globalcomix/src/eu/kanade/tachiyomi/extension/all/globalcomix/dto/ArtistDto.kt b/src/all/globalcomix/src/eu/kanade/tachiyomi/extension/all/globalcomix/dto/ArtistDto.kt
new file mode 100644
index 000000000..96f3da9e3
--- /dev/null
+++ b/src/all/globalcomix/src/eu/kanade/tachiyomi/extension/all/globalcomix/dto/ArtistDto.kt
@@ -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()
diff --git a/src/all/globalcomix/src/eu/kanade/tachiyomi/extension/all/globalcomix/dto/ChapterDto.kt b/src/all/globalcomix/src/eu/kanade/tachiyomi/extension/all/globalcomix/dto/ChapterDto.kt
new file mode 100644
index 000000000..adb8570f4
--- /dev/null
+++ b/src/all/globalcomix/src/eu/kanade/tachiyomi/extension/all/globalcomix/dto/ChapterDto.kt
@@ -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
+typealias ChaptersDto = PaginatedResponseDto
+
+@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?,
+) : 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()
+ 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)
+ }
+ }
+ }
+}
diff --git a/src/all/globalcomix/src/eu/kanade/tachiyomi/extension/all/globalcomix/dto/EntityDto.kt b/src/all/globalcomix/src/eu/kanade/tachiyomi/extension/all/globalcomix/dto/EntityDto.kt
new file mode 100644
index 000000000..c74fe9f4f
--- /dev/null
+++ b/src/all/globalcomix/src/eu/kanade/tachiyomi/extension/all/globalcomix/dto/EntityDto.kt
@@ -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()
diff --git a/src/all/globalcomix/src/eu/kanade/tachiyomi/extension/all/globalcomix/dto/MangaDto.kt b/src/all/globalcomix/src/eu/kanade/tachiyomi/extension/all/globalcomix/dto/MangaDto.kt
new file mode 100644
index 000000000..5cc54bd98
--- /dev/null
+++ b/src/all/globalcomix/src/eu/kanade/tachiyomi/extension/all/globalcomix/dto/MangaDto.kt
@@ -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
+typealias MangasDto = PaginatedResponseDto
+
+@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
+ }
+ }
+ }
+}
diff --git a/src/all/globalcomix/src/eu/kanade/tachiyomi/extension/all/globalcomix/dto/PageDataDto.kt b/src/all/globalcomix/src/eu/kanade/tachiyomi/extension/all/globalcomix/dto/PageDataDto.kt
new file mode 100644
index 000000000..921c614ee
--- /dev/null
+++ b/src/all/globalcomix/src/eu/kanade/tachiyomi/extension/all/globalcomix/dto/PageDataDto.kt
@@ -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()
diff --git a/src/all/globalcomix/src/eu/kanade/tachiyomi/extension/all/globalcomix/dto/PaginatedResponseDto.kt b/src/all/globalcomix/src/eu/kanade/tachiyomi/extension/all/globalcomix/dto/PaginatedResponseDto.kt
new file mode 100644
index 000000000..9b3d72bca
--- /dev/null
+++ b/src/all/globalcomix/src/eu/kanade/tachiyomi/extension/all/globalcomix/dto/PaginatedResponseDto.kt
@@ -0,0 +1,36 @@
+package eu.kanade.tachiyomi.extension.all.globalcomix.dto
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+class PaginatedResponseDto(
+ val payload: PaginatedPayloadDto? = null,
+)
+
+@Serializable
+class PaginatedPayloadDto(
+ val results: List = emptyList(),
+ val pagination: PaginationStateDto,
+)
+
+@Serializable
+class ResponseDto(
+ val payload: PayloadDto? = null,
+)
+
+@Serializable
+class PayloadDto(
+ 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
+}