Add NamiComi (#7057)

* Add Namicomi

* Remove conditional; already handled by the intent-filter

* Simplify error handling

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

* Update old comment

* Remove hardcoded /en/ url path

* Chapters missing from the accessMap should be considered inaccessible

* Remove setOnPreferenceChangeListener

* Remove unused i18n key

* Rename Namicomi to NamiComi

* Revert accidental change to settings.gradle.kts

* Remove remaining setOnPreferenceListener

* Close response on error

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

* Require nonnull chapter access map

* Change abstract to sealed in EntityDto

Co-authored-by: FourTOne5 <107297513+FourTOne5@users.noreply.github.com>

* Change abstract to sealed in MangaDto

Co-authored-by: FourTOne5 <107297513+FourTOne5@users.noreply.github.com>

* Change SManga.PUBLISHING_FINISHED to SManga.COMPLETED

Co-authored-by: FourTOne5 <107297513+FourTOne5@users.noreply.github.com>

* Set initialized = true

Co-authored-by: FourTOne5 <107297513+FourTOne5@users.noreply.github.com>

* Remove allowSpecialFloatingPointValues and prettyPrint

* Remove markdown cleanup functions

* Cleanup import

* Remove constructors for new sealed interface

* Fix PaginatedResponseDto structure

* Simplify and remove createBasicManga

* Remove old MangaDex code

* Use 🔒 for locked chapters

* Update NamiComiHelper.kt

Co-authored-by: FourTOne5 <107297513+FourTOne5@users.noreply.github.com>

* Remove data modifier from dto classes

* Apply suggestions from code review

Co-authored-by: FourTOne5 <107297513+FourTOne5@users.noreply.github.com>

* Update URL/ID handling

* Move companion object to bottom

---------

Co-authored-by: AwkwardPeak7 <48650614+AwkwardPeak7@users.noreply.github.com>
Co-authored-by: FourTOne5 <107297513+FourTOne5@users.noreply.github.com>
This commit is contained in:
Tim Schneeberger 2025-01-12 10:28:52 +01:00 committed by Draff
parent a837998ad8
commit d470490087
No known key found for this signature in database
GPG Key ID: E8A89F3211677653
22 changed files with 1398 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.namicomi.NamiComiUrlActivity"
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="namicomi.com" />
<data android:scheme="https" />
<data android:pathPattern="/.*/title/..*" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@ -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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@ -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<Application>().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<MangaListDto>()
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<MangaDto>()
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<String>): 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<SChapter> {
if (response.code == 204) {
return emptyList()
}
val mangaId = response.request.url.toString()
.substringBefore("/chapter")
.substringAfter("${NamiComiConstants.apiMangaUrl}/")
val chapterListResponse = response.parseAs<ChapterListDto>()
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<ChapterListDto>()
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<EntityAccessMapDto>()
.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<Page> {
val chapterId = response.request.url.pathSegments.last()
val pageListDataDto = response.parseAs<PageListDto>().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 <reified T> 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)
}

View File

@ -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)
}

View File

@ -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<Source> = 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")

View File

@ -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<ContentRating>) :
Filter.Group<ContentRating>(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<Status>) :
Filter.Group<Status>(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<Sortable>) :
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<Tag>) :
Filter.Group<Tag>(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<Tag> {
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<Tag> {
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<Tag> {
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<Tag> {
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<Tag> {
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<TagMode>) :
Filter.Select<TagMode>(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<TagMode>) :
Filter.Select<TagMode>(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<Filter<*>>(intl["tags_mode"], innerFilters),
UrlQueryFilter {
override fun addQueryParameter(url: HttpUrl.Builder, extLang: String) {
state.filterIsInstance<UrlQueryFilter>()
.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<UrlQueryFilter>()
.forEach { filter -> filter.addQueryParameter(url, extLang) }
return url.build()
}
private fun List<Tag>.sortIfTranslated(intl: Intl): List<Tag> = apply {
if (intl.chosenLanguage == NamiComiConstants.english) {
return this
}
return sortedWith(compareBy(intl.collator, Tag::name))
}
}

View File

@ -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<OrganizationDto>()
.mapNotNull { it.attributes?.name }
.distinct()
val coverFileName = mangaDataDto.relationships
.filterIsInstance<CoverArtDto>()
.firstOrNull()
?.attributes?.fileName
val tags = filters.getTags(intl).associate { it.id to it.name }
val genresMap = mangaDataDto.relationships
.filterIsInstance<AbstractTagDto>()
.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<String>()
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()
}
}

View File

@ -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)
}
}

View File

@ -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<ChapterDataDto>
@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

View File

@ -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

View File

@ -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<EntityAccessMapDataDto>
@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<String, Boolean>,
) : AttributesDto
@Serializable
class EntityAccessRequestDto(
val entities: List<EntityAccessRequestItemDto>,
)
@Serializable
class EntityAccessRequestItemDto(
val entityId: String,
val entityType: String,
)

View File

@ -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<EntityDto> = emptyList()
abstract val attributes: AttributesDto?
}
@Serializable
sealed interface AttributesDto
@Serializable
class UnknownEntity(override val attributes: AttributesDto? = null) : EntityDto()

View File

@ -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<MangaDataDto>
typealias MangaDto = ResponseDto<MangaDataDto>
@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<String, String>,
val description: Map<String, String>,
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

View File

@ -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

View File

@ -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<PageListDataDto>
@Serializable
@SerialName(NamiComiConstants.imageData)
class PageListDataDto(
override val attributes: AttributesDto? = null,
val baseUrl: String,
val hash: String,
val source: List<PageImageDto>,
val high: List<PageImageDto>,
val medium: List<PageImageDto>,
val low: List<PageImageDto>,
) : EntityDto()
@Serializable
class PageImageDto(
val size: Int?,
val filename: String,
val resolution: String?,
)

View File

@ -0,0 +1,27 @@
package eu.kanade.tachiyomi.extension.all.namicomi.dto
import kotlinx.serialization.Serializable
@Serializable
class PaginatedResponseDto<T : EntityDto>(
val result: String,
val data: List<T> = emptyList(),
val meta: PaginationStateDto,
)
@Serializable
class ResponseDto<T : EntityDto>(
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
}