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:
parent
a837998ad8
commit
d470490087
|
@ -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>
|
|
@ -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
|
|
@ -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 |
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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")
|
|
@ -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))
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
|
@ -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
|
|
@ -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,
|
||||
)
|
|
@ -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()
|
|
@ -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
|
|
@ -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
|
|
@ -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?,
|
||||
)
|
|
@ -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
|
||||
}
|
Loading…
Reference in New Issue