Merge branch 'feature/search_query'

This commit is contained in:
2025-02-27 17:46:14 +02:00
160 changed files with 2740 additions and 1340 deletions

View File

@@ -5,8 +5,6 @@ import okhttp3.Request
import okhttp3.Response
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.domain
import org.koitharu.kotatsu.parsers.util.mergeWith
private const val HEADER_REFERER = "Referer"
@@ -20,11 +18,7 @@ internal class CommonHeadersInterceptor : Interceptor {
} else {
null
}
val sourceHeaders = parser?.getRequestHeaders()
val headersBuilder = request.headers.newBuilder()
if (sourceHeaders != null) {
headersBuilder.mergeWith(sourceHeaders, replaceExisting = false)
}
if (headersBuilder[HEADER_REFERER] == null && parser != null) {
headersBuilder[HEADER_REFERER] = "https://${parser.domain}/"
}

View File

@@ -5,8 +5,13 @@ import okhttp3.HttpUrl
import org.junit.jupiter.api.Assertions
import org.junit.jupiter.api.Disabled
import org.junit.jupiter.params.ParameterizedTest
import org.koitharu.kotatsu.parsers.core.LegacyPagedMangaParser
import org.koitharu.kotatsu.parsers.core.LegacySinglePageMangaParser
import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.util.domain
import org.koitharu.kotatsu.parsers.model.search.MangaSearchQuery
import org.koitharu.kotatsu.parsers.model.search.QueryCriteria
import org.koitharu.kotatsu.parsers.model.search.QueryCriteria.Include
import org.koitharu.kotatsu.parsers.model.search.SearchableField.*
import org.koitharu.kotatsu.parsers.util.medianOrNull
import org.koitharu.kotatsu.parsers.util.mimeType
import org.koitharu.kotatsu.test_util.*
@@ -22,7 +27,7 @@ internal class MangaParserTest {
@MangaSources
fun list(source: MangaParserSource) = runTest(timeout = timeout) {
val parser = context.newParserInstance(source)
val list = parser.getList(0, parser.defaultSortOrder, MangaListFilter.EMPTY)
val list = parser.getList(MangaSearchQuery.Builder().build())
checkMangaList(list, "list")
assert(list.all { it.source == source })
}
@@ -31,12 +36,13 @@ internal class MangaParserTest {
@MangaSources
fun pagination(source: MangaParserSource) = runTest(timeout = timeout) {
val parser = context.newParserInstance(source)
if (parser is SinglePageMangaParser) {
if (parser is LegacySinglePageMangaParser) {
return@runTest
}
val page1 = parser.getList(0, parser.defaultSortOrder, MangaListFilter.EMPTY)
val page2 = parser.getList(page1.size, parser.defaultSortOrder, MangaListFilter.EMPTY)
if (parser is PagedMangaParser) {
val page1 = parser.getList(MangaSearchQuery.EMPTY)
val page2 =
parser.getList(MangaSearchQuery.Builder().offset(page1.size).build())
if (parser is LegacyPagedMangaParser) {
assert(parser.pageSize >= page1.size) {
"Page size is ${page1.size} but ${parser.pageSize} expected"
}
@@ -52,18 +58,20 @@ internal class MangaParserTest {
@ParameterizedTest(name = "{index}|search|{0}")
@MangaSources
fun search(source: MangaParserSource) = runTest(timeout = timeout) {
fun searchByTitleName(source: MangaParserSource) = runTest(timeout = timeout) {
val parser = context.newParserInstance(source)
val subject = parser.getList(
offset = 0,
order = SortOrder.POPULARITY,
filter = MangaListFilter.EMPTY,
).minByOrNull {
val subject = parser.getList(MangaSearchQuery.EMPTY).minByOrNull {
it.title.length
} ?: error("No manga found")
val query = subject.title
check(query.isNotBlank()) { "Manga title '$query' is blank" }
val list = parser.getList(0, SortOrder.RELEVANCE, MangaListFilter(query = query))
val list = parser.getList(
MangaSearchQuery.Builder()
.order(SortOrder.RELEVANCE)
.criterion(QueryCriteria.Match(TITLE_NAME, query))
.build(),
)
assert(list.isNotEmpty()) { "Empty search results by \"$query\"" }
assert(list.singleOrNull { it.url == subject.url && it.id == subject.id } != null) {
"Single subject '${subject.title} (${subject.publicUrl})' not found in search results"
@@ -92,9 +100,10 @@ internal class MangaParserTest {
val tag = tags.last()
val list = parser.getList(
offset = 0,
order = parser.defaultSortOrder,
filter = MangaListFilter(tags = setOf(tag)),
MangaSearchQuery.Builder()
.offset(0)
.criterion(Include(TAG, setOf(tag)))
.build(),
)
checkMangaList(list, "${tag.title} (${tag.key})")
assert(list.all { it.source == source })
@@ -104,11 +113,16 @@ internal class MangaParserTest {
@MangaSources
fun tagsMultiple(source: MangaParserSource) = runTest(timeout = timeout) {
val parser = context.newParserInstance(source)
if (!parser.filterCapabilities.isMultipleTagsSupported) return@runTest
// if (!parser.filterCapabilities.isMultipleTagsSupported) return@runTest
val tags = parser.getFilterOptions().availableTags.shuffled().take(2).toSet()
val filter = MangaListFilter(tags = tags)
val list = parser.getList(0, parser.defaultSortOrder, filter)
val list = parser.getList(
MangaSearchQuery.Builder()
.offset(0)
.criterion(Include(TAG, tags))
.build(),
)
checkMangaList(list, "${tags.joinToString { it.title }} (${tags.joinToString { it.key }})")
assert(list.all { it.source == source })
}
@@ -121,12 +135,15 @@ internal class MangaParserTest {
if (locales.isEmpty()) {
return@runTest
}
val filter = MangaListFilter(
locale = locales.random(),
originalLocale = locales.random(),
val locale = locales.random()
val list = parser.getList(
MangaSearchQuery.Builder()
.criterion(Include(LANGUAGE, setOf(locale)))
.criterion(Include(LANGUAGE, setOf(locale)))
.criterion(Include(ORIGINAL_LANGUAGE, setOf(locales.random())))
.build(),
)
val list = parser.getList(offset = 0, order = parser.defaultSortOrder, filter)
checkMangaList(list, filter.locale.toString())
checkMangaList(list, locale.toString())
assert(list.all { it.source == source })
}
@@ -135,8 +152,9 @@ internal class MangaParserTest {
@MangaSources
fun details(source: MangaParserSource) = runTest(timeout = timeout) {
val parser = context.newParserInstance(source)
val list = parser.getList(0, parser.defaultSortOrder, MangaListFilter.EMPTY)
val manga = list[3]
val list = parser.getList(MangaSearchQuery.EMPTY)
val manga = list[0]
parser.getDetails(manga).apply {
assert(!chapters.isNullOrEmpty()) { "Chapters are null or empty" }
assert(publicUrl.isUrlAbsolute()) { "Manga public url is not absolute: '$publicUrl'" }
@@ -165,7 +183,7 @@ internal class MangaParserTest {
@MangaSources
fun pages(source: MangaParserSource) = runTest(timeout = timeout) {
val parser = context.newParserInstance(source)
val list = parser.getList(0, parser.defaultSortOrder, MangaListFilter.EMPTY)
val list = parser.getList(MangaSearchQuery.EMPTY)
val manga = list.first()
val chapter = parser.getDetails(manga).chapters?.firstOrNull() ?: error("Chapter is null at ${manga.publicUrl}")
val pages = parser.getPages(chapter)
@@ -220,7 +238,7 @@ internal class MangaParserTest {
@MangaSources
fun link(source: MangaParserSource) = runTest(timeout = timeout) {
val parser = context.newParserInstance(source)
val manga = parser.getList(0, parser.defaultSortOrder, MangaListFilter.EMPTY).first()
val manga = parser.getList(MangaSearchQuery.Builder().build()).first()
val resolved = context.newLinkResolver(manga.publicUrl).getManga()
Assertions.assertNotNull(resolved)
resolved ?: return@runTest

View File

@@ -0,0 +1,72 @@
package org.koitharu.kotatsu.parsers.model.search
import org.junit.jupiter.api.Assertions.assertDoesNotThrow
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Test
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.search.QueryCriteria.*
import org.koitharu.kotatsu.parsers.model.search.SearchableField.*
import java.util.*
class MangaSearchQueryCapabilitiesTest {
private val capabilities = MangaSearchQueryCapabilities(
capabilities = setOf(
SearchCapability(TITLE_NAME, setOf(Match::class), multiValue = false, otherCriteria = false),
SearchCapability(TAG, setOf(Include::class, Exclude::class), multiValue = true, otherCriteria = true),
SearchCapability(PUBLICATION_YEAR, setOf(Range::class), multiValue = false, otherCriteria = true),
SearchCapability(STATE, setOf(Include::class), multiValue = false, otherCriteria = true),
),
)
@Test
fun validateValidSingleCriterionQuery() {
val query = MangaSearchQuery.Builder()
.criterion(Match(TITLE_NAME, "title"))
.build()
assertDoesNotThrow { capabilities.validate(query) }
}
@Test
fun validateUnsupportedFieldThrowsException() {
val query = MangaSearchQuery.Builder()
.criterion(Include(ORIGINAL_LANGUAGE, setOf(Locale.ENGLISH)))
.build()
assertThrows(IllegalArgumentException::class.java) { capabilities.validate(query) }
}
@Test
fun validateUnsupportedMultiValueThrowsException() {
val query = MangaSearchQuery.Builder()
.criterion(Include(STATE, setOf(MangaState.ONGOING, MangaState.FINISHED)))
.build()
assertThrows(IllegalArgumentException::class.java) { capabilities.validate(query) }
}
@Test
fun validateMultipleCriteriaWithOtherCriteriaAllowed() {
val query = MangaSearchQuery.Builder()
.criterion(Include(TAG, setOf(buildTag("tag1"), buildTag("tag2"))))
.criterion(Exclude(TAG, setOf(buildTag("tag3"))))
.build()
assertDoesNotThrow { capabilities.validate(query) }
}
@Test
fun validateMultipleCriteriaWithStrictCapabilityThrowsException() {
val query = MangaSearchQuery.Builder()
.criterion(Match(TITLE_NAME, "title"))
.criterion(Range(PUBLICATION_YEAR, 1990, 2000))
.build()
assertThrows(IllegalArgumentException::class.java) { capabilities.validate(query) }
}
private fun buildTag(name: String) = MangaTag(title = name, key = "${name}Key", source = MangaParserSource.DUMMY)
}

View File

@@ -1,6 +1,7 @@
package org.koitharu.kotatsu.parsers.util
import org.junit.jupiter.api.Test
import org.koitharu.kotatsu.parsers.core.LegacyMangaParser
import org.koitharu.kotatsu.parsers.MangaLoaderContextMock
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.newParser
@@ -25,7 +26,7 @@ class IntentFilterGenerator {
if (source == MangaParserSource.DUMMY) {
continue
}
val parser = source.newParser(MangaLoaderContextMock)
val parser = source.newParser(MangaLoaderContextMock) as LegacyMangaParser
parser.configKeyDomain.presetValues.forEach { domain ->
writer.appendTab().append("<data android:host=\"").append(domain).appendLine("\" />")
}

View File

@@ -0,0 +1,77 @@
package org.koitharu.kotatsu.parsers.util
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.model.ContentType.MANGA
import org.koitharu.kotatsu.parsers.model.ContentType.MANHUA
import org.koitharu.kotatsu.parsers.model.Demographic.SEINEN
import org.koitharu.kotatsu.parsers.model.search.MangaSearchQuery
import org.koitharu.kotatsu.parsers.model.search.QueryCriteria.*
import org.koitharu.kotatsu.parsers.model.search.SearchableField.*
import java.util.*
class ListFilterToSearchQueryConverterTest {
@Test
fun convertToMangaSearchQueryTest() {
val tags = setOf(buildMangaTag("tag1"), buildMangaTag("tag2"))
val excludedTags = setOf(buildMangaTag("exclude_tag"))
val states = setOf(MangaState.ONGOING)
val contentRatings = setOf(ContentRating.SAFE)
val contentTypes = setOf(MANGA, MANHUA)
val demographics = setOf(SEINEN)
val filter = MangaListFilter(
query = "title_name",
tags = tags,
tagsExclude = excludedTags,
locale = Locale.ENGLISH,
originalLocale = Locale.JAPANESE,
states = states,
contentRating = contentRatings,
types = contentTypes,
demographics = demographics,
year = 2020,
yearFrom = 1997,
yearTo = 2024,
)
val searchQuery = convertToMangaSearchQuery(0, SortOrder.NEWEST, filter)
val expectedQuery = MangaSearchQuery.Builder()
.offset(0)
.order(SortOrder.NEWEST)
.criterion(Match(TITLE_NAME, "title_name"))
.criterion(Include(TAG, tags))
.criterion(Exclude(TAG, excludedTags))
.criterion(Include(LANGUAGE, setOf(Locale.ENGLISH)))
.criterion(Include(ORIGINAL_LANGUAGE, setOf(Locale.JAPANESE)))
.criterion(Include(STATE, states))
.criterion(Include(CONTENT_RATING, contentRatings))
.criterion(Include(CONTENT_TYPE, contentTypes))
.criterion(Include(DEMOGRAPHIC, demographics))
.criterion(Range(PUBLICATION_YEAR, 1997, 2024))
.criterion(Match(PUBLICATION_YEAR, 2020))
.build()
assertEquals(expectedQuery, searchQuery)
}
@Test
fun convertToMangaSearchQueryWithEmptyFieldsTest() {
val filter = MangaListFilter()
val searchQuery = convertToMangaSearchQuery(0, SortOrder.NEWEST, filter)
assertEquals(MangaSearchQuery.Builder().offset(0).order(SortOrder.NEWEST).build(), searchQuery)
}
private fun buildMangaTag(name: String): MangaTag {
return MangaTag(
key = "${name}Key",
title = name,
source = MangaParserSource.DUMMY,
)
}
}

View File

@@ -0,0 +1,94 @@
package org.koitharu.kotatsu.parsers.util
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.ContentType.MANGA
import org.koitharu.kotatsu.parsers.model.ContentType.MANHUA
import org.koitharu.kotatsu.parsers.model.Demographic.SEINEN
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.search.MangaSearchQuery
import org.koitharu.kotatsu.parsers.model.search.QueryCriteria.*
import org.koitharu.kotatsu.parsers.model.search.SearchableField.*
import java.util.*
class ConvertToMangaListFilterTest {
@Test
fun convertToMangaListFilterTest() {
val tags = setOf(buildMangaTag("tag1"), buildMangaTag("tag2"))
val excludedTags = setOf(buildMangaTag("exclude_tag"))
val states = setOf(MangaState.ONGOING)
val contentRatings = setOf(ContentRating.SAFE)
val contentTypes = setOf(MANGA, MANHUA)
val demographics = setOf(SEINEN)
val query = MangaSearchQuery.Builder()
.criterion(Match(TITLE_NAME, "title_name"))
.criterion(Include(TAG, tags))
.criterion(Exclude(TAG, excludedTags))
.criterion(Include(LANGUAGE, setOf(Locale.ENGLISH)))
.criterion(Include(ORIGINAL_LANGUAGE, setOf(Locale.JAPANESE)))
.criterion(Include(STATE, states))
.criterion(Include(CONTENT_RATING, contentRatings))
.criterion(Include(CONTENT_TYPE, contentTypes))
.criterion(Include(DEMOGRAPHIC, demographics))
.criterion(Range(PUBLICATION_YEAR, 1997, 2024))
.criterion(Match(PUBLICATION_YEAR, 2020))
.build()
val listFilter = convertToMangaListFilter(query)
assertEquals(listFilter.query, "title_name")
assertEquals(listFilter.tags, tags)
assertEquals(listFilter.tagsExclude, excludedTags)
assertEquals(listFilter.locale, Locale.ENGLISH)
assertEquals(listFilter.originalLocale, Locale.JAPANESE)
assertEquals(listFilter.states, states)
assertEquals(listFilter.contentRating, contentRatings)
assertEquals(listFilter.types, contentTypes)
assertEquals(listFilter.demographics, demographics)
assertEquals(listFilter.year, 2020)
assertEquals(listFilter.yearFrom, 1997)
assertEquals(listFilter.yearTo, 2024)
}
@Test
fun convertToMangaListFilterWithMultipleTagsIncludeTest() {
val tags1 = setOf(buildMangaTag("tag1"), buildMangaTag("tag2"))
val tags2 = setOf(buildMangaTag("tag3"), buildMangaTag("tag4"))
val query = MangaSearchQuery.Builder()
.criterion(Include(TAG, tags1))
.criterion(Include(TAG, tags2))
.build()
val listFilter = convertToMangaListFilter(query)
assertEquals(listFilter.tags, tags1 union tags2)
}
@Test
fun convertToMangaListFilterWithUnsupportedFieldTest() {
val query = MangaSearchQuery.Builder()
.criterion(Include(AUTHOR, setOf(buildMangaTag("author"))))
.build()
val exception = assertThrows<IllegalArgumentException> {
convertToMangaListFilter(query)
}
assert(exception.message!!.contains("Unsupported field for Include criterion: AUTHOR"))
}
private fun buildMangaTag(name: String): MangaTag {
return MangaTag(
key = "${name}Key",
title = name,
source = MangaParserSource.DUMMY,
)
}
}