diff --git a/src/all/namicomi/AndroidManifest.xml b/src/all/namicomi/AndroidManifest.xml
new file mode 100644
index 000000000..64995a98d
--- /dev/null
+++ b/src/all/namicomi/AndroidManifest.xml
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/all/namicomi/assets/i18n/messages_en.properties b/src/all/namicomi/assets/i18n/messages_en.properties
new file mode 100644
index 000000000..1d250c929
--- /dev/null
+++ b/src/all/namicomi/assets/i18n/messages_en.properties
@@ -0,0 +1,114 @@
+content=Content
+content_rating=Content rating
+content_rating_genre=Content rating: %s
+content_rating_mature=Mature
+content_rating_restricted=Restricted
+content_rating_safe=Safe
+content_warnings_drugs=Drugs
+content_warnings_gambling=Gambling
+content_warnings_gore=Gore
+content_warnings_mental_disorders=Mental Disorders
+content_warnings_physical_abuse=Physical Abuse
+content_warnings_racism=Racism
+content_warnings_self_harm=Self-harm
+content_warnings_sexual_abuse=Sexual Abuse
+content_warnings_verbal_abuse=Verbal Abuse
+cover_quality=Cover quality
+cover_quality_low=Low
+cover_quality_medium=Medium
+cover_quality_original=Original
+data_saver=Data saver
+data_saver_summary=Enables smaller, more compressed images
+error_payment_required=Payment required. Chapter requires a premium subscription
+excluded_tags_mode=Excluded tags mode
+format=Format
+format_4_koma=4-Koma
+format_adaptation=Adaptation
+format_anthology=Anthology
+format_full_color=Full Color
+format_oneshot=Oneshot
+format_silent=Silent
+genre=Genre
+genre_action=Action
+genre_adventure=Adventure
+genre_boys_love=Boys' Love
+genre_comedy=Comedy
+genre_crime=Crime
+genre_drama=Drama
+genre_fantasy=Fantasy
+genre_girls_love=Girls' Love
+genre_historical=Historical
+genre_horror=Horror
+genre_isekai=Isekai
+genre_mecha=Mecha
+genre_medical=Medical
+genre_mystery=Mystery
+genre_philosophical=Philosophical
+genre_psychological=Psychological
+genre_romance=Romance
+genre_sci_fi=Sci-Fi
+genre_slice_of_life=Slice of Life
+genre_sports=Sports
+genre_superhero=Superhero
+genre_thriller=Thriller
+genre_tragedy=Tragedy
+genre_wuxia=Wuxia
+has_available_chapters=Has available chapters
+included_tags_mode=Included tags mode
+invalid_manga_id=Not a valid title ID
+mode_and=And
+mode_or=Or
+show_locked_chapters=Show locked/paywalled chapters
+show_locked_chapters_summary=Display chapters that require an account with a premium subscription
+sort=Sort
+sort_alphabetic=Alphabetic
+sort_content_created_at=Content created at
+sort_number_of_chapters=Chapter count
+sort_number_of_comments=Comment count
+sort_number_of_follows=Followers
+sort_number_of_likes=Likes
+sort_rating=Rating
+sort_views=Views
+sort_year=Year
+status=Status
+status_cancelled=Cancelled
+status_completed=Completed
+status_hiatus=Hiatus
+status_ongoing=Ongoing
+tags_mode=Tags mode
+theme=Theme
+theme_aliens=Aliens
+theme_animals=Animals
+theme_cooking=Cooking
+theme_crossdressing=Crossdressing
+theme_delinquents=Delinquents
+theme_demons=Demons
+theme_genderswap=Genderswap
+theme_ghosts=Ghosts
+theme_gyaru=Gyaru
+theme_harem=Harem
+theme_mafia=Mafia
+theme_magic=Magic
+theme_magical_girls=Magical Girls
+theme_martial_arts=Martial Arts
+theme_military=Military
+theme_monster_girls=Monster Girls
+theme_monsters=Monsters
+theme_music=Music
+theme_ninja=Ninja
+theme_office_workers=Office Workers
+theme_police=Police
+theme_post_apocalyptic=Post-Apocalyptic
+theme_reincarnation=Reincarnation
+theme_reverse_harem=Reverse Harem
+theme_samurai=Samurai
+theme_school_life=School Life
+theme_supernatural=Supernatural
+theme_survival=Survival
+theme_time_travel=Time Travel
+theme_traditional_games=Traditional Games
+theme_vampires=Vampires
+theme_video_games=Video Games
+theme_villainess=Villainess
+theme_virtual_reality=Virtual Reality
+theme_zombies=Zombies
diff --git a/src/all/namicomi/build.gradle b/src/all/namicomi/build.gradle
new file mode 100644
index 000000000..11a531ba5
--- /dev/null
+++ b/src/all/namicomi/build.gradle
@@ -0,0 +1,12 @@
+ext {
+ extName = 'NamiComi'
+ extClass = '.NamiComiFactory'
+ extVersionCode = 1
+ isNsfw = false
+}
+
+apply from: "$rootDir/common.gradle"
+
+dependencies {
+ implementation(project(":lib:i18n"))
+}
diff --git a/src/all/namicomi/res/mipmap-hdpi/ic_launcher.png b/src/all/namicomi/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 000000000..fc8d6dcfc
Binary files /dev/null and b/src/all/namicomi/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/src/all/namicomi/res/mipmap-mdpi/ic_launcher.png b/src/all/namicomi/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 000000000..fb113908c
Binary files /dev/null and b/src/all/namicomi/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/src/all/namicomi/res/mipmap-xhdpi/ic_launcher.png b/src/all/namicomi/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 000000000..1cd542fd4
Binary files /dev/null and b/src/all/namicomi/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/src/all/namicomi/res/mipmap-xxhdpi/ic_launcher.png b/src/all/namicomi/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 000000000..042f96f89
Binary files /dev/null and b/src/all/namicomi/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/src/all/namicomi/res/mipmap-xxxhdpi/ic_launcher.png b/src/all/namicomi/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 000000000..593877ceb
Binary files /dev/null and b/src/all/namicomi/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/NamiComi.kt b/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/NamiComi.kt
new file mode 100644
index 000000000..5290e2a9e
--- /dev/null
+++ b/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/NamiComi.kt
@@ -0,0 +1,354 @@
+package eu.kanade.tachiyomi.extension.all.namicomi
+
+import android.app.Application
+import android.content.SharedPreferences
+import androidx.preference.ListPreference
+import androidx.preference.PreferenceScreen
+import androidx.preference.SwitchPreferenceCompat
+import eu.kanade.tachiyomi.extension.all.namicomi.dto.ChapterListDto
+import eu.kanade.tachiyomi.extension.all.namicomi.dto.EntityAccessMapDto
+import eu.kanade.tachiyomi.extension.all.namicomi.dto.EntityAccessRequestDto
+import eu.kanade.tachiyomi.extension.all.namicomi.dto.EntityAccessRequestItemDto
+import eu.kanade.tachiyomi.extension.all.namicomi.dto.MangaDto
+import eu.kanade.tachiyomi.extension.all.namicomi.dto.MangaListDto
+import eu.kanade.tachiyomi.extension.all.namicomi.dto.PageListDto
+import eu.kanade.tachiyomi.network.GET
+import eu.kanade.tachiyomi.network.POST
+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 kotlinx.serialization.decodeFromString
+import kotlinx.serialization.encodeToString
+import okhttp3.CacheControl
+import okhttp3.HttpUrl.Companion.toHttpUrl
+import okhttp3.Request
+import okhttp3.RequestBody.Companion.toRequestBody
+import okhttp3.Response
+import okio.IOException
+import uy.kohesive.injekt.Injekt
+import uy.kohesive.injekt.api.get
+
+abstract class NamiComi(final override val lang: String, private val extLang: String = lang) :
+ ConfigurableSource, HttpSource() {
+
+ override val name = "NamiComi"
+ override val baseUrl = NamiComiConstants.webUrl
+ override val supportsLatest = true
+
+ private val preferences: SharedPreferences by lazy {
+ Injekt.get().getSharedPreferences("source_$id", 0x0000)
+ }
+
+ private val helper = NamiComiHelper(lang)
+
+ final override fun headersBuilder() = super.headersBuilder().apply {
+ set("Referer", "$baseUrl/")
+ set("Origin", baseUrl)
+ }
+
+ override val client = network.client.newBuilder()
+ .rateLimit(3)
+ .addNetworkInterceptor { chain ->
+ val response = chain.proceed(chain.request())
+
+ if (response.code == 402) {
+ response.close()
+ throw IOException(helper.intl["error_payment_required"])
+ }
+
+ return@addNetworkInterceptor response
+ }
+ .build()
+
+ private fun sortedMangaRequest(page: Int, orderBy: String): Request {
+ val url = NamiComiConstants.apiSearchUrl.toHttpUrl().newBuilder()
+ .addQueryParameter("order[$orderBy]", "desc")
+ .addQueryParameter("availableTranslatedLanguages[]", extLang)
+ .addQueryParameter("limit", NamiComiConstants.mangaLimit.toString())
+ .addQueryParameter("offset", helper.getMangaListOffset(page))
+ .addQueryParameter("includes[]", NamiComiConstants.coverArt)
+ .addQueryParameter("includes[]", NamiComiConstants.primaryTag)
+ .addQueryParameter("includes[]", NamiComiConstants.secondaryTag)
+ .addQueryParameter("includes[]", NamiComiConstants.tag)
+ .build()
+
+ return GET(url, headers, CacheControl.FORCE_NETWORK)
+ }
+
+ // Popular manga section
+
+ override fun popularMangaRequest(page: Int): Request =
+ sortedMangaRequest(page, "views")
+
+ override fun popularMangaParse(response: Response): MangasPage =
+ mangaListParse(response)
+
+ // Latest manga section
+
+ override fun latestUpdatesRequest(page: Int): Request =
+ sortedMangaRequest(page, "publishedAt")
+
+ override fun latestUpdatesParse(response: Response): MangasPage =
+ mangaListParse(response)
+
+ private fun mangaListParse(response: Response): MangasPage {
+ if (response.code == 204) {
+ return MangasPage(emptyList(), false)
+ }
+
+ val mangaListDto = response.parseAs()
+ val mangaList = mangaListDto.data.map { mangaDataDto ->
+ helper.createManga(
+ mangaDataDto,
+ extLang,
+ preferences.coverQuality,
+ )
+ }
+
+ return MangasPage(mangaList, mangaListDto.meta.hasNextPage)
+ }
+
+ // Search manga section
+
+ override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
+ if (query.startsWith(NamiComiConstants.prefixIdSearch)) {
+ val mangaId = query.removePrefix(NamiComiConstants.prefixIdSearch)
+
+ if (mangaId.isEmpty()) {
+ throw Exception(helper.intl["invalid_manga_id"])
+ }
+
+ // If the query is an ID, return the manga directly
+ val url = NamiComiConstants.apiSearchUrl.toHttpUrl().newBuilder()
+ .addQueryParameter("ids[]", query.removePrefix(NamiComiConstants.prefixIdSearch))
+ .addQueryParameter("includes[]", NamiComiConstants.coverArt)
+ .build()
+
+ return GET(url, headers, CacheControl.FORCE_NETWORK)
+ }
+
+ val tempUrl = NamiComiConstants.apiSearchUrl.toHttpUrl().newBuilder()
+ .addQueryParameter("limit", NamiComiConstants.mangaLimit.toString())
+ .addQueryParameter("offset", helper.getMangaListOffset(page))
+ .addQueryParameter("includes[]", NamiComiConstants.coverArt)
+
+ val actualQuery = query.replace(NamiComiConstants.whitespaceRegex, " ")
+ if (actualQuery.isNotBlank()) {
+ tempUrl.addQueryParameter("title", actualQuery)
+ }
+
+ val finalUrl = helper.filters.addFiltersToUrl(
+ url = tempUrl,
+ filters = filters.ifEmpty { getFilterList() },
+ extLang = extLang,
+ )
+
+ return GET(finalUrl, headers, CacheControl.FORCE_NETWORK)
+ }
+
+ override fun searchMangaParse(response: Response): MangasPage = popularMangaParse(response)
+
+ // Manga Details section
+
+ override fun getMangaUrl(manga: SManga): String =
+ "$baseUrl/$extLang/title/${manga.url}/${helper.titleToSlug(manga.title)}"
+
+ /**
+ * Get the API endpoint URL for the entry details.
+ */
+ override fun mangaDetailsRequest(manga: SManga): Request {
+ val url = (NamiComiConstants.apiMangaUrl + manga.url).toHttpUrl().newBuilder()
+ .addQueryParameter("includes[]", NamiComiConstants.coverArt)
+ .addQueryParameter("includes[]", NamiComiConstants.organization)
+ .addQueryParameter("includes[]", NamiComiConstants.tag)
+ .addQueryParameter("includes[]", NamiComiConstants.primaryTag)
+ .addQueryParameter("includes[]", NamiComiConstants.secondaryTag)
+ .build()
+
+ return GET(url, headers, CacheControl.FORCE_NETWORK)
+ }
+
+ override fun mangaDetailsParse(response: Response): SManga {
+ val manga = response.parseAs()
+
+ return helper.createManga(
+ manga.data!!,
+ extLang,
+ preferences.coverQuality,
+ )
+ }
+
+ // Chapter list section
+
+ /**
+ * Get the API endpoint URL for the first page of chapter list.
+ */
+ override fun chapterListRequest(manga: SManga): Request {
+ return paginatedChapterListRequest(manga.url, 0)
+ }
+
+ /**
+ * Required because the chapter list API endpoint is paginated.
+ */
+ private fun paginatedChapterListRequest(mangaId: String, offset: Int): Request {
+ val url = NamiComiConstants.apiChapterUrl.toHttpUrl().newBuilder()
+ .addQueryParameter("titleId", mangaId)
+ .addQueryParameter("includes[]", NamiComiConstants.organization)
+ .addQueryParameter("limit", "500")
+ .addQueryParameter("offset", offset.toString())
+ .addQueryParameter("translatedLanguages[]", extLang)
+ .addQueryParameter("order[volume]", "desc")
+ .addQueryParameter("order[chapter]", "desc")
+ .toString()
+
+ return GET(url, headers, CacheControl.FORCE_NETWORK)
+ }
+
+ /**
+ * Requests information about gated chapters (requiring payment & login).
+ */
+ private fun accessibleChapterListRequest(chapterIds: List): Request {
+ return POST(
+ NamiComiConstants.apiGatingCheckUrl,
+ headers,
+ chapterIds
+ .map { EntityAccessRequestItemDto(it, NamiComiConstants.chapter) }
+ .let { helper.json.encodeToString(EntityAccessRequestDto(it)) }
+ .toRequestBody(),
+ CacheControl.FORCE_NETWORK,
+ )
+ }
+
+ override fun chapterListParse(response: Response): List {
+ if (response.code == 204) {
+ return emptyList()
+ }
+
+ val mangaId = response.request.url.toString()
+ .substringBefore("/chapter")
+ .substringAfter("${NamiComiConstants.apiMangaUrl}/")
+
+ val chapterListResponse = response.parseAs()
+ val chapterListResults = chapterListResponse.data.toMutableList()
+ var offset = chapterListResponse.meta.offset
+ var hasNextPage = chapterListResponse.meta.hasNextPage
+
+ // Max results that can be returned is 500 so need to make more API
+ // calls if the chapter list response has a next page.
+ while (hasNextPage) {
+ offset += chapterListResponse.meta.limit
+
+ val newRequest = paginatedChapterListRequest(mangaId, offset)
+ val newResponse = client.newCall(newRequest).execute()
+ val newChapterList = newResponse.parseAs()
+ chapterListResults.addAll(newChapterList.data)
+
+ hasNextPage = newChapterList.meta.hasNextPage
+ }
+
+ // If there are no chapters, don't attempt to check gating
+ if (chapterListResults.isEmpty()) {
+ return emptyList()
+ }
+
+ val gatingCheckRequest = accessibleChapterListRequest(chapterListResults.map { it.id })
+ val gatingCheckResponse = client.newCall(gatingCheckRequest).execute()
+ val accessibleChapterMap = gatingCheckResponse.parseAs()
+ .data?.attributes?.map ?: emptyMap()
+
+ return chapterListResults.mapNotNull {
+ val isAccessible = accessibleChapterMap[it.id]!!
+ when {
+ // Chapter can be viewed
+ isAccessible -> helper.createChapter(it)
+ // Chapter cannot be viewed and user wants to see locked chapters
+ preferences.showLockedChapters -> {
+ helper.createChapter(it).apply {
+ name = "${NamiComiConstants.lockSymbol} $name"
+ }
+ }
+ // Ignore locked chapters otherwise
+ else -> null
+ }
+ }
+ }
+
+ override fun getChapterUrl(chapter: SChapter): String =
+ "$baseUrl/$extLang/chapter/${chapter.url}"
+
+ override fun pageListRequest(chapter: SChapter): Request {
+ val chapterId = chapter.url
+ val url = "${NamiComiConstants.apiUrl}/images/chapter/$chapterId?newQualities=true"
+ return GET(url, headers, CacheControl.FORCE_NETWORK)
+ }
+
+ override fun pageListParse(response: Response): List {
+ val chapterId = response.request.url.pathSegments.last()
+ val pageListDataDto = response.parseAs().data ?: return emptyList()
+
+ val hash = pageListDataDto.hash
+ val prefix = "${pageListDataDto.baseUrl}/chapter/$chapterId/$hash"
+
+ val urls = if (preferences.useDataSaver) {
+ pageListDataDto.low.map { prefix + "/low/${it.filename}" }
+ } else {
+ pageListDataDto.source.map { prefix + "/source/${it.filename}" }
+ }
+
+ return urls.mapIndexed { index, url ->
+ Page(index, url, url)
+ }
+ }
+
+ override fun imageUrlParse(response: Response): String = ""
+
+ override fun setupPreferenceScreen(screen: PreferenceScreen) {
+ val coverQualityPref = ListPreference(screen.context).apply {
+ key = NamiComiConstants.getCoverQualityPreferenceKey(extLang)
+ title = helper.intl["cover_quality"]
+ entries = NamiComiConstants.getCoverQualityPreferenceEntries(helper.intl)
+ entryValues = NamiComiConstants.getCoverQualityPreferenceEntryValues()
+ setDefaultValue(NamiComiConstants.getCoverQualityPreferenceDefaultValue())
+ summary = "%s"
+ }
+
+ val dataSaverPref = SwitchPreferenceCompat(screen.context).apply {
+ key = NamiComiConstants.getDataSaverPreferenceKey(extLang)
+ title = helper.intl["data_saver"]
+ summary = helper.intl["data_saver_summary"]
+ setDefaultValue(false)
+ }
+
+ val showLockedChaptersPref = SwitchPreferenceCompat(screen.context).apply {
+ key = NamiComiConstants.getShowLockedChaptersPreferenceKey(extLang)
+ title = helper.intl["show_locked_chapters"]
+ summary = helper.intl["show_locked_chapters_summary"]
+ setDefaultValue(false)
+ }
+
+ screen.addPreference(coverQualityPref)
+ screen.addPreference(dataSaverPref)
+ screen.addPreference(showLockedChaptersPref)
+ }
+
+ override fun getFilterList(): FilterList =
+ helper.filters.getFilterList(helper.intl)
+
+ private inline fun Response.parseAs(): T = use {
+ helper.json.decodeFromString(body.string())
+ }
+
+ private val SharedPreferences.coverQuality
+ get() = getString(NamiComiConstants.getCoverQualityPreferenceKey(extLang), "")
+
+ private val SharedPreferences.useDataSaver
+ get() = getBoolean(NamiComiConstants.getDataSaverPreferenceKey(extLang), false)
+
+ private val SharedPreferences.showLockedChapters
+ get() = getBoolean(NamiComiConstants.getShowLockedChaptersPreferenceKey(extLang), false)
+}
diff --git a/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/NamiComiConstants.kt b/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/NamiComiConstants.kt
new file mode 100644
index 000000000..082909a1d
--- /dev/null
+++ b/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/NamiComiConstants.kt
@@ -0,0 +1,63 @@
+package eu.kanade.tachiyomi.extension.all.namicomi
+
+import eu.kanade.tachiyomi.lib.i18n.Intl
+import java.text.SimpleDateFormat
+import java.util.Locale
+import java.util.TimeZone
+
+object NamiComiConstants {
+ const val mangaLimit = 20
+
+ val whitespaceRegex = "\\s".toRegex()
+ val dateFormatter = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss+SSS", Locale.US)
+ .apply { timeZone = TimeZone.getTimeZone("UTC") }
+
+ const val lockSymbol = "🔒"
+
+ // Language codes used for translations
+ const val english = "en"
+
+ // JSON discriminators
+ const val chapter = "chapter"
+ const val manga = "title"
+ const val coverArt = "cover_art"
+ const val organization = "organization"
+ const val tag = "tag"
+ const val primaryTag = "primary_tag"
+ const val secondaryTag = "secondary_tag"
+ const val imageData = "image_data"
+ const val entityAccessMap = "entity_access_map"
+
+ // URLs & API endpoints
+ const val webUrl = "https://namicomi.com"
+ const val cdnUrl = "https://uploads.namicomi.com"
+ const val apiUrl = "https://api.namicomi.com"
+ const val apiMangaUrl = "$apiUrl/title"
+ const val apiSearchUrl = "$apiMangaUrl/search"
+ const val apiChapterUrl = "$apiUrl/chapter"
+ const val apiGatingCheckUrl = "$apiUrl/gating/check"
+
+ // Search prefix for title ids
+ const val prefixIdSearch = "id:"
+
+ // Preferences
+ private const val coverQualityPref = "thumbnailQuality"
+ fun getCoverQualityPreferenceKey(extLang: String): String = "${coverQualityPref}_$extLang"
+ fun getCoverQualityPreferenceEntries(intl: Intl) =
+ arrayOf(intl["cover_quality_original"], intl["cover_quality_medium"], intl["cover_quality_low"])
+ fun getCoverQualityPreferenceEntryValues() = arrayOf("", ".512.jpg", ".256.jpg")
+ fun getCoverQualityPreferenceDefaultValue() = getCoverQualityPreferenceEntryValues()[0]
+
+ private const val dataSaverPref = "dataSaver"
+ fun getDataSaverPreferenceKey(extLang: String): String = "${dataSaverPref}_$extLang"
+
+ private const val showLockedChaptersPref = "showLockedChapters"
+ fun getShowLockedChaptersPreferenceKey(extLang: String): String = "${showLockedChaptersPref}_$extLang"
+
+ // Tag types
+ private const val tagGroupContent = "content-warnings"
+ private const val tagGroupFormat = "format"
+ private const val tagGroupGenre = "genre"
+ private const val tagGroupTheme = "theme"
+ val tagGroupsOrder = arrayOf(tagGroupContent, tagGroupFormat, tagGroupGenre, tagGroupTheme)
+}
diff --git a/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/NamiComiFactory.kt b/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/NamiComiFactory.kt
new file mode 100644
index 000000000..7dcb6af2c
--- /dev/null
+++ b/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/NamiComiFactory.kt
@@ -0,0 +1,96 @@
+package eu.kanade.tachiyomi.extension.all.namicomi
+
+import eu.kanade.tachiyomi.source.Source
+import eu.kanade.tachiyomi.source.SourceFactory
+
+class NamiComiFactory : SourceFactory {
+ override fun createSources(): List = listOf(
+ NamiComiEnglish(),
+ NamiComiArabic(),
+ NamiComiBulgarian(),
+ NamiComiCatalan(),
+ NamiComiChineseSimplified(),
+ NamiComiChineseTraditional(),
+ NamiComiCroatian(),
+ NamiComiCzech(),
+ NamiComiDanish(),
+ NamiComiDutch(),
+ NamiComiEstonian(),
+ NamiComiFilipino(),
+ NamiComiFinnish(),
+ NamiComiFrench(),
+ NamiComiGerman(),
+ NamiComiGreek(),
+ NamiComiHebrew(),
+ NamiComiHindi(),
+ NamiComiHungarian(),
+ NamiComiIcelandic(),
+ NamiComiIrish(),
+ NamiComiIndonesian(),
+ NamiComiItalian(),
+ NamiComiJapanese(),
+ NamiComiKorean(),
+ NamiComiLithuanian(),
+ NamiComiMalay(),
+ NamiComiNepali(),
+ NamiComiNorwegian(),
+ NamiComiPanjabi(),
+ NamiComiPersian(),
+ NamiComiPolish(),
+ NamiComiPortugueseBrazil(),
+ NamiComiPortuguesePortugal(),
+ NamiComiRussian(),
+ NamiComiSlovak(),
+ NamiComiSlovenian(),
+ NamiComiSpanishLatinAmerica(),
+ NamiComiSpanishSpain(),
+ NamiComiSwedish(),
+ NamiComiThai(),
+ NamiComiTurkish(),
+ NamiComiUkrainian(),
+ )
+}
+
+class NamiComiArabic : NamiComi("ar")
+class NamiComiBulgarian : NamiComi("bg")
+class NamiComiCatalan : NamiComi("ca")
+class NamiComiChineseSimplified : NamiComi("zh-Hans", "zh-hans")
+class NamiComiChineseTraditional : NamiComi("zh-Hant", "zh-hant")
+class NamiComiCroatian : NamiComi("hr")
+class NamiComiCzech : NamiComi("cs")
+class NamiComiDanish : NamiComi("da")
+class NamiComiDutch : NamiComi("nl")
+class NamiComiEnglish : NamiComi("en")
+class NamiComiEstonian : NamiComi("et")
+class NamiComiFilipino : NamiComi("fil")
+class NamiComiFinnish : NamiComi("fi")
+class NamiComiFrench : NamiComi("fr")
+class NamiComiGerman : NamiComi("de")
+class NamiComiGreek : NamiComi("el")
+class NamiComiHebrew : NamiComi("he")
+class NamiComiHindi : NamiComi("hi")
+class NamiComiHungarian : NamiComi("hu")
+class NamiComiIcelandic : NamiComi("is")
+class NamiComiIrish : NamiComi("ga")
+class NamiComiIndonesian : NamiComi("id")
+class NamiComiItalian : NamiComi("it")
+class NamiComiJapanese : NamiComi("ja")
+class NamiComiKorean : NamiComi("ko")
+class NamiComiLithuanian : NamiComi("lt")
+class NamiComiMalay : NamiComi("ms")
+class NamiComiNepali : NamiComi("ne")
+class NamiComiNorwegian : NamiComi("no")
+class NamiComiPanjabi : NamiComi("pa")
+class NamiComiPersian : NamiComi("fa")
+class NamiComiPolish : NamiComi("pl")
+class NamiComiPortugueseBrazil : NamiComi("pt-BR", "pt-br")
+class NamiComiPortuguesePortugal : NamiComi("pt", "pt-pt")
+class NamiComiRussian : NamiComi("ru")
+class NamiComiSlovak : NamiComi("sk")
+class NamiComiSlovenian : NamiComi("sl")
+class NamiComiSpanishLatinAmerica : NamiComi("es-419")
+class NamiComiSpanishSpain : NamiComi("es", "es-es")
+class NamiComiSwedish : NamiComi("sv")
+class NamiComiThai : NamiComi("th")
+class NamiComiTurkish : NamiComi("tr")
+class NamiComiUkrainian : NamiComi("uk")
diff --git a/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/NamiComiFilters.kt b/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/NamiComiFilters.kt
new file mode 100644
index 000000000..88223e1ba
--- /dev/null
+++ b/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/NamiComiFilters.kt
@@ -0,0 +1,289 @@
+package eu.kanade.tachiyomi.extension.all.namicomi
+
+import eu.kanade.tachiyomi.extension.all.namicomi.dto.ContentRatingDto
+import eu.kanade.tachiyomi.extension.all.namicomi.dto.StatusDto
+import eu.kanade.tachiyomi.lib.i18n.Intl
+import eu.kanade.tachiyomi.source.model.Filter
+import eu.kanade.tachiyomi.source.model.FilterList
+import okhttp3.HttpUrl
+
+class NamiComiFilters {
+
+ internal fun getFilterList(intl: Intl): FilterList = FilterList(
+ HasAvailableChaptersFilter(intl),
+ ContentRatingList(intl, getContentRatings(intl)),
+ StatusList(intl, getStatus(intl)),
+ SortFilter(intl, getSortables(intl)),
+ TagsFilter(intl, getTagFilters(intl)),
+ TagList(intl["content"], getContents(intl)),
+ TagList(intl["format"], getFormats(intl)),
+ TagList(intl["genre"], getGenres(intl)),
+ TagList(intl["theme"], getThemes(intl)),
+ )
+
+ private interface UrlQueryFilter {
+ fun addQueryParameter(url: HttpUrl.Builder, extLang: String)
+ }
+
+ private class HasAvailableChaptersFilter(intl: Intl) :
+ Filter.CheckBox(intl["has_available_chapters"]),
+ UrlQueryFilter {
+
+ override fun addQueryParameter(url: HttpUrl.Builder, extLang: String) {
+ if (state) {
+ url.addQueryParameter("hasAvailableChapters", "true")
+ url.addQueryParameter("availableTranslatedLanguages[]", extLang)
+ }
+ }
+ }
+
+ private class ContentRating(name: String, val value: String) : Filter.CheckBox(name)
+ private class ContentRatingList(intl: Intl, contentRating: List) :
+ Filter.Group(intl["content_rating"], contentRating),
+ UrlQueryFilter {
+
+ override fun addQueryParameter(url: HttpUrl.Builder, extLang: String) {
+ state.filter(ContentRating::state)
+ .forEach { url.addQueryParameter("contentRatings[]", it.value) }
+ }
+ }
+
+ private fun getContentRatings(intl: Intl) = listOf(
+ ContentRating(intl["content_rating_safe"], ContentRatingDto.SAFE.value),
+ ContentRating(intl["content_rating_restricted"], ContentRatingDto.RESTRICTED.value),
+ ContentRating(intl["content_rating_mature"], ContentRatingDto.MATURE.value),
+ )
+
+ private class Status(name: String, val value: String) : Filter.CheckBox(name)
+ private class StatusList(intl: Intl, status: List) :
+ Filter.Group(intl["status"], status),
+ UrlQueryFilter {
+
+ override fun addQueryParameter(url: HttpUrl.Builder, extLang: String) {
+ state.filter(Status::state)
+ .forEach { url.addQueryParameter("publicationStatuses[]", it.value) }
+ }
+ }
+
+ private fun getStatus(intl: Intl) = listOf(
+ Status(intl["status_ongoing"], StatusDto.ONGOING.value),
+ Status(intl["status_completed"], StatusDto.COMPLETED.value),
+ Status(intl["status_hiatus"], StatusDto.HIATUS.value),
+ Status(intl["status_cancelled"], StatusDto.CANCELLED.value),
+ )
+
+ data class Sortable(val title: String, val value: String) {
+ override fun toString(): String = title
+ }
+
+ private fun getSortables(intl: Intl) = arrayOf(
+ Sortable(intl["sort_alphabetic"], "title"),
+ Sortable(intl["sort_number_of_chapters"], "chapterCount"),
+ Sortable(intl["sort_number_of_follows"], "followCount"),
+ Sortable(intl["sort_number_of_likes"], "reactions"),
+ Sortable(intl["sort_number_of_comments"], "commentCount"),
+ Sortable(intl["sort_content_created_at"], "publishedAt"),
+ Sortable(intl["sort_views"], "views"),
+ Sortable(intl["sort_year"], "year"),
+ Sortable(intl["sort_rating"], "rating"),
+ )
+
+ class SortFilter(intl: Intl, private val sortables: Array) :
+ Filter.Sort(
+ intl["sort"],
+ sortables.map(Sortable::title).toTypedArray(),
+ Selection(5, false),
+ ),
+ UrlQueryFilter {
+
+ override fun addQueryParameter(url: HttpUrl.Builder, extLang: String) {
+ if (state != null) {
+ val query = sortables[state!!.index].value
+ val value = if (state!!.ascending) "asc" else "desc"
+
+ url.addQueryParameter("order[$query]", value)
+ }
+ }
+ }
+
+ internal class Tag(val id: String, name: String) : Filter.TriState(name)
+
+ private class TagList(collection: String, tags: List) :
+ Filter.Group(collection, tags),
+ UrlQueryFilter {
+
+ override fun addQueryParameter(url: HttpUrl.Builder, extLang: String) {
+ state.forEach { tag ->
+ if (tag.isIncluded()) {
+ url.addQueryParameter("includedTags[]", tag.id)
+ } else if (tag.isExcluded()) {
+ url.addQueryParameter("excludedTags[]", tag.id)
+ }
+ }
+ }
+ }
+
+ private fun getContents(intl: Intl): List {
+ val tags = listOf(
+ Tag("drugs", intl["content_warnings_drugs"]),
+ Tag("gambling", intl["content_warnings_gambling"]),
+ Tag("gore", intl["content_warnings_gore"]),
+ Tag("mental-disorders", intl["content_warnings_mental_disorders"]),
+ Tag("physical-abuse", intl["content_warnings_physical_abuse"]),
+ Tag("racism", intl["content_warnings_racism"]),
+ Tag("self-harm", intl["content_warnings_self_harm"]),
+ Tag("sexual-abuse", intl["content_warnings_sexual_abuse"]),
+ Tag("verbal-abuse", intl["content_warnings_verbal_abuse"]),
+ )
+
+ return tags.sortIfTranslated(intl)
+ }
+
+ private fun getFormats(intl: Intl): List {
+ val tags = listOf(
+ Tag("4-koma", intl["format_4_koma"]),
+ Tag("adaptation", intl["format_adaptation"]),
+ Tag("anthology", intl["format_anthology"]),
+ Tag("full-color", intl["format_full_color"]),
+ Tag("oneshot", intl["format_oneshot"]),
+ Tag("silent", intl["format_silent"]),
+ )
+
+ return tags.sortIfTranslated(intl)
+ }
+
+ private fun getGenres(intl: Intl): List {
+ val tags = listOf(
+ Tag("action", intl["genre_action"]),
+ Tag("adventure", intl["genre_adventure"]),
+ Tag("boys-love", intl["genre_boys_love"]),
+ Tag("comedy", intl["genre_comedy"]),
+ Tag("crime", intl["genre_crime"]),
+ Tag("drama", intl["genre_drama"]),
+ Tag("fantasy", intl["genre_fantasy"]),
+ Tag("girls-love", intl["genre_girls_love"]),
+ Tag("historical", intl["genre_historical"]),
+ Tag("horror", intl["genre_horror"]),
+ Tag("isekai", intl["genre_isekai"]),
+ Tag("mecha", intl["genre_mecha"]),
+ Tag("medical", intl["genre_medical"]),
+ Tag("mystery", intl["genre_mystery"]),
+ Tag("philosophical", intl["genre_philosophical"]),
+ Tag("psychological", intl["genre_psychological"]),
+ Tag("romance", intl["genre_romance"]),
+ Tag("sci-fi", intl["genre_sci_fi"]),
+ Tag("slice-of-life", intl["genre_slice_of_life"]),
+ Tag("sports", intl["genre_sports"]),
+ Tag("superhero", intl["genre_superhero"]),
+ Tag("thriller", intl["genre_thriller"]),
+ Tag("tragedy", intl["genre_tragedy"]),
+ Tag("wuxia", intl["genre_wuxia"]),
+ )
+
+ return tags.sortIfTranslated(intl)
+ }
+
+ private fun getThemes(intl: Intl): List {
+ val tags = listOf(
+ Tag("aliens", intl["theme_aliens"]),
+ Tag("animals", intl["theme_animals"]),
+ Tag("cooking", intl["theme_cooking"]),
+ Tag("crossdressing", intl["theme_crossdressing"]),
+ Tag("delinquents", intl["theme_delinquents"]),
+ Tag("demons", intl["theme_demons"]),
+ Tag("genderswap", intl["theme_genderswap"]),
+ Tag("ghosts", intl["theme_ghosts"]),
+ Tag("gyaru", intl["theme_gyaru"]),
+ Tag("harem", intl["theme_harem"]),
+ Tag("mafia", intl["theme_mafia"]),
+ Tag("magic", intl["theme_magic"]),
+ Tag("magical-girls", intl["theme_magical_girls"]),
+ Tag("martial-arts", intl["theme_martial_arts"]),
+ Tag("military", intl["theme_military"]),
+ Tag("monster-girls", intl["theme_monster_girls"]),
+ Tag("monsters", intl["theme_monsters"]),
+ Tag("music", intl["theme_music"]),
+ Tag("ninja", intl["theme_ninja"]),
+ Tag("office-workers", intl["theme_office_workers"]),
+ Tag("police", intl["theme_police"]),
+ Tag("post-apocalyptic", intl["theme_post_apocalyptic"]),
+ Tag("reincarnation", intl["theme_reincarnation"]),
+ Tag("reverse-harem", intl["theme_reverse_harem"]),
+ Tag("samurai", intl["theme_samurai"]),
+ Tag("school-life", intl["theme_school_life"]),
+ Tag("supernatural", intl["theme_supernatural"]),
+ Tag("survival", intl["theme_survival"]),
+ Tag("time-travel", intl["theme_time_travel"]),
+ Tag("traditional-games", intl["theme_traditional_games"]),
+ Tag("vampires", intl["theme_vampires"]),
+ Tag("video-games", intl["theme_video_games"]),
+ Tag("villainess", intl["theme_villainess"]),
+ Tag("virtual-reality", intl["theme_virtual_reality"]),
+ Tag("zombies", intl["theme_zombies"]),
+ )
+
+ return tags.sortIfTranslated(intl)
+ }
+
+ // Tags taken from: https://api.namicomi.com/title/tags
+ internal fun getTags(intl: Intl): List {
+ return getContents(intl) + getFormats(intl) + getGenres(intl) + getThemes(intl)
+ }
+
+ private data class TagMode(val title: String, val value: String) {
+ override fun toString(): String = title
+ }
+
+ private fun getTagModes(intl: Intl) = arrayOf(
+ TagMode(intl["mode_and"], "and"),
+ TagMode(intl["mode_or"], "or"),
+ )
+
+ private class TagInclusionMode(intl: Intl, modes: Array) :
+ Filter.Select(intl["included_tags_mode"], modes, 0),
+ UrlQueryFilter {
+
+ override fun addQueryParameter(url: HttpUrl.Builder, extLang: String) {
+ url.addQueryParameter("includedTagsMode", values[state].value)
+ }
+ }
+
+ private class TagExclusionMode(intl: Intl, modes: Array) :
+ Filter.Select(intl["excluded_tags_mode"], modes, 1),
+ UrlQueryFilter {
+
+ override fun addQueryParameter(url: HttpUrl.Builder, extLang: String) {
+ url.addQueryParameter("excludedTagsMode", values[state].value)
+ }
+ }
+
+ private class TagsFilter(intl: Intl, innerFilters: FilterList) :
+ Filter.Group>(intl["tags_mode"], innerFilters),
+ UrlQueryFilter {
+
+ override fun addQueryParameter(url: HttpUrl.Builder, extLang: String) {
+ state.filterIsInstance()
+ .forEach { filter -> filter.addQueryParameter(url, extLang) }
+ }
+ }
+
+ private fun getTagFilters(intl: Intl): FilterList = FilterList(
+ TagInclusionMode(intl, getTagModes(intl)),
+ TagExclusionMode(intl, getTagModes(intl)),
+ )
+
+ internal fun addFiltersToUrl(url: HttpUrl.Builder, filters: FilterList, extLang: String): HttpUrl {
+ filters.filterIsInstance()
+ .forEach { filter -> filter.addQueryParameter(url, extLang) }
+
+ return url.build()
+ }
+
+ private fun List.sortIfTranslated(intl: Intl): List = apply {
+ if (intl.chosenLanguage == NamiComiConstants.english) {
+ return this
+ }
+
+ return sortedWith(compareBy(intl.collator, Tag::name))
+ }
+}
diff --git a/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/NamiComiHelper.kt b/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/NamiComiHelper.kt
new file mode 100644
index 000000000..9f586cae0
--- /dev/null
+++ b/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/NamiComiHelper.kt
@@ -0,0 +1,182 @@
+package eu.kanade.tachiyomi.extension.all.namicomi
+
+import eu.kanade.tachiyomi.extension.all.namicomi.dto.AbstractTagDto
+import eu.kanade.tachiyomi.extension.all.namicomi.dto.ChapterDataDto
+import eu.kanade.tachiyomi.extension.all.namicomi.dto.ContentRatingDto
+import eu.kanade.tachiyomi.extension.all.namicomi.dto.CoverArtDto
+import eu.kanade.tachiyomi.extension.all.namicomi.dto.EntityDto
+import eu.kanade.tachiyomi.extension.all.namicomi.dto.MangaDataDto
+import eu.kanade.tachiyomi.extension.all.namicomi.dto.OrganizationDto
+import eu.kanade.tachiyomi.extension.all.namicomi.dto.StatusDto
+import eu.kanade.tachiyomi.extension.all.namicomi.dto.UnknownEntity
+import eu.kanade.tachiyomi.lib.i18n.Intl
+import eu.kanade.tachiyomi.source.model.SChapter
+import eu.kanade.tachiyomi.source.model.SManga
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.modules.SerializersModule
+import kotlinx.serialization.modules.plus
+import kotlinx.serialization.modules.polymorphic
+import java.util.Locale
+
+class NamiComiHelper(lang: String) {
+
+ val filters = NamiComiFilters()
+
+ val json = Json {
+ isLenient = true
+ ignoreUnknownKeys = true
+ serializersModule += SerializersModule {
+ polymorphic(EntityDto::class) {
+ defaultDeserializer { UnknownEntity.serializer() }
+ }
+ }
+ }
+
+ val intl = Intl(
+ language = lang,
+ baseLanguage = NamiComiConstants.english,
+ availableLanguages = setOf(NamiComiConstants.english),
+ classLoader = this::class.java.classLoader!!,
+ createMessageFileName = { lang -> Intl.createDefaultMessageFileName(lang) },
+ )
+
+ /**
+ * Get the manga offset pages are 1 based, so subtract 1
+ */
+ fun getMangaListOffset(page: Int): String = (NamiComiConstants.mangaLimit * (page - 1)).toString()
+
+ private fun getPublicationStatus(mangaDataDto: MangaDataDto): Int {
+ return when (mangaDataDto.attributes!!.publicationStatus) {
+ StatusDto.ONGOING -> SManga.ONGOING
+ StatusDto.CANCELLED -> SManga.CANCELLED
+ StatusDto.COMPLETED -> SManga.COMPLETED
+ StatusDto.HIATUS -> SManga.ON_HIATUS
+ else -> SManga.UNKNOWN
+ }
+ }
+
+ private fun parseDate(dateAsString: String): Long =
+ NamiComiConstants.dateFormatter.parse(dateAsString)?.time ?: 0
+
+ /**
+ * Create an [SManga] from the JSON element with all attributes filled.
+ */
+ fun createManga(
+ mangaDataDto: MangaDataDto,
+ lang: String,
+ coverSuffix: String?,
+ ): SManga {
+ val attr = mangaDataDto.attributes!!
+
+ // Things that will go with the genre tags but aren't actually genre
+ val extLocale = Locale.forLanguageTag(lang)
+
+ val nonGenres = listOfNotNull(
+ attr.contentRating
+ .takeIf { it != ContentRatingDto.SAFE }
+ ?.let { intl.format("content_rating_genre", intl["content_rating_${it.name.lowercase()}"]) },
+ attr.originalLanguage
+ ?.let { Locale.forLanguageTag(it) }
+ ?.getDisplayName(extLocale)
+ ?.replaceFirstChar { it.uppercase(extLocale) },
+ )
+
+ val organization = mangaDataDto.relationships
+ .filterIsInstance()
+ .mapNotNull { it.attributes?.name }
+ .distinct()
+
+ val coverFileName = mangaDataDto.relationships
+ .filterIsInstance()
+ .firstOrNull()
+ ?.attributes?.fileName
+
+ val tags = filters.getTags(intl).associate { it.id to it.name }
+
+ val genresMap = mangaDataDto.relationships
+ .filterIsInstance()
+ .groupBy({ it.attributes!!.group }) { tagDto -> tags[tagDto.id] }
+ .mapValues { it.value.filterNotNull().sortedWith(intl.collator) }
+
+ val genreList = NamiComiConstants.tagGroupsOrder.flatMap { genresMap[it].orEmpty() } + nonGenres
+
+ val desc = (attr.description[lang] ?: attr.description["en"])
+ .orEmpty()
+
+ return SManga.create().apply {
+ initialized = true
+ url = mangaDataDto.id
+ description = desc
+ author = organization.joinToString()
+ status = getPublicationStatus(mangaDataDto)
+ genre = genreList
+ .filter(String::isNotEmpty)
+ .joinToString()
+
+ mangaDataDto.attributes.title.let { titleMap ->
+ title = titleMap[lang] ?: titleMap.values.first()
+ }
+
+ coverFileName?.let {
+ thumbnail_url = when (!coverSuffix.isNullOrEmpty()) {
+ true -> "${NamiComiConstants.cdnUrl}/covers/${mangaDataDto.id}/$coverFileName$coverSuffix"
+ else -> "${NamiComiConstants.cdnUrl}/covers/${mangaDataDto.id}/$coverFileName"
+ }
+ }
+ }
+ }
+
+ /**
+ * Create the [SChapter] from the JSON element.
+ */
+ fun createChapter(chapterDataDto: ChapterDataDto): SChapter {
+ val attr = chapterDataDto.attributes!!
+ val chapterName = mutableListOf()
+
+ attr.volume?.let {
+ if (it.isNotEmpty()) {
+ chapterName.add("Vol.$it")
+ }
+ }
+
+ attr.chapter?.let {
+ if (it.isNotEmpty()) {
+ chapterName.add("Ch.$it")
+ }
+ }
+
+ attr.name?.let {
+ if (it.isNotEmpty()) {
+ if (chapterName.isNotEmpty()) {
+ chapterName.add("-")
+ }
+ chapterName.add(it)
+ }
+ }
+
+ return SChapter.create().apply {
+ url = chapterDataDto.id
+ name = chapterName.joinToString(" ")
+ date_upload = parseDate(attr.publishAt)
+ }
+ }
+
+ fun titleToSlug(title: String) = title.trim()
+ .lowercase(Locale.US)
+ .replace(titleSpecialCharactersRegex, "-")
+ .replace(trailingHyphenRegex, "")
+ .split("-")
+ .reduce { accumulator, element ->
+ val currentSlug = "$accumulator-$element"
+ if (currentSlug.length > 100) {
+ accumulator
+ } else {
+ currentSlug
+ }
+ }
+
+ companion object {
+ val titleSpecialCharactersRegex = "[^a-z0-9]+".toRegex()
+ val trailingHyphenRegex = "-+$".toRegex()
+ }
+}
diff --git a/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/NamiComiUrlActivity.kt b/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/NamiComiUrlActivity.kt
new file mode 100644
index 000000000..c5d2b57da
--- /dev/null
+++ b/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/NamiComiUrlActivity.kt
@@ -0,0 +1,45 @@
+package eu.kanade.tachiyomi.extension.all.namicomi
+
+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://namicomi.com/xx/title/yyy 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 NamiComiUrlActivity : Activity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ val pathSegments = intent?.data?.pathSegments
+
+ // Supported path: /en/title/12345
+ if (pathSegments != null && pathSegments.size > 2) {
+ val titleId = pathSegments[2]
+ val mainIntent = Intent().apply {
+ action = "eu.kanade.tachiyomi.SEARCH"
+ putExtra("query", NamiComiConstants.prefixIdSearch + titleId)
+ putExtra("filter", packageName)
+ }
+
+ try {
+ startActivity(mainIntent)
+ } catch (e: ActivityNotFoundException) {
+ Log.e("NamiComiUrlActivity", e.toString())
+ }
+ } else {
+ Toast.makeText(this, "This URL cannot be handled by the Namicomi extension.", Toast.LENGTH_SHORT).show()
+ Log.e("NamiComiUrlActivity", "Could not parse URI from intent $intent")
+ }
+
+ finish()
+ exitProcess(0)
+ }
+}
diff --git a/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/dto/ChapterDto.kt b/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/dto/ChapterDto.kt
new file mode 100644
index 000000000..204e6c7f6
--- /dev/null
+++ b/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/dto/ChapterDto.kt
@@ -0,0 +1,20 @@
+package eu.kanade.tachiyomi.extension.all.namicomi.dto
+
+import eu.kanade.tachiyomi.extension.all.namicomi.NamiComiConstants
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+typealias ChapterListDto = PaginatedResponseDto
+
+@Serializable
+@SerialName(NamiComiConstants.chapter)
+class ChapterDataDto(override val attributes: ChapterAttributesDto? = null) : EntityDto()
+
+@Serializable
+class ChapterAttributesDto(
+ val name: String?,
+ val volume: String?,
+ val chapter: String?,
+ val pages: Int,
+ val publishAt: String,
+) : AttributesDto
diff --git a/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/dto/CoverArtDto.kt b/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/dto/CoverArtDto.kt
new file mode 100644
index 000000000..e476c705d
--- /dev/null
+++ b/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/dto/CoverArtDto.kt
@@ -0,0 +1,15 @@
+package eu.kanade.tachiyomi.extension.all.namicomi.dto
+
+import eu.kanade.tachiyomi.extension.all.namicomi.NamiComiConstants
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+@SerialName(NamiComiConstants.coverArt)
+class CoverArtDto(override val attributes: CoverArtAttributesDto? = null) : EntityDto()
+
+@Serializable
+class CoverArtAttributesDto(
+ val fileName: String? = null,
+ val locale: String? = null,
+) : AttributesDto
diff --git a/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/dto/EntityAccessMapDto.kt b/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/dto/EntityAccessMapDto.kt
new file mode 100644
index 000000000..c277d127f
--- /dev/null
+++ b/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/dto/EntityAccessMapDto.kt
@@ -0,0 +1,30 @@
+package eu.kanade.tachiyomi.extension.all.namicomi.dto
+
+import eu.kanade.tachiyomi.extension.all.namicomi.NamiComiConstants
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+typealias EntityAccessMapDto = ResponseDto
+
+@Serializable
+@SerialName(NamiComiConstants.entityAccessMap)
+class EntityAccessMapDataDto(
+ override val attributes: EntityAccessMapAttributesDto? = null,
+) : EntityDto()
+
+@Serializable
+class EntityAccessMapAttributesDto(
+ // Map of entity IDs to whether the user has access to them
+ val map: Map,
+) : AttributesDto
+
+@Serializable
+class EntityAccessRequestDto(
+ val entities: List,
+)
+
+@Serializable
+class EntityAccessRequestItemDto(
+ val entityId: String,
+ val entityType: String,
+)
diff --git a/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/dto/EntityDto.kt b/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/dto/EntityDto.kt
new file mode 100644
index 000000000..8699f1876
--- /dev/null
+++ b/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/dto/EntityDto.kt
@@ -0,0 +1,16 @@
+package eu.kanade.tachiyomi.extension.all.namicomi.dto
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+sealed class EntityDto {
+ val id: String = ""
+ val relationships: List = emptyList()
+ abstract val attributes: AttributesDto?
+}
+
+@Serializable
+sealed interface AttributesDto
+
+@Serializable
+class UnknownEntity(override val attributes: AttributesDto? = null) : EntityDto()
diff --git a/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/dto/MangaDto.kt b/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/dto/MangaDto.kt
new file mode 100644
index 000000000..baf3d0ff1
--- /dev/null
+++ b/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/dto/MangaDto.kt
@@ -0,0 +1,70 @@
+package eu.kanade.tachiyomi.extension.all.namicomi.dto
+
+import eu.kanade.tachiyomi.extension.all.namicomi.NamiComiConstants
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+typealias MangaListDto = PaginatedResponseDto
+
+typealias MangaDto = ResponseDto
+
+@Serializable
+@SerialName(NamiComiConstants.manga)
+class MangaDataDto(override val attributes: MangaAttributesDto? = null) : EntityDto()
+
+@Serializable
+class MangaAttributesDto(
+ // Title and description are maps of language codes to localized strings
+ val title: Map,
+ val description: Map,
+ val slug: String,
+ val originalLanguage: String?,
+ val year: Int?,
+ val contentRating: ContentRatingDto? = null,
+ val publicationStatus: StatusDto? = null,
+) : AttributesDto
+
+@Serializable
+enum class ContentRatingDto(val value: String) {
+ @SerialName("safe")
+ SAFE("safe"),
+
+ @SerialName("restricted")
+ RESTRICTED("restricted"),
+
+ @SerialName("mature")
+ MATURE("mature"),
+}
+
+@Serializable
+enum class StatusDto(val value: String) {
+ @SerialName("ongoing")
+ ONGOING("ongoing"),
+
+ @SerialName("completed")
+ COMPLETED("completed"),
+
+ @SerialName("hiatus")
+ HIATUS("hiatus"),
+
+ @SerialName("cancelled")
+ CANCELLED("cancelled"),
+}
+
+@Serializable
+sealed class AbstractTagDto(override val attributes: TagAttributesDto? = null) : EntityDto()
+
+@Serializable
+@SerialName(NamiComiConstants.tag)
+class TagDto : AbstractTagDto()
+
+@Serializable
+@SerialName(NamiComiConstants.primaryTag)
+class PrimaryTagDto : AbstractTagDto()
+
+@Serializable
+@SerialName(NamiComiConstants.secondaryTag)
+class SecondaryTagDto : AbstractTagDto()
+
+@Serializable
+class TagAttributesDto(val group: String) : AttributesDto
diff --git a/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/dto/OrganizationDto.kt b/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/dto/OrganizationDto.kt
new file mode 100644
index 000000000..ec864ef8e
--- /dev/null
+++ b/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/dto/OrganizationDto.kt
@@ -0,0 +1,12 @@
+package eu.kanade.tachiyomi.extension.all.namicomi.dto
+
+import eu.kanade.tachiyomi.extension.all.namicomi.NamiComiConstants
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+@SerialName(NamiComiConstants.organization)
+class OrganizationDto(override val attributes: OrganizationAttributesDto? = null) : EntityDto()
+
+@Serializable
+class OrganizationAttributesDto(val name: String) : AttributesDto
diff --git a/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/dto/PageListDataDto.kt b/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/dto/PageListDataDto.kt
new file mode 100644
index 000000000..0c5485931
--- /dev/null
+++ b/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/dto/PageListDataDto.kt
@@ -0,0 +1,26 @@
+package eu.kanade.tachiyomi.extension.all.namicomi.dto
+
+import eu.kanade.tachiyomi.extension.all.namicomi.NamiComiConstants
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+typealias PageListDto = ResponseDto
+
+@Serializable
+@SerialName(NamiComiConstants.imageData)
+class PageListDataDto(
+ override val attributes: AttributesDto? = null,
+ val baseUrl: String,
+ val hash: String,
+ val source: List,
+ val high: List,
+ val medium: List,
+ val low: List,
+) : EntityDto()
+
+@Serializable
+class PageImageDto(
+ val size: Int?,
+ val filename: String,
+ val resolution: String?,
+)
diff --git a/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/dto/ResponseDto.kt b/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/dto/ResponseDto.kt
new file mode 100644
index 000000000..3aa5c99f4
--- /dev/null
+++ b/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/dto/ResponseDto.kt
@@ -0,0 +1,27 @@
+package eu.kanade.tachiyomi.extension.all.namicomi.dto
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+class PaginatedResponseDto(
+ val result: String,
+ val data: List = emptyList(),
+ val meta: PaginationStateDto,
+)
+
+@Serializable
+class ResponseDto(
+ val result: String,
+ val type: String,
+ val data: T? = null,
+)
+
+@Serializable
+class PaginationStateDto(
+ val limit: Int = 0,
+ val offset: Int = 0,
+ val total: Int = 0,
+) {
+ val hasNextPage: Boolean
+ get() = limit + offset < total
+}