diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/ErrorMessages.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/ErrorMessages.kt index 57ae550d5..23f819cd6 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/ErrorMessages.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/ErrorMessages.kt @@ -5,7 +5,11 @@ object ErrorMessages { const val FILTER_MULTIPLE_STATES_NOT_SUPPORTED = "Multiple states are not supported by this source" const val FILTER_MULTIPLE_GENRES_NOT_SUPPORTED = "Multiple genres are not supported by this source" const val FILTER_MULTIPLE_CONTENT_RATING_NOT_SUPPORTED = - "Multiple Content Rating are not supported by this source" + "Multiple Content ratings are not supported by this source" + const val FILTER_MULTIPLE_CONTENT_TYPES_NOT_SUPPORTED = + "Multiple Content types are not supported by this source" + const val FILTER_MULTIPLE_DEMOGRAPHICS_NOT_SUPPORTED = + "Multiple Demographics are not supported by this source" const val FILTER_BOTH_LOCALE_GENRES_NOT_SUPPORTED = "Filtering by both genres and locale is not supported by this source" const val FILTER_BOTH_STATES_GENRES_NOT_SUPPORTED = diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/MangaParser.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/MangaParser.kt index 14eee58f9..b9966944a 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/MangaParser.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/MangaParser.kt @@ -34,9 +34,30 @@ abstract class MangaParser @InternalParsersApi constructor( get() = emptySet() + /** + * Supported [ContentRating] variants for filtering. May be empty. + * + * For better performance use [EnumSet] for more than one item. + */ open val availableContentRating: Set get() = emptySet() + /** + * Supported [ContentType] variants for filtering. May be empty. + * + * For better performance use [EnumSet] for more than one item. + */ + open val availableContentTypes: Set + get() = emptySet() + + /** + * Supported [Demographic] variants for filtering. May be empty. + * + * For better performance use [EnumSet] for more than one item. + */ + open val availableDemographics: Set + get() = emptySet() + /** * Whether parser supports filtering by more than one tag */ @@ -191,6 +212,8 @@ abstract class MangaParser @InternalParsersApi constructor( year = null, yearFrom = null, yearTo = null, + types = emptySet(), + demographics = emptySet(), ), ) } diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/model/ContentType.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/model/ContentType.kt index e3eb1ecf5..695440fbb 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/model/ContentType.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/model/ContentType.kt @@ -7,6 +7,10 @@ enum class ContentType { */ MANGA, + MANHWA, + + MANHUA, + /** * Use this if the source provides mostly nsfw content. */ @@ -17,6 +21,8 @@ enum class ContentType { */ COMICS, + NOVEL, + /** * Use this type if no other suits your needs. For example, for an indie manga */ diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/model/Demographic.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/model/Demographic.kt new file mode 100644 index 000000000..d94bda672 --- /dev/null +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/model/Demographic.kt @@ -0,0 +1,9 @@ +package org.koitharu.kotatsu.parsers.model + +enum class Demographic { + SHOUNEN, + SHOUJO, + SEINEN, + JOSEI, + NONE, +} diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/model/MangaListFilter.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/model/MangaListFilter.kt index aa7777269..6ff8dda82 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/model/MangaListFilter.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/model/MangaListFilter.kt @@ -15,8 +15,10 @@ sealed interface MangaListFilter { (tagsExclude.isEmpty() || parser.isTagsExclusionSupported) && (contentRating.isEmpty() || parser.availableContentRating.containsAll(contentRating)) && (states.isEmpty() || parser.availableStates.containsAll(states) && - (parser.searchSupportedWithMultipleFilters) &&(parser.isSearchOriginalLanguages) && - (parser.isSearchYearSupported) && (parser.isSearchYearRangeSupported)) + (parser.searchSupportedWithMultipleFilters) && (parser.isSearchOriginalLanguages) && + (parser.isSearchYearSupported) && (parser.isSearchYearRangeSupported)) && + (types.isEmpty() || parser.availableContentTypes.containsAll(types)) && + (demographics.isEmpty() || parser.availableDemographics.containsAll(demographics)) is Search -> parser.isSearchSupported } @@ -42,10 +44,12 @@ sealed interface MangaListFilter { @JvmField val year: Int?, @JvmField val yearFrom: Int?, @JvmField val yearTo: Int?, + @JvmField val types: Set, + @JvmField val demographics: Set, ) : MangaListFilter { override fun isEmpty(): Boolean = - tags.isEmpty() && tagsExclude.isEmpty() && locale == null && localeMangas == null && states.isEmpty() && contentRating.isEmpty() && query == null && year == null && yearFrom == null && yearTo == null + tags.isEmpty() && tagsExclude.isEmpty() && locale == null && localeMangas == null && states.isEmpty() && contentRating.isEmpty() && query == null && year == null && yearFrom == null && yearTo == null && types.isEmpty() && demographics.isEmpty() fun newBuilder() = Builder(sortOrder) .tags(tags) @@ -58,6 +62,8 @@ sealed interface MangaListFilter { .year(year) .yearFrom(yearFrom) .yearTo(yearTo) + .type(types) + .demographic(demographics) class Builder(sortOrder: SortOrder) { @@ -72,6 +78,8 @@ sealed interface MangaListFilter { private var _year: Int? = null private var _yearFrom: Int? = null private var _yearTo: Int? = null + private var _types: Set? = null + private var _demographic: Set? = null fun sortOrder(order: SortOrder) = apply { _sortOrder = order @@ -117,7 +125,13 @@ sealed interface MangaListFilter { _yearTo = yearTo } + fun type(type: Set?) = apply { + _types = type + } + fun demographic(demographic: Set?) = apply { + _demographic = demographic + } fun build() = Advanced( sortOrder = _sortOrder, @@ -131,7 +145,9 @@ sealed interface MangaListFilter { year = _year, yearFrom = _yearFrom, yearTo = _yearTo, - ) + types = _types.orEmpty(), + demographics = _demographic.orEmpty(), + ) } } } diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/all/ComickFunParser.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/all/ComickFunParser.kt index dea30ddad..a737edd16 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/all/ComickFunParser.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/all/ComickFunParser.kt @@ -38,6 +38,15 @@ internal class ComickFunParser(context: MangaLoaderContext) : SortOrder.NEWEST, ) + override val availableContentTypes: Set = EnumSet.of( + ContentType.MANGA, + ContentType.MANHWA, + ContentType.MANHUA, + ContentType.OTHER, + ) + + override val availableDemographics: Set = EnumSet.allOf(Demographic::class.java) + override val availableStates: Set = EnumSet.of(MangaState.ONGOING, MangaState.FINISHED, MangaState.PAUSED, MangaState.ABANDONED) @@ -58,10 +67,6 @@ internal class ComickFunParser(context: MangaLoaderContext) : url.addQueryParameter("q", filter.query) } - null -> { - url.addQueryParameter("sort", "view") - } - is MangaListFilter.Advanced -> { filter.tags.forEach { @@ -97,12 +102,42 @@ internal class ComickFunParser(context: MangaLoaderContext) : } filter.yearFrom?.let { - url.addQueryParameter("from", filter.yearFrom.toString() ) + url.addQueryParameter("from", filter.yearFrom.toString()) } filter.yearTo?.let { - url.addQueryParameter("to", filter.yearTo.toString() ) + url.addQueryParameter("to", filter.yearTo.toString()) + } + + filter.types.forEach { + url.addQueryParameter( + "country", + when (it) { + ContentType.MANGA -> "jp" + ContentType.MANHWA -> "kr" + ContentType.MANHUA -> "cn" + ContentType.OTHER -> "others" + else -> "" + }, + ) } + + filter.demographics.forEach { + url.addQueryParameter( + "demographic", + when (it) { + Demographic.SHOUNEN -> "1" + Demographic.SHOUJO -> "2" + Demographic.SEINEN -> "3" + Demographic.JOSEI -> "4" + Demographic.NONE -> "5" + }, + ) + } + } + + null -> { + url.addQueryParameter("sort", "uploaded") } } val ja = webClient.httpGet(url.build()).parseJsonArray() diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/all/MangaDexParser.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/all/MangaDexParser.kt index 0ebb39f9e..4397ff327 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/all/MangaDexParser.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/all/MangaDexParser.kt @@ -37,6 +37,8 @@ internal class MangaDexParser(context: MangaLoaderContext) : MangaParser(context override val availableContentRating: Set = EnumSet.allOf(ContentRating::class.java) + override val availableDemographics: Set = EnumSet.allOf(Demographic::class.java) + override val availableStates: Set = EnumSet.of(MangaState.ONGOING, MangaState.FINISHED, MangaState.PAUSED, MangaState.ABANDONED) @@ -108,6 +110,7 @@ internal class MangaDexParser(context: MangaLoaderContext) : MangaParser(context SortOrder.RELEVANCE -> "&order[relevance]=desc" }, ) + filter.states.forEach { append("&status[]=") when (it) { @@ -118,6 +121,20 @@ internal class MangaDexParser(context: MangaLoaderContext) : MangaParser(context else -> append("") } } + + filter.demographics.forEach { + append("&publicationDemographic[]=") + append( + when (it) { + Demographic.SHOUNEN -> "shounen" + Demographic.SHOUJO -> "shoujo" + Demographic.SEINEN -> "seinen" + Demographic.JOSEI -> "josei" + Demographic.NONE -> "none" + }, + ) + } + filter.locale?.let { append("&availableTranslatedLanguage[]=") append(it.language) diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/mangareader/MangaReaderParser.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/mangareader/MangaReaderParser.kt index 99188a2c0..a6a810235 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/mangareader/MangaReaderParser.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/mangareader/MangaReaderParser.kt @@ -46,6 +46,16 @@ internal abstract class MangaReaderParser( override val availableStates: Set get() = EnumSet.of(MangaState.ONGOING, MangaState.FINISHED, MangaState.PAUSED) + override val availableContentTypes: Set + get() = EnumSet.of( + ContentType.MANGA, + ContentType.MANHWA, + ContentType.MANHUA, + ContentType.COMICS, + ContentType.NOVEL, + ) + + override val isTagsExclusionSupported = true protected open val listUrl = "/manga" @@ -110,6 +120,20 @@ internal abstract class MangaReaderParser( } } + filter.types.oneOrThrowIfMany()?.let { + append("&type=") + append( + when (it) { + ContentType.MANGA -> "manga" + ContentType.MANHWA -> "manhwa" + ContentType.MANHUA -> "manhua" + ContentType.COMICS -> "comic" + ContentType.NOVEL -> "novel" + else -> "" + }, + ) + } + append("&page=") append(page.toString()) } @@ -223,18 +247,18 @@ internal abstract class MangaReaderParser( "En cours de publication", "Đang tiến hành", "Em lançamento", "em lançamento", "Em Lançamento", "Онгоінг", "Publishing", "Devam Ediyor", "Em Andamento", "In Corso", "Güncel", "Berjalan", "Продолжается", "Updating", "Lançando", "In Arrivo", "Emision", "En emision", "مستمر", "Curso", "En marcha", "Publicandose", "Publicando", "连载中", "Devam ediyor", "Devam Etmekte", - -> MangaState.ONGOING + -> MangaState.ONGOING "Completed", "Completo", "Complété", "Fini", "Achevé", "Terminé", "Terminé ⚫", "Tamamlandı", "Đã hoàn thành", "Hoàn Thành", "مكتملة", "Завершено", "Finished", "Finalizado", "Completata", "One-Shot", "Bitti", "Tamat", "Completado", "Concluído", "Concluido", "已完结", "Bitmiş", - -> MangaState.FINISHED + -> MangaState.FINISHED "Canceled", "Cancelled", "Cancelado", "cancellato", "Cancelados", "Dropped", "Discontinued", "abandonné", "Abandonné", - -> MangaState.ABANDONED + -> MangaState.ABANDONED "Hiatus", "On Hold", "Pausado", "En espera", "En pause", "En Pause", "En attente", - -> MangaState.PAUSED + -> MangaState.PAUSED else -> null } diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/util/MangaParserEnv.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/util/MangaParserEnv.kt index 79fb7bc07..845c5e293 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/util/MangaParserEnv.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/util/MangaParserEnv.kt @@ -51,30 +51,34 @@ fun Element.parseFailed(message: String? = null): Nothing { } @InternalParsersApi -fun Set?.oneOrThrowIfMany(): MangaTag? { - return when { - isNullOrEmpty() -> null - size == 1 -> first() - else -> throw IllegalArgumentException(ErrorMessages.FILTER_MULTIPLE_GENRES_NOT_SUPPORTED) - } -} +fun Set?.oneOrThrowIfMany(): MangaTag? = oneOrThrowIfMany( + ErrorMessages.FILTER_MULTIPLE_GENRES_NOT_SUPPORTED, +) @InternalParsersApi -fun Set?.oneOrThrowIfMany(): MangaState? { - return when { - isNullOrEmpty() -> null - size == 1 -> first() - else -> throw IllegalArgumentException(ErrorMessages.FILTER_MULTIPLE_STATES_NOT_SUPPORTED) - } -} +fun Set?.oneOrThrowIfMany(): MangaState? = oneOrThrowIfMany( + ErrorMessages.FILTER_MULTIPLE_STATES_NOT_SUPPORTED, +) @InternalParsersApi -fun Set?.oneOrThrowIfMany(): ContentRating? { - return when { - isNullOrEmpty() -> null - size == 1 -> first() - else -> throw IllegalArgumentException(ErrorMessages.FILTER_MULTIPLE_CONTENT_RATING_NOT_SUPPORTED) - } +fun Set?.oneOrThrowIfMany(): ContentType? = oneOrThrowIfMany( + ErrorMessages.FILTER_MULTIPLE_CONTENT_TYPES_NOT_SUPPORTED, +) + +@InternalParsersApi +fun Set?.oneOrThrowIfMany(): Demographic? = oneOrThrowIfMany( + ErrorMessages.FILTER_MULTIPLE_DEMOGRAPHICS_NOT_SUPPORTED, +) + +@InternalParsersApi +fun Set?.oneOrThrowIfMany(): ContentRating? = oneOrThrowIfMany( + ErrorMessages.FILTER_MULTIPLE_CONTENT_RATING_NOT_SUPPORTED, +) + +private fun Set?.oneOrThrowIfMany(msg: String): T? = when { + isNullOrEmpty() -> null + size == 1 -> first() + else -> throw IllegalArgumentException(msg) } val MangaParser.domain: String diff --git a/src/test/kotlin/org/koitharu/kotatsu/parsers/MangaParserTest.kt b/src/test/kotlin/org/koitharu/kotatsu/parsers/MangaParserTest.kt index e58151b00..5b698f46d 100644 --- a/src/test/kotlin/org/koitharu/kotatsu/parsers/MangaParserTest.kt +++ b/src/test/kotlin/org/koitharu/kotatsu/parsers/MangaParserTest.kt @@ -19,7 +19,7 @@ internal class MangaParserTest { @ParameterizedTest(name = "{index}|list|{0}") @MangaSources - fun list(source: MangaParserSource) = runTest(timeout = timeout) { + fun list(source: MangaParserSource) = runTest(timeout = timeout) { val parser = context.newParserInstance(source) val list = parser.getList(0, null) checkMangaList(list, "list") @@ -28,7 +28,7 @@ internal class MangaParserTest { @ParameterizedTest(name = "{index}|pagination|{0}") @MangaSources - fun pagination(source: MangaParserSource) = runTest(timeout = timeout) { + fun pagination(source: MangaParserSource) = runTest(timeout = timeout) { val parser = context.newParserInstance(source) val page1 = parser.getList(0, filter = null) val page2 = parser.getList(page1.size, filter = null) @@ -48,7 +48,7 @@ internal class MangaParserTest { @ParameterizedTest(name = "{index}|search|{0}") @MangaSources - fun search(source: MangaParserSource) = runTest(timeout = timeout) { + fun search(source: MangaParserSource) = runTest(timeout = timeout) { val parser = context.newParserInstance(source) val subject = parser.getList( offset = 0, @@ -64,6 +64,8 @@ internal class MangaParserTest { year = null, yearFrom = null, yearTo = null, + types = emptySet(), + demographics = emptySet(), ), ).minByOrNull { it.title.length @@ -81,7 +83,7 @@ internal class MangaParserTest { @ParameterizedTest(name = "{index}|tags|{0}") @MangaSources - fun tags(source: MangaParserSource) = runTest(timeout = timeout) { + fun tags(source: MangaParserSource) = runTest(timeout = timeout) { val parser = context.newParserInstance(source) val tags = parser.getAvailableTags() assert(tags.isNotEmpty()) { "No tags found" } @@ -110,7 +112,7 @@ internal class MangaParserTest { @ParameterizedTest(name = "{index}|tags_multiple|{0}") @MangaSources - fun tagsMultiple(source: MangaParserSource) = runTest(timeout = timeout) { + fun tagsMultiple(source: MangaParserSource) = runTest(timeout = timeout) { val parser = context.newParserInstance(source) if (!parser.isMultipleTagsSupported) return@runTest val tags = parser.getAvailableTags().shuffled().take(2).toSet() @@ -125,7 +127,7 @@ internal class MangaParserTest { @ParameterizedTest(name = "{index}|locale|{0}") @MangaSources - fun locale(source: MangaParserSource) = runTest(timeout = timeout) { + fun locale(source: MangaParserSource) = runTest(timeout = timeout) { val parser = context.newParserInstance(source) val locales = parser.getAvailableLocales() if (locales.isEmpty()) { @@ -143,6 +145,8 @@ internal class MangaParserTest { year = null, yearFrom = null, yearTo = null, + types = emptySet(), + demographics = emptySet(), ) val list = parser.getList(offset = 0, filter) checkMangaList(list, filter.locale.toString()) @@ -152,7 +156,7 @@ internal class MangaParserTest { @ParameterizedTest(name = "{index}|details|{0}") @MangaSources - fun details(source: MangaParserSource) = runTest(timeout = timeout) { + fun details(source: MangaParserSource) = runTest(timeout = timeout) { val parser = context.newParserInstance(source) val list = parser.getList(0, null) val manga = list[3] @@ -182,7 +186,7 @@ internal class MangaParserTest { @ParameterizedTest(name = "{index}|pages|{0}") @MangaSources - fun pages(source: MangaParserSource) = runTest(timeout = timeout) { + fun pages(source: MangaParserSource) = runTest(timeout = timeout) { val parser = context.newParserInstance(source) val list = parser.getList(0, null) val manga = list.first() @@ -206,7 +210,7 @@ internal class MangaParserTest { @ParameterizedTest(name = "{index}|favicon|{0}") @MangaSources - fun favicon(source: MangaParserSource) = runTest(timeout = timeout) { + fun favicon(source: MangaParserSource) = runTest(timeout = timeout) { val parser = context.newParserInstance(source) val favicons = parser.getFavicons() val types = setOf("png", "svg", "ico", "gif", "jpg", "jpeg") @@ -222,7 +226,7 @@ internal class MangaParserTest { @ParameterizedTest(name = "{index}|domain|{0}") @MangaSources - fun domain(source: MangaParserSource) = runTest(timeout = timeout) { + fun domain(source: MangaParserSource) = runTest(timeout = timeout) { val parser = context.newParserInstance(source) val defaultDomain = parser.domain val url = HttpUrl.Builder().host(defaultDomain).scheme("https").toString() @@ -238,7 +242,7 @@ internal class MangaParserTest { @ParameterizedTest(name = "{index}|authorization|{0}") @MangaSources @Disabled - fun authorization(source: MangaParserSource) = runTest(timeout = timeout) { + fun authorization(source: MangaParserSource) = runTest(timeout = timeout) { val parser = context.newParserInstance(source) if (parser is MangaParserAuthProvider) { val username = parser.getUsername()