From 5ddc3b3a2013721ac219c27b70aceb470c75bf08 Mon Sep 17 00:00:00 2001 From: wiseCirno <78086755+wiseCirno@users.noreply.github.com> Date: Fri, 27 Feb 2026 15:31:43 +0800 Subject: [PATCH 1/3] feat: E-Hentai metadata provider initial implementation --- .../kotlin/snd/komf/api/CommonTypes.kt | 1 + .../kotlin/snd/komf/api/config/KomfConfig.kt | 1 + .../api/config/KomfConfigUpdateRequest.kt | 1 + .../komf/app/api/mappers/AppConfigMapper.kt | 1 + .../app/api/mappers/AppConfigUpdateMapper.kt | 2 + .../komf/app/api/mappers/CommonTypesMapper.kt | 2 + .../snd/komf/providers/CoreProviders.kt | 1 + .../komf/providers/MetadataProvidersConfig.kt | 1 + .../snd/komf/providers/ProvidersModule.kt | 49 +++++++++ .../komf/providers/ehentai/EHentaiClient.kt | 100 +++++++++++++++++ .../providers/ehentai/EHentaiGidParser.kt | 14 +++ .../ehentai/EHentaiMetadataMapper.kt | 101 ++++++++++++++++++ .../ehentai/EHentaiMetadataProvider.kt | 89 +++++++++++++++ .../komf/providers/ehentai/EHentaiResponse.kt | 78 ++++++++++++++ 14 files changed, 441 insertions(+) create mode 100644 komf-core/src/commonMain/kotlin/snd/komf/providers/ehentai/EHentaiClient.kt create mode 100644 komf-core/src/commonMain/kotlin/snd/komf/providers/ehentai/EHentaiGidParser.kt create mode 100644 komf-core/src/commonMain/kotlin/snd/komf/providers/ehentai/EHentaiMetadataMapper.kt create mode 100644 komf-core/src/commonMain/kotlin/snd/komf/providers/ehentai/EHentaiMetadataProvider.kt create mode 100644 komf-core/src/commonMain/kotlin/snd/komf/providers/ehentai/EHentaiResponse.kt diff --git a/komf-api-models/src/commonMain/kotlin/snd/komf/api/CommonTypes.kt b/komf-api-models/src/commonMain/kotlin/snd/komf/api/CommonTypes.kt index 66460b9c..1894d262 100644 --- a/komf-api-models/src/commonMain/kotlin/snd/komf/api/CommonTypes.kt +++ b/komf-api-models/src/commonMain/kotlin/snd/komf/api/CommonTypes.kt @@ -57,6 +57,7 @@ enum class KomfCoreProviders : KomfProviders { BANGUMI, BOOK_WALKER, COMIC_VINE, + EHENTAI, HENTAG, KODANSHA, MAL, diff --git a/komf-api-models/src/commonMain/kotlin/snd/komf/api/config/KomfConfig.kt b/komf-api-models/src/commonMain/kotlin/snd/komf/api/config/KomfConfig.kt index 663f9dda..2de41ca8 100644 --- a/komf-api-models/src/commonMain/kotlin/snd/komf/api/config/KomfConfig.kt +++ b/komf-api-models/src/commonMain/kotlin/snd/komf/api/config/KomfConfig.kt @@ -118,6 +118,7 @@ data class ProvidersConfigDto( val bangumi: ProviderConfigDto, val comicVine: ProviderConfigDto, val hentag: ProviderConfigDto, + val ehentai: ProviderConfigDto, val mangaBaka: MangaBakaConfigDto, val webtoons: ProviderConfigDto, ) diff --git a/komf-api-models/src/commonMain/kotlin/snd/komf/api/config/KomfConfigUpdateRequest.kt b/komf-api-models/src/commonMain/kotlin/snd/komf/api/config/KomfConfigUpdateRequest.kt index 1ea45c9d..1e216384 100644 --- a/komf-api-models/src/commonMain/kotlin/snd/komf/api/config/KomfConfigUpdateRequest.kt +++ b/komf-api-models/src/commonMain/kotlin/snd/komf/api/config/KomfConfigUpdateRequest.kt @@ -109,6 +109,7 @@ data class ProvidersConfigUpdateRequest( val bangumi: PatchValue = PatchValue.Unset, val comicVine: PatchValue = PatchValue.Unset, val hentag: PatchValue = PatchValue.Unset, + val ehentai: PatchValue = PatchValue.Unset, val mangaBaka: PatchValue = PatchValue.Unset, val webtoons: PatchValue = PatchValue.Unset, ) diff --git a/komf-app/src/main/kotlin/snd/komf/app/api/mappers/AppConfigMapper.kt b/komf-app/src/main/kotlin/snd/komf/app/api/mappers/AppConfigMapper.kt index 4ab7b364..d036e13c 100644 --- a/komf-app/src/main/kotlin/snd/komf/app/api/mappers/AppConfigMapper.kt +++ b/komf-app/src/main/kotlin/snd/komf/app/api/mappers/AppConfigMapper.kt @@ -174,6 +174,7 @@ class AppConfigMapper { bangumi = toDto(config.bangumi), comicVine = toDto(config.comicVine), hentag = toDto(config.hentag), + ehentai = toDto(config.ehentai), mangaBaka = toDto(config.mangaBaka), webtoons = toDto(config.webtoons), ) diff --git a/komf-app/src/main/kotlin/snd/komf/app/api/mappers/AppConfigUpdateMapper.kt b/komf-app/src/main/kotlin/snd/komf/app/api/mappers/AppConfigUpdateMapper.kt index 28a4a1ff..d0763f34 100644 --- a/komf-app/src/main/kotlin/snd/komf/app/api/mappers/AppConfigUpdateMapper.kt +++ b/komf-app/src/main/kotlin/snd/komf/app/api/mappers/AppConfigUpdateMapper.kt @@ -153,6 +153,8 @@ class AppConfigUpdateMapper { ?.let { providerConfig(config.comicVine, it) } ?: config.comicVine, hentag = patch.hentag.getOrNull() ?.let { providerConfig(config.hentag, it) } ?: config.hentag, + ehentai = patch.ehentai.getOrNull() + ?.let { providerConfig(config.ehentai, it) } ?: config.ehentai, mangaBaka = patch.mangaBaka.getOrNull() ?.let { mangaBakaProviderConfig(config.mangaBaka, it) } ?: config.mangaBaka, webtoons = patch.webtoons.getOrNull() diff --git a/komf-app/src/main/kotlin/snd/komf/app/api/mappers/CommonTypesMapper.kt b/komf-app/src/main/kotlin/snd/komf/app/api/mappers/CommonTypesMapper.kt index 62fae3e3..66b5faee 100644 --- a/komf-app/src/main/kotlin/snd/komf/app/api/mappers/CommonTypesMapper.kt +++ b/komf-app/src/main/kotlin/snd/komf/app/api/mappers/CommonTypesMapper.kt @@ -92,6 +92,7 @@ fun CoreProviders.fromProvider() = when (this) { CoreProviders.BANGUMI -> KomfCoreProviders.BANGUMI CoreProviders.BOOK_WALKER -> KomfCoreProviders.BOOK_WALKER CoreProviders.COMIC_VINE -> KomfCoreProviders.COMIC_VINE + CoreProviders.EHENTAI -> KomfCoreProviders.EHENTAI CoreProviders.HENTAG -> KomfCoreProviders.HENTAG CoreProviders.KODANSHA -> KomfCoreProviders.KODANSHA CoreProviders.MAL -> KomfCoreProviders.MAL @@ -109,6 +110,7 @@ fun KomfProviders.toProvider() = when (this) { KomfCoreProviders.BANGUMI -> CoreProviders.BANGUMI KomfCoreProviders.BOOK_WALKER -> CoreProviders.BOOK_WALKER KomfCoreProviders.COMIC_VINE -> CoreProviders.COMIC_VINE + KomfCoreProviders.EHENTAI -> CoreProviders.EHENTAI KomfCoreProviders.HENTAG -> CoreProviders.HENTAG KomfCoreProviders.KODANSHA -> CoreProviders.KODANSHA KomfCoreProviders.MAL -> CoreProviders.MAL diff --git a/komf-core/src/commonMain/kotlin/snd/komf/providers/CoreProviders.kt b/komf-core/src/commonMain/kotlin/snd/komf/providers/CoreProviders.kt index 5e2f5bb4..cf109af9 100644 --- a/komf-core/src/commonMain/kotlin/snd/komf/providers/CoreProviders.kt +++ b/komf-core/src/commonMain/kotlin/snd/komf/providers/CoreProviders.kt @@ -5,6 +5,7 @@ enum class CoreProviders { BANGUMI, BOOK_WALKER, COMIC_VINE, + EHENTAI, HENTAG, KODANSHA, MAL, diff --git a/komf-core/src/commonMain/kotlin/snd/komf/providers/MetadataProvidersConfig.kt b/komf-core/src/commonMain/kotlin/snd/komf/providers/MetadataProvidersConfig.kt index 7c229e9a..26e62d4e 100644 --- a/komf-core/src/commonMain/kotlin/snd/komf/providers/MetadataProvidersConfig.kt +++ b/komf-core/src/commonMain/kotlin/snd/komf/providers/MetadataProvidersConfig.kt @@ -41,6 +41,7 @@ data class ProvidersConfig( val bangumi: ProviderConfig = ProviderConfig(), val comicVine: ProviderConfig = ProviderConfig(), val hentag: ProviderConfig = ProviderConfig(), + val ehentai: ProviderConfig = ProviderConfig(), val mangaBaka: MangaBakaConfig = MangaBakaConfig(), val webtoons: ProviderConfig = ProviderConfig(), ) diff --git a/komf-core/src/commonMain/kotlin/snd/komf/providers/ProvidersModule.kt b/komf-core/src/commonMain/kotlin/snd/komf/providers/ProvidersModule.kt index a92e54d4..c696b0a7 100644 --- a/komf-core/src/commonMain/kotlin/snd/komf/providers/ProvidersModule.kt +++ b/komf-core/src/commonMain/kotlin/snd/komf/providers/ProvidersModule.kt @@ -34,6 +34,9 @@ import snd.komf.providers.comicvine.ComicVineClient import snd.komf.providers.comicvine.ComicVineMetadataMapper import snd.komf.providers.comicvine.ComicVineMetadataProvider import snd.komf.providers.comicvine.ComicVineRateLimiter +import snd.komf.providers.ehentai.EHentaiClient +import snd.komf.providers.ehentai.EHentaiMetadataMapper +import snd.komf.providers.ehentai.EHentaiMetadataProvider import snd.komf.providers.hentag.HentagClient import snd.komf.providers.hentag.HentagMetadataMapper import snd.komf.providers.hentag.HentagMetadataProvider @@ -260,6 +263,20 @@ class ProvidersModule( } ) + /* Load limiting: 4-5 sequential requests usually okay before having to wait for ~5 seconds */ + private val ehentaiClient = EHentaiClient( + baseHttpClientJson.config { + install(HttpRequestRateLimiter) { + interval = 6.seconds + eventsPerInterval = 4 + allowBurst = true + } + install(HttpRequestRetry) { + defaultRetry() + } + } + ) + private val mangaBakaClient = MangaBakaApiClient( baseHttpClientJson.config { install(HttpRequestRateLimiter) { @@ -411,6 +428,12 @@ class ProvidersModule( defaultNameMatcher ), hentagPriority = config.hentag.priority, + ehentai = createEHentaiMetadataProvider( + config.ehentai, + ehentaiClient, + defaultNameMatcher + ), + ehentaiPriority = config.ehentai.priority, mangaBaka = createMangaBakaMetadataProvider( config = config.mangaBaka, datasource = when (config.mangaBaka.mode) { @@ -761,6 +784,27 @@ class ProvidersModule( ) } + private fun createEHentaiMetadataProvider( + config: ProviderConfig, + client: EHentaiClient, + defaultNameMatcher: NameSimilarityMatcher + ): EHentaiMetadataProvider? { + if (config.enabled.not()) return null + + val eHentaiMetadataMapper = EHentaiMetadataMapper( + metadataConfig = config.seriesMetadata, + authorRoles = config.authorRoles, + ) + + val ehentaiSimilarityMatcher: NameSimilarityMatcher = + config.nameMatchingMode?.let { nameSimilarityMatcher(it) } ?: defaultNameMatcher + return EHentaiMetadataProvider( + client, + eHentaiMetadataMapper, + ehentaiSimilarityMatcher, + config.seriesMetadata.thumbnail, + ) + } private fun createMangaBakaMetadataProvider( config: MangaBakaConfig, @@ -860,6 +904,9 @@ class ProvidersModule( private val hentag: HentagMetadataProvider?, private val hentagPriority: Int, + private val ehentai: EHentaiMetadataProvider?, + private val ehentaiPriority: Int, + private val mangaBaka: MangaBakaMetadataProvider?, private val mangaBakaPriority: Int, @@ -880,6 +927,7 @@ class ProvidersModule( bangumi?.let { it to bangumiPriority }, comicVine?.let { it to comicVinePriority }, hentag?.let { it to hentagPriority }, + ehentai?.let { it to ehentaiPriority }, mangaBaka?.let { it to mangaBakaPriority }, webtoons?.let { it to webtoonsPriority } ) @@ -901,6 +949,7 @@ class ProvidersModule( CoreProviders.BANGUMI -> bangumi CoreProviders.COMIC_VINE -> comicVine CoreProviders.HENTAG -> hentag + CoreProviders.EHENTAI -> ehentai CoreProviders.MANGA_BAKA -> mangaBaka CoreProviders.WEBTOONS -> webtoons } diff --git a/komf-core/src/commonMain/kotlin/snd/komf/providers/ehentai/EHentaiClient.kt b/komf-core/src/commonMain/kotlin/snd/komf/providers/ehentai/EHentaiClient.kt new file mode 100644 index 00000000..c3ec756d --- /dev/null +++ b/komf-core/src/commonMain/kotlin/snd/komf/providers/ehentai/EHentaiClient.kt @@ -0,0 +1,100 @@ +package snd.komf.providers.ehentai + +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.get +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.http.contentType +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.add +import kotlinx.serialization.json.addJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonArray +import snd.komf.model.Image + +class EHentaiClient( + private val ktor: HttpClient +) { + private val apiUrl: String = "https://api.e-hentai.org/api.php" + private val baseUrl: String = "https://e-hentai.org" + + private val json = Json { + ignoreUnknownKeys = true + isLenient = true + } + + suspend fun searchByGidList( + gidList: List> + ): EHentaiResponse { + if (gidList.isEmpty()) return EHentaiResponse() + + /* Load limiting: 25 entries per request */ + val chunks = gidList.chunked(25) + + return coroutineScope { + val responses = chunks.map { chunk -> + async { + val responseText = ktor.post(apiUrl) { + contentType(ContentType.Application.Json) + setBody(buildJsonObject { + put("method", "gdata") + putJsonArray("gidlist") { + chunk.forEach { (gid, token) -> + addJsonArray { + add(gid) + add(token) + } + } + } + put("namespace", 1) + }) + }.bodyAsText() + json.decodeFromString(responseText) + } + }.awaitAll() + + EHentaiResponse( + gmetadata = responses.flatMap { it.gmetadata }, + error = responses.firstNotNullOfOrNull { it.error } + ) + } + } + + suspend fun searchByTitle( + title: String + ): EHentaiResponse { + val htmlResponse: String = ktor.get("$baseUrl/") { + url { parameters.append("f_search", title) } + }.bodyAsText() + + val galleryRegex = """/g/(\d+)/([a-f0-9]+)""".toRegex() + val gidList = galleryRegex.findAll(htmlResponse) + .map { matchResult -> + val gid = matchResult.groupValues[1].toInt() + val token = matchResult.groupValues[2] + Pair(gid, token) + } + .distinct() + .toList() + + if (gidList.isEmpty()) { + return EHentaiResponse(error = "Empty gidList for search: $title") + } + + return searchByGidList(gidList) + } + + suspend fun getThumbnail(book: EHentaiBook): Image? { + return book.thumb?.ifBlank { null }?.let { url -> + val bytes = ktor.get(url).body() + Image(bytes) + } + } +} \ No newline at end of file diff --git a/komf-core/src/commonMain/kotlin/snd/komf/providers/ehentai/EHentaiGidParser.kt b/komf-core/src/commonMain/kotlin/snd/komf/providers/ehentai/EHentaiGidParser.kt new file mode 100644 index 00000000..4f81db32 --- /dev/null +++ b/komf-core/src/commonMain/kotlin/snd/komf/providers/ehentai/EHentaiGidParser.kt @@ -0,0 +1,14 @@ +package snd.komf.providers.ehentai + +import snd.komf.model.ProviderSeriesId + +fun ProviderSeriesId.parseEHentaiGid(): Pair { + val parts = this.value.split(";", limit = 2) + + require(parts.size == 2) { "Invalid E-Hentai ID format: ${this.value}" } + val gid = parts[0].toIntOrNull() + ?: throw IllegalArgumentException("Invalid GID (Not a number) in ID: ${this.value}") + val token = parts[1] + + return Pair(gid, token) +} \ No newline at end of file diff --git a/komf-core/src/commonMain/kotlin/snd/komf/providers/ehentai/EHentaiMetadataMapper.kt b/komf-core/src/commonMain/kotlin/snd/komf/providers/ehentai/EHentaiMetadataMapper.kt new file mode 100644 index 00000000..50abdedf --- /dev/null +++ b/komf-core/src/commonMain/kotlin/snd/komf/providers/ehentai/EHentaiMetadataMapper.kt @@ -0,0 +1,101 @@ +package snd.komf.providers.ehentai + +import kotlinx.datetime.TimeZone +import kotlinx.datetime.number +import kotlinx.datetime.toLocalDateTime +import snd.komf.model.Author +import snd.komf.model.AuthorRole +import snd.komf.model.Image +import snd.komf.model.ProviderSeriesId +import snd.komf.model.ProviderSeriesMetadata +import snd.komf.model.ReleaseDate +import snd.komf.model.SeriesMetadata +import snd.komf.model.SeriesSearchResult +import snd.komf.model.SeriesTitle +import snd.komf.model.WebLink +import snd.komf.providers.CoreProviders +import snd.komf.providers.MetadataConfigApplier +import snd.komf.providers.SeriesMetadataConfig + +class EHentaiMetadataMapper( + private val metadataConfig: SeriesMetadataConfig, + private val authorRoles: Collection, +) { + + fun toSeriesMetadata( + book: EHentaiBook, + thumbnail: Image? + ): ProviderSeriesMetadata { + val rawTags = book.tags ?: emptyList() + + val languageTag = rawTags.firstOrNull { it.startsWith("language:") } + val language = when (languageTag) { + "language:english" -> "en" + "language:chinese" -> "zh" + "language:japanese" -> "ja" + "language:korean" -> "ko" + "language:russian" -> "ru" + "language:french" -> "fr" + "language:spanish" -> "es" + else -> null + } + + val artists = rawTags.filter { it.startsWith("artist:") }.map { it.removePrefix("artist:") } + val groups = rawTags.filter { it.startsWith("group:") }.map { it.removePrefix("group:") } + val authors = (artists + groups).flatMap { authorName -> + authorRoles.map { role -> Author(authorName, role) } + } + + val finalTags = rawTags + .asSequence() + .filterNot { it.startsWith("language:") } + .filterNot { it.startsWith("artist:") } + .filterNot { it.startsWith("group:") } + .map { tag -> if (tag.contains(":")) tag.substringAfter(":") else tag } + .distinct() + .toList() + + val titles = listOfNotNull( + book.title?.let { SeriesTitle(name = it, type = null, language = null) }, + book.titleJpn?.ifBlank { null }?.let { SeriesTitle(name = it, type = null, language = "ja") } + ) + + val token = book.token ?: "unknown" + val link = WebLink("e-hentai", "https://e-hentai.org/g/${book.gid}/$token") + + val metadata = SeriesMetadata( + titles = titles, + language = language, + releaseDate = book.posted?.toLocalDateTime(TimeZone.UTC)?.let { date -> + ReleaseDate( + year = date.year, + month = date.month.number, + day = date.day + ) + }, + tags = finalTags, + authors = authors, + links = listOf(link), + thumbnail = thumbnail + ) + + return MetadataConfigApplier.apply( + ProviderSeriesMetadata( + id = ProviderSeriesId("${book.gid};$token"), + metadata = metadata + ), + metadataConfig + ) + } + + fun toSeriesSearchResult(result: EHentaiBook): SeriesSearchResult { + val token = result.token ?: "unknown" + return SeriesSearchResult( + resultId = "${result.gid};$token", + url = "https://e-hentai.org/g/${result.gid}/$token", + imageUrl = result.thumb, + title = result.titleJpn?.ifBlank { null } ?: result.title ?: "Unknown Title", + provider = CoreProviders.EHENTAI + ) + } +} \ No newline at end of file diff --git a/komf-core/src/commonMain/kotlin/snd/komf/providers/ehentai/EHentaiMetadataProvider.kt b/komf-core/src/commonMain/kotlin/snd/komf/providers/ehentai/EHentaiMetadataProvider.kt new file mode 100644 index 00000000..8c9921fa --- /dev/null +++ b/komf-core/src/commonMain/kotlin/snd/komf/providers/ehentai/EHentaiMetadataProvider.kt @@ -0,0 +1,89 @@ +package snd.komf.providers.ehentai + +import io.github.reactivecircus.cache4k.Cache +import snd.komf.model.Image +import snd.komf.model.MatchQuery +import snd.komf.model.ProviderBookId +import snd.komf.model.ProviderBookMetadata +import snd.komf.model.ProviderSeriesId +import snd.komf.model.ProviderSeriesMetadata +import snd.komf.model.SeriesSearchResult +import snd.komf.providers.CoreProviders +import snd.komf.providers.MetadataProvider +import snd.komf.util.NameSimilarityMatcher +import kotlin.time.Duration.Companion.minutes + +class EHentaiMetadataProvider( + private val eHentaiClient: EHentaiClient, + private val metadataMapper: EHentaiMetadataMapper, + private val nameMatcher: NameSimilarityMatcher, + private val fetchSeriesCovers: Boolean, +) : MetadataProvider { + + private val cache = Cache.Builder() + .expireAfterWrite(5.minutes) + .build() + + override fun providerName() = CoreProviders.EHENTAI + + private suspend fun getBookOrThrow(seriesId: ProviderSeriesId): EHentaiBook { + return cache.get(seriesId) { + val response = eHentaiClient.searchByGidList(listOf(seriesId.parseEHentaiGid())) + val book = response.gmetadata.firstOrNull() ?: throw RuntimeException("Gallery not found") + if (book.error != null) { + throw RuntimeException("E-Hentai API Error for $seriesId: ${book.error}") + } + book + } + } + + override suspend fun getSeriesMetadata(seriesId: ProviderSeriesId): ProviderSeriesMetadata { + val book = getBookOrThrow(seriesId) + val thumbnail = if (fetchSeriesCovers) eHentaiClient.getThumbnail(book) else null + return metadataMapper.toSeriesMetadata(book, thumbnail) + } + + override suspend fun getSeriesCover(seriesId: ProviderSeriesId): Image? { + val book = getBookOrThrow(seriesId) + return eHentaiClient.getThumbnail(book) + } + + override suspend fun getBookMetadata(seriesId: ProviderSeriesId, bookId: ProviderBookId): ProviderBookMetadata { + throw UnsupportedOperationException() + } + + override suspend fun searchSeries(seriesName: String, limit: Int): Collection { + return eHentaiClient.searchByTitle(seriesName).gmetadata + .filter { it.error == null && it.token != null } + .map { result -> + metadataMapper.toSeriesSearchResult(result) + .also { cache.put(ProviderSeriesId(it.resultId), result) } + } + } + + override suspend fun matchSeriesMetadata(matchQuery: MatchQuery): ProviderSeriesMetadata? { + val seriesName = matchQuery.seriesName + val searchResults = eHentaiClient.searchByTitle(seriesName.take(400)).gmetadata + .filter { it.error == null && it.token != null && it.title != null } + + return searchResults + .firstOrNull { matchesName(seriesName, it.title!!) } + ?.let { book -> + val cover = if (fetchSeriesCovers) eHentaiClient.getThumbnail(book) else null + metadataMapper.toSeriesMetadata(book, cover).also { cache.put(it.id, book) } + } + } + + private fun matchesName(name: String, nameToMatch: String): Boolean { + return nameMatcher.matches(name, nameToMatch) || + nameMatcher.matches( + removeParentheses(name), + removeParentheses(nameToMatch) + ) + } + + private fun removeParentheses(name: String): String { + val strippedName = name.replace("[(\\[{]([^)\\]}]+)[)\\]}]".toRegex(), "").trim() + return strippedName.ifBlank { name } + } +} \ No newline at end of file diff --git a/komf-core/src/commonMain/kotlin/snd/komf/providers/ehentai/EHentaiResponse.kt b/komf-core/src/commonMain/kotlin/snd/komf/providers/ehentai/EHentaiResponse.kt new file mode 100644 index 00000000..34633099 --- /dev/null +++ b/komf-core/src/commonMain/kotlin/snd/komf/providers/ehentai/EHentaiResponse.kt @@ -0,0 +1,78 @@ +package snd.komf.providers.ehentai + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlin.time.Instant + +@Serializable +data class EHentaiResponse( + val gmetadata: List = emptyList(), + val gid: Int? = null, + val error: String? = null +) + +@Serializable +data class EHentaiBook( + val gid: Int, + val token: String? = null, + val title: String? = null, + @SerialName("title_jpn") + val titleJpn: String? = null, + val category: String? = null, + val thumb: String? = null, + val uploader: String? = null, + @Serializable(with = InstantEpochSecondsSerializer::class) + val posted: Instant? = null, + @SerialName("filecount") + val fileCount: String? = null, + @SerialName("filesize") + val fileSize: Long? = null, + val expunged: Boolean? = null, + val rating: String? = null, + @SerialName("torrentcount") + val torrentCount: String? = null, + val torrents: List? = null, + val tags: List? = null, + @SerialName("parent_gid") + val parentGid: String? = null, + @SerialName("parent_key") + val parentKey: String? = null, + @SerialName("current_gid") + val currentGid: String? = null, + @SerialName("current_key") + val currentKey: String? = null, + @SerialName("first_gid") + val firstGid: String? = null, + @SerialName("first_key") + val firstKey: String? = null, + val error: String? = null +) + +@Serializable +data class EHentaiTorrent( + val hash: String, + @Serializable(with = InstantEpochSecondsSerializer::class) + val added: Instant, + val name: String, + @SerialName("tsize") + val tSize: String, + @SerialName("fsize") + val fSize: String +) + +object InstantEpochSecondsSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("kotlinx.datetime.Instant", PrimitiveKind.STRING) + + override fun deserialize(decoder: Decoder): Instant = + Instant.fromEpochSeconds(decoder.decodeString().toLong()) + + override fun serialize(encoder: Encoder, value: Instant) = + encoder.encodeString(value.epochSeconds.toString()) +} \ No newline at end of file From 622fcd62c621137523d8eb75efbecc451cd301c0 Mon Sep 17 00:00:00 2001 From: wiseCirno <78086755+wiseCirno@users.noreply.github.com> Date: Sat, 28 Feb 2026 20:31:35 +0800 Subject: [PATCH 2/3] refactor: extract EHentaiResponse and move models to model package --- .../kotlin/snd/komf/providers/ehentai/EHentaiClient.kt | 2 ++ .../komf/providers/ehentai/EHentaiMetadataMapper.kt | 1 + .../komf/providers/ehentai/EHentaiMetadataProvider.kt | 1 + .../{EHentaiResponse.kt => model/EHentaiBook.kt} | 2 +- .../komf/providers/ehentai/model/EHentaiResponse.kt | 10 ++++++++++ 5 files changed, 15 insertions(+), 1 deletion(-) rename komf-core/src/commonMain/kotlin/snd/komf/providers/ehentai/{EHentaiResponse.kt => model/EHentaiBook.kt} (98%) create mode 100644 komf-core/src/commonMain/kotlin/snd/komf/providers/ehentai/model/EHentaiResponse.kt diff --git a/komf-core/src/commonMain/kotlin/snd/komf/providers/ehentai/EHentaiClient.kt b/komf-core/src/commonMain/kotlin/snd/komf/providers/ehentai/EHentaiClient.kt index c3ec756d..8f6ef58e 100644 --- a/komf-core/src/commonMain/kotlin/snd/komf/providers/ehentai/EHentaiClient.kt +++ b/komf-core/src/commonMain/kotlin/snd/komf/providers/ehentai/EHentaiClient.kt @@ -18,6 +18,8 @@ import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put import kotlinx.serialization.json.putJsonArray import snd.komf.model.Image +import snd.komf.providers.ehentai.model.EHentaiBook +import snd.komf.providers.ehentai.model.EHentaiResponse class EHentaiClient( private val ktor: HttpClient diff --git a/komf-core/src/commonMain/kotlin/snd/komf/providers/ehentai/EHentaiMetadataMapper.kt b/komf-core/src/commonMain/kotlin/snd/komf/providers/ehentai/EHentaiMetadataMapper.kt index 50abdedf..c49c68a1 100644 --- a/komf-core/src/commonMain/kotlin/snd/komf/providers/ehentai/EHentaiMetadataMapper.kt +++ b/komf-core/src/commonMain/kotlin/snd/komf/providers/ehentai/EHentaiMetadataMapper.kt @@ -16,6 +16,7 @@ import snd.komf.model.WebLink import snd.komf.providers.CoreProviders import snd.komf.providers.MetadataConfigApplier import snd.komf.providers.SeriesMetadataConfig +import snd.komf.providers.ehentai.model.EHentaiBook class EHentaiMetadataMapper( private val metadataConfig: SeriesMetadataConfig, diff --git a/komf-core/src/commonMain/kotlin/snd/komf/providers/ehentai/EHentaiMetadataProvider.kt b/komf-core/src/commonMain/kotlin/snd/komf/providers/ehentai/EHentaiMetadataProvider.kt index 8c9921fa..d933dcd9 100644 --- a/komf-core/src/commonMain/kotlin/snd/komf/providers/ehentai/EHentaiMetadataProvider.kt +++ b/komf-core/src/commonMain/kotlin/snd/komf/providers/ehentai/EHentaiMetadataProvider.kt @@ -10,6 +10,7 @@ import snd.komf.model.ProviderSeriesMetadata import snd.komf.model.SeriesSearchResult import snd.komf.providers.CoreProviders import snd.komf.providers.MetadataProvider +import snd.komf.providers.ehentai.model.EHentaiBook import snd.komf.util.NameSimilarityMatcher import kotlin.time.Duration.Companion.minutes diff --git a/komf-core/src/commonMain/kotlin/snd/komf/providers/ehentai/EHentaiResponse.kt b/komf-core/src/commonMain/kotlin/snd/komf/providers/ehentai/model/EHentaiBook.kt similarity index 98% rename from komf-core/src/commonMain/kotlin/snd/komf/providers/ehentai/EHentaiResponse.kt rename to komf-core/src/commonMain/kotlin/snd/komf/providers/ehentai/model/EHentaiBook.kt index 34633099..8c44a435 100644 --- a/komf-core/src/commonMain/kotlin/snd/komf/providers/ehentai/EHentaiResponse.kt +++ b/komf-core/src/commonMain/kotlin/snd/komf/providers/ehentai/model/EHentaiBook.kt @@ -1,4 +1,4 @@ -package snd.komf.providers.ehentai +package snd.komf.providers.ehentai.model import kotlinx.serialization.KSerializer import kotlinx.serialization.SerialName diff --git a/komf-core/src/commonMain/kotlin/snd/komf/providers/ehentai/model/EHentaiResponse.kt b/komf-core/src/commonMain/kotlin/snd/komf/providers/ehentai/model/EHentaiResponse.kt new file mode 100644 index 00000000..4f589424 --- /dev/null +++ b/komf-core/src/commonMain/kotlin/snd/komf/providers/ehentai/model/EHentaiResponse.kt @@ -0,0 +1,10 @@ +package snd.komf.providers.ehentai.model + +import kotlinx.serialization.Serializable + +@Serializable +data class EHentaiResponse( + val gmetadata: List = emptyList(), + val gid: Int? = null, + val error: String? = null +) \ No newline at end of file From 3e612e9cecd719ab52468b7ead04b3c885873f66 Mon Sep 17 00:00:00 2001 From: wiseCirno <78086755+wiseCirno@users.noreply.github.com> Date: Wed, 4 Mar 2026 23:51:23 +0800 Subject: [PATCH 3/3] feat: enhance series search and metadata identification - Add new config option "preferredLanguages" - Improve manual search accuracy and image loading speed - Enhance auto-identification to match more series - Add age classification metadata, rating tags, and language info - Refactor: improve code formatting --- README.md | 6 + .../kotlin/snd/komf/api/config/KomfConfig.kt | 17 ++- .../api/config/KomfConfigUpdateRequest.kt | 17 ++- .../komf/app/api/mappers/AppConfigMapper.kt | 18 ++- .../app/api/mappers/AppConfigUpdateMapper.kt | 28 +++- .../snd/komf/app/config/ConfigLoader.kt | 1 + .../komf/providers/MetadataProvidersConfig.kt | 17 ++- .../snd/komf/providers/ProvidersModule.kt | 28 ++-- .../komf/providers/ehentai/EHentaiClient.kt | 69 ++++++---- .../providers/ehentai/EHentaiGidParser.kt | 14 -- .../ehentai/EHentaiMetadataMapper.kt | 129 ++++++++++++------ .../ehentai/EHentaiMetadataProvider.kt | 64 +++++---- .../komf/providers/ehentai/EHentaiParser.kt | 77 +++++++++++ .../providers/ehentai/EHentaiTagMapper.kt | 113 +++++++++++++++ .../providers/ehentai/model/EHentaiBook.kt | 58 ++++++-- .../ehentai/model/EHentaiParsedTitle.kt | 18 +++ 16 files changed, 541 insertions(+), 133 deletions(-) delete mode 100644 komf-core/src/commonMain/kotlin/snd/komf/providers/ehentai/EHentaiGidParser.kt create mode 100644 komf-core/src/commonMain/kotlin/snd/komf/providers/ehentai/EHentaiParser.kt create mode 100644 komf-core/src/commonMain/kotlin/snd/komf/providers/ehentai/EHentaiTagMapper.kt create mode 100644 komf-core/src/commonMain/kotlin/snd/komf/providers/ehentai/model/EHentaiParsedTitle.kt diff --git a/README.md b/README.md index b78985dc..2b916a5c 100644 --- a/README.md +++ b/README.md @@ -235,6 +235,12 @@ metadataProviders: # Datasource used for metadata retrieval. DATABASE mode will only work if MangaBaka database is installed # API or DATABASE mode: API + eHentai: + priority: 150 + enabled: false + preferredLanguages: + - "en" + - "ja" server: port: 8085 # or env:KOMF_SERVER_PORT diff --git a/komf-api-models/src/commonMain/kotlin/snd/komf/api/config/KomfConfig.kt b/komf-api-models/src/commonMain/kotlin/snd/komf/api/config/KomfConfig.kt index 2de41ca8..106c2e9e 100644 --- a/komf-api-models/src/commonMain/kotlin/snd/komf/api/config/KomfConfig.kt +++ b/komf-api-models/src/commonMain/kotlin/snd/komf/api/config/KomfConfig.kt @@ -118,7 +118,7 @@ data class ProvidersConfigDto( val bangumi: ProviderConfigDto, val comicVine: ProviderConfigDto, val hentag: ProviderConfigDto, - val ehentai: ProviderConfigDto, + val eHentai: EHentaiConfigDto, val mangaBaka: MangaBakaConfigDto, val webtoons: ProviderConfigDto, ) @@ -148,6 +148,21 @@ data class ProviderConfigDto( override val artistRoles: Collection, ) : ProviderConf +@Serializable +data class EHentaiConfigDto( + override val priority: Int, + override val enabled: Boolean, + override val seriesMetadata: SeriesMetadataConfigDto, + override val bookMetadata: BookMetadataConfigDto, + override val nameMatchingMode: KomfNameMatchingMode?, + override val mediaType: KomfMediaType, + + override val authorRoles: Collection, + override val artistRoles: Collection, + + val preferredLanguages: List, +) : ProviderConf + @Serializable data class AniListConfigDto( override val priority: Int, diff --git a/komf-api-models/src/commonMain/kotlin/snd/komf/api/config/KomfConfigUpdateRequest.kt b/komf-api-models/src/commonMain/kotlin/snd/komf/api/config/KomfConfigUpdateRequest.kt index 1e216384..0ac439da 100644 --- a/komf-api-models/src/commonMain/kotlin/snd/komf/api/config/KomfConfigUpdateRequest.kt +++ b/komf-api-models/src/commonMain/kotlin/snd/komf/api/config/KomfConfigUpdateRequest.kt @@ -109,7 +109,7 @@ data class ProvidersConfigUpdateRequest( val bangumi: PatchValue = PatchValue.Unset, val comicVine: PatchValue = PatchValue.Unset, val hentag: PatchValue = PatchValue.Unset, - val ehentai: PatchValue = PatchValue.Unset, + val eHentai: PatchValue = PatchValue.Unset, val mangaBaka: PatchValue = PatchValue.Unset, val webtoons: PatchValue = PatchValue.Unset, ) @@ -127,6 +127,21 @@ class ProviderConfigUpdateRequest( val artistRoles: PatchValue> = PatchValue.Unset, ) +@Serializable +class EHentaiConfigUpdateRequest( + val priority: PatchValue = PatchValue.Unset, + val enabled: PatchValue = PatchValue.Unset, + val seriesMetadata: PatchValue = PatchValue.Unset, + val bookMetadata: PatchValue = PatchValue.Unset, + val nameMatchingMode: PatchValue = PatchValue.Unset, + val mediaType: PatchValue = PatchValue.Unset, + + val authorRoles: PatchValue> = PatchValue.Unset, + val artistRoles: PatchValue> = PatchValue.Unset, + + val preferredLanguages: PatchValue> = PatchValue.Unset, +) + @Serializable class AniListConfigUpdateRequest( val priority: PatchValue = PatchValue.Unset, diff --git a/komf-app/src/main/kotlin/snd/komf/app/api/mappers/AppConfigMapper.kt b/komf-app/src/main/kotlin/snd/komf/app/api/mappers/AppConfigMapper.kt index d036e13c..ba97c109 100644 --- a/komf-app/src/main/kotlin/snd/komf/app/api/mappers/AppConfigMapper.kt +++ b/komf-app/src/main/kotlin/snd/komf/app/api/mappers/AppConfigMapper.kt @@ -6,6 +6,7 @@ import snd.komf.api.config.AniListConfigDto import snd.komf.api.config.AppriseConfigDto import snd.komf.api.config.BookMetadataConfigDto import snd.komf.api.config.DiscordConfigDto +import snd.komf.api.config.EHentaiConfigDto import snd.komf.api.config.EventListenerConfigDto import snd.komf.api.config.KavitaConfigDto import snd.komf.api.config.KomfConfig @@ -34,6 +35,7 @@ import snd.komf.notifications.apprise.AppriseConfig import snd.komf.notifications.discord.DiscordConfig import snd.komf.providers.AniListConfig import snd.komf.providers.BookMetadataConfig +import snd.komf.providers.EHentaiConfig import snd.komf.providers.MangaBakaConfig import snd.komf.providers.MangaDexConfig import snd.komf.providers.MetadataProvidersConfig @@ -174,7 +176,7 @@ class AppConfigMapper { bangumi = toDto(config.bangumi), comicVine = toDto(config.comicVine), hentag = toDto(config.hentag), - ehentai = toDto(config.ehentai), + eHentai = toDto(config.eHentai), mangaBaka = toDto(config.mangaBaka), webtoons = toDto(config.webtoons), ) @@ -193,6 +195,20 @@ class AppConfigMapper { ) } + private fun toDto(config: EHentaiConfig): EHentaiConfigDto { + return EHentaiConfigDto( + nameMatchingMode = config.nameMatchingMode?.fromNameMatchingMode(), + priority = config.priority, + enabled = config.enabled, + mediaType = config.mediaType.fromMediaType(), + authorRoles = config.authorRoles.map { it.fromAuthorRole() }, + artistRoles = config.artistRoles.map { it.fromAuthorRole() }, + seriesMetadata = toDto(config.seriesMetadata), + bookMetadata = toDto(config.bookMetadata), + preferredLanguages = config.preferredLanguages, + ) + } + private fun toDto(config: AniListConfig): AniListConfigDto { return AniListConfigDto( nameMatchingMode = config.nameMatchingMode?.fromNameMatchingMode(), diff --git a/komf-app/src/main/kotlin/snd/komf/app/api/mappers/AppConfigUpdateMapper.kt b/komf-app/src/main/kotlin/snd/komf/app/api/mappers/AppConfigUpdateMapper.kt index d0763f34..3d3dbfae 100644 --- a/komf-app/src/main/kotlin/snd/komf/app/api/mappers/AppConfigUpdateMapper.kt +++ b/komf-app/src/main/kotlin/snd/komf/app/api/mappers/AppConfigUpdateMapper.kt @@ -6,6 +6,7 @@ import snd.komf.api.config.AniListConfigUpdateRequest import snd.komf.api.config.AppriseConfigUpdateRequest import snd.komf.api.config.BookMetadataConfigUpdateRequest import snd.komf.api.config.DiscordConfigUpdateRequest +import snd.komf.api.config.EHentaiConfigUpdateRequest import snd.komf.api.config.EventListenerConfigUpdateRequest import snd.komf.api.config.KavitaConfigUpdateRequest import snd.komf.api.config.KomfConfigUpdateRequest @@ -31,6 +32,7 @@ import snd.komf.notifications.apprise.AppriseConfig import snd.komf.notifications.discord.DiscordConfig import snd.komf.providers.AniListConfig import snd.komf.providers.BookMetadataConfig +import snd.komf.providers.EHentaiConfig import snd.komf.providers.MangaBakaConfig import snd.komf.providers.MangaDexConfig import snd.komf.providers.MetadataProvidersConfig @@ -153,8 +155,8 @@ class AppConfigUpdateMapper { ?.let { providerConfig(config.comicVine, it) } ?: config.comicVine, hentag = patch.hentag.getOrNull() ?.let { providerConfig(config.hentag, it) } ?: config.hentag, - ehentai = patch.ehentai.getOrNull() - ?.let { providerConfig(config.ehentai, it) } ?: config.ehentai, + eHentai = patch.eHentai.getOrNull() + ?.let { eHentaiProviderConfig(config.eHentai, it) } ?: config.eHentai, mangaBaka = patch.mangaBaka.getOrNull() ?.let { mangaBakaProviderConfig(config.mangaBaka, it) } ?: config.mangaBaka, webtoons = patch.webtoons.getOrNull() @@ -188,6 +190,28 @@ class AppConfigUpdateMapper { ) } + private fun eHentaiProviderConfig(config: EHentaiConfig, patch: EHentaiConfigUpdateRequest): EHentaiConfig { + return config.copy( + priority = patch.priority.getOrNull() ?: config.priority, + enabled = patch.enabled.getOrNull() ?: config.enabled, + mediaType = patch.mediaType.getOrNull()?.toMediaType() ?: config.mediaType, + authorRoles = patch.authorRoles.getOrNull()?.map { it.toAuthorRole() } ?: config.authorRoles, + artistRoles = patch.artistRoles.getOrNull()?.map { it.toAuthorRole() } ?: config.artistRoles, + seriesMetadata = patch.seriesMetadata.getOrNull() + ?.let { seriesMetadataConfig(config.seriesMetadata, it) } + ?: config.seriesMetadata, + bookMetadata = patch.bookMetadata.getOrNull() + ?.let { bookMetadataConfig(config.bookMetadata, it) } + ?: config.bookMetadata, + nameMatchingMode = when (val mode = patch.nameMatchingMode) { + PatchValue.None -> null + is PatchValue.Some -> mode.value.toNameMatchingMode() + PatchValue.Unset -> config.nameMatchingMode + }, + preferredLanguages = patch.preferredLanguages.getOrNull() ?: config.preferredLanguages + ) + } + private fun aniListProviderConfig(config: AniListConfig, patch: AniListConfigUpdateRequest): AniListConfig { return config.copy( priority = patch.priority.getOrNull() ?: config.priority, diff --git a/komf-app/src/main/kotlin/snd/komf/app/config/ConfigLoader.kt b/komf-app/src/main/kotlin/snd/komf/app/config/ConfigLoader.kt index d8954a97..9a5c8d46 100644 --- a/komf-app/src/main/kotlin/snd/komf/app/config/ConfigLoader.kt +++ b/komf-app/src/main/kotlin/snd/komf/app/config/ConfigLoader.kt @@ -126,6 +126,7 @@ class ConfigLoader(private val yaml: Yaml) { config.metadataProviders.defaultProviders.bangumi.enabled.not() && config.metadataProviders.defaultProviders.comicVine.enabled.not() && config.metadataProviders.defaultProviders.hentag.enabled.not() && + config.metadataProviders.defaultProviders.eHentai.enabled.not() && config.metadataProviders.defaultProviders.mangaBaka.enabled.not() && config.metadataProviders.defaultProviders.webtoons.enabled.not() && config.metadataProviders.libraryProviders.isEmpty() diff --git a/komf-core/src/commonMain/kotlin/snd/komf/providers/MetadataProvidersConfig.kt b/komf-core/src/commonMain/kotlin/snd/komf/providers/MetadataProvidersConfig.kt index 26e62d4e..bde0cee3 100644 --- a/komf-core/src/commonMain/kotlin/snd/komf/providers/MetadataProvidersConfig.kt +++ b/komf-core/src/commonMain/kotlin/snd/komf/providers/MetadataProvidersConfig.kt @@ -41,7 +41,7 @@ data class ProvidersConfig( val bangumi: ProviderConfig = ProviderConfig(), val comicVine: ProviderConfig = ProviderConfig(), val hentag: ProviderConfig = ProviderConfig(), - val ehentai: ProviderConfig = ProviderConfig(), + val eHentai: EHentaiConfig = EHentaiConfig(), val mangaBaka: MangaBakaConfig = MangaBakaConfig(), val webtoons: ProviderConfig = ProviderConfig(), ) @@ -59,6 +59,21 @@ data class ProviderConfig( val artistRoles: Collection = listOf(PENCILLER, INKER, COLORIST, LETTERER, COVER), ) +@Serializable +data class EHentaiConfig( + val priority: Int = 10, + val enabled: Boolean = false, + val seriesMetadata: SeriesMetadataConfig = SeriesMetadataConfig(), + val bookMetadata: BookMetadataConfig = BookMetadataConfig(), + val nameMatchingMode: NameMatchingMode? = null, + val mediaType: MediaType = MANGA, + + val preferredLanguages: List = listOf("en", "ja"), + + val authorRoles: Collection = listOf(WRITER), + val artistRoles: Collection = listOf(PENCILLER, INKER, COLORIST, LETTERER, COVER), +) + @Serializable data class MangaBakaConfig( val priority: Int = 10, diff --git a/komf-core/src/commonMain/kotlin/snd/komf/providers/ProvidersModule.kt b/komf-core/src/commonMain/kotlin/snd/komf/providers/ProvidersModule.kt index c696b0a7..f444dbc5 100644 --- a/komf-core/src/commonMain/kotlin/snd/komf/providers/ProvidersModule.kt +++ b/komf-core/src/commonMain/kotlin/snd/komf/providers/ProvidersModule.kt @@ -264,7 +264,7 @@ class ProvidersModule( ) /* Load limiting: 4-5 sequential requests usually okay before having to wait for ~5 seconds */ - private val ehentaiClient = EHentaiClient( + private val eHentaiClient = EHentaiClient( baseHttpClientJson.config { install(HttpRequestRateLimiter) { interval = 6.seconds @@ -274,6 +274,11 @@ class ProvidersModule( install(HttpRequestRetry) { defaultRetry() } + }, + baseHttpClientJson.config { + install(HttpRequestRetry) { + defaultRetry() + } } ) @@ -428,12 +433,12 @@ class ProvidersModule( defaultNameMatcher ), hentagPriority = config.hentag.priority, - ehentai = createEHentaiMetadataProvider( - config.ehentai, - ehentaiClient, + eHentai = createEHentaiMetadataProvider( + config.eHentai, + eHentaiClient, defaultNameMatcher ), - ehentaiPriority = config.ehentai.priority, + eHentaiPriority = config.eHentai.priority, mangaBaka = createMangaBakaMetadataProvider( config = config.mangaBaka, datasource = when (config.mangaBaka.mode) { @@ -785,15 +790,16 @@ class ProvidersModule( } private fun createEHentaiMetadataProvider( - config: ProviderConfig, + config: EHentaiConfig, client: EHentaiClient, - defaultNameMatcher: NameSimilarityMatcher + defaultNameMatcher: NameSimilarityMatcher, ): EHentaiMetadataProvider? { if (config.enabled.not()) return null val eHentaiMetadataMapper = EHentaiMetadataMapper( metadataConfig = config.seriesMetadata, authorRoles = config.authorRoles, + preferredLanguages = config.preferredLanguages, ) val ehentaiSimilarityMatcher: NameSimilarityMatcher = @@ -904,8 +910,8 @@ class ProvidersModule( private val hentag: HentagMetadataProvider?, private val hentagPriority: Int, - private val ehentai: EHentaiMetadataProvider?, - private val ehentaiPriority: Int, + private val eHentai: EHentaiMetadataProvider?, + private val eHentaiPriority: Int, private val mangaBaka: MangaBakaMetadataProvider?, private val mangaBakaPriority: Int, @@ -927,7 +933,7 @@ class ProvidersModule( bangumi?.let { it to bangumiPriority }, comicVine?.let { it to comicVinePriority }, hentag?.let { it to hentagPriority }, - ehentai?.let { it to ehentaiPriority }, + eHentai?.let { it to eHentaiPriority }, mangaBaka?.let { it to mangaBakaPriority }, webtoons?.let { it to webtoonsPriority } ) @@ -949,7 +955,7 @@ class ProvidersModule( CoreProviders.BANGUMI -> bangumi CoreProviders.COMIC_VINE -> comicVine CoreProviders.HENTAG -> hentag - CoreProviders.EHENTAI -> ehentai + CoreProviders.EHENTAI -> eHentai CoreProviders.MANGA_BAKA -> mangaBaka CoreProviders.WEBTOONS -> webtoons } diff --git a/komf-core/src/commonMain/kotlin/snd/komf/providers/ehentai/EHentaiClient.kt b/komf-core/src/commonMain/kotlin/snd/komf/providers/ehentai/EHentaiClient.kt index 8f6ef58e..efb2e542 100644 --- a/komf-core/src/commonMain/kotlin/snd/komf/providers/ehentai/EHentaiClient.kt +++ b/komf-core/src/commonMain/kotlin/snd/komf/providers/ehentai/EHentaiClient.kt @@ -22,14 +22,20 @@ import snd.komf.providers.ehentai.model.EHentaiBook import snd.komf.providers.ehentai.model.EHentaiResponse class EHentaiClient( - private val ktor: HttpClient + private val apiClient: HttpClient, + private val imgClient: HttpClient ) { - private val apiUrl: String = "https://api.e-hentai.org/api.php" - private val baseUrl: String = "https://e-hentai.org" + companion object { + private const val API_URL = "https://api.e-hentai.org/api.php" + private const val BASE_URL = "https://e-hentai.org" + private const val MAX_PAGES = 3 + private val GALLERY_REGEX = """/g/(\d+)/([a-f0-9]+)""".toRegex() + private val NEXT_URL_REGEX = """var\s+nexturl="([^"]+)"""".toRegex() - private val json = Json { - ignoreUnknownKeys = true - isLenient = true + private val JSON_PARSER = Json { + ignoreUnknownKeys = true + isLenient = true + } } suspend fun searchByGidList( @@ -43,7 +49,7 @@ class EHentaiClient( return coroutineScope { val responses = chunks.map { chunk -> async { - val responseText = ktor.post(apiUrl) { + val responseText = apiClient.post(API_URL) { contentType(ContentType.Application.Json) setBody(buildJsonObject { put("method", "gdata") @@ -58,7 +64,7 @@ class EHentaiClient( put("namespace", 1) }) }.bodyAsText() - json.decodeFromString(responseText) + JSON_PARSER.decodeFromString(responseText) } }.awaitAll() @@ -69,33 +75,44 @@ class EHentaiClient( } } - suspend fun searchByTitle( - title: String - ): EHentaiResponse { - val htmlResponse: String = ktor.get("$baseUrl/") { - url { parameters.append("f_search", title) } - }.bodyAsText() + suspend fun searchByTitle(title: String): EHentaiResponse { + val gidList = mutableListOf>() + var currentUrl: String? = null - val galleryRegex = """/g/(\d+)/([a-f0-9]+)""".toRegex() - val gidList = galleryRegex.findAll(htmlResponse) - .map { matchResult -> - val gid = matchResult.groupValues[1].toInt() - val token = matchResult.groupValues[2] - Pair(gid, token) + var currentPage = 0 + while (currentPage < MAX_PAGES) { + val htmlResponse = if (currentUrl == null) { + apiClient.get(BASE_URL) { + url { parameters.append("f_search", title) } + }.bodyAsText() + } else { + apiClient.get(currentUrl).bodyAsText() } - .distinct() - .toList() - if (gidList.isEmpty()) { - return EHentaiResponse(error = "Empty gidList for search: $title") + val gidInPage = GALLERY_REGEX.findAll(htmlResponse) + .map { it.groupValues[1].toInt() to it.groupValues[2] } + .toList() + + if (gidInPage.isEmpty()) break + gidList.addAll(gidInPage) + + currentUrl = NEXT_URL_REGEX.find(htmlResponse) + ?.groupValues?.get(1) + ?.replace("&", "&") ?: break + + currentPage++ } - return searchByGidList(gidList) + val distinctGidList = gidList.distinct() + return when { + distinctGidList.isNotEmpty() -> searchByGidList(distinctGidList) + else -> EHentaiResponse(error = "Empty gidList for search: $title") + } } suspend fun getThumbnail(book: EHentaiBook): Image? { return book.thumb?.ifBlank { null }?.let { url -> - val bytes = ktor.get(url).body() + val bytes = imgClient.get(url).body() Image(bytes) } } diff --git a/komf-core/src/commonMain/kotlin/snd/komf/providers/ehentai/EHentaiGidParser.kt b/komf-core/src/commonMain/kotlin/snd/komf/providers/ehentai/EHentaiGidParser.kt deleted file mode 100644 index 4f81db32..00000000 --- a/komf-core/src/commonMain/kotlin/snd/komf/providers/ehentai/EHentaiGidParser.kt +++ /dev/null @@ -1,14 +0,0 @@ -package snd.komf.providers.ehentai - -import snd.komf.model.ProviderSeriesId - -fun ProviderSeriesId.parseEHentaiGid(): Pair { - val parts = this.value.split(";", limit = 2) - - require(parts.size == 2) { "Invalid E-Hentai ID format: ${this.value}" } - val gid = parts[0].toIntOrNull() - ?: throw IllegalArgumentException("Invalid GID (Not a number) in ID: ${this.value}") - val token = parts[1] - - return Pair(gid, token) -} \ No newline at end of file diff --git a/komf-core/src/commonMain/kotlin/snd/komf/providers/ehentai/EHentaiMetadataMapper.kt b/komf-core/src/commonMain/kotlin/snd/komf/providers/ehentai/EHentaiMetadataMapper.kt index c49c68a1..eda87a38 100644 --- a/komf-core/src/commonMain/kotlin/snd/komf/providers/ehentai/EHentaiMetadataMapper.kt +++ b/komf-core/src/commonMain/kotlin/snd/komf/providers/ehentai/EHentaiMetadataMapper.kt @@ -11,7 +11,9 @@ import snd.komf.model.ProviderSeriesMetadata import snd.komf.model.ReleaseDate import snd.komf.model.SeriesMetadata import snd.komf.model.SeriesSearchResult +import snd.komf.model.SeriesStatus import snd.komf.model.SeriesTitle +import snd.komf.model.TitleType import snd.komf.model.WebLink import snd.komf.providers.CoreProviders import snd.komf.providers.MetadataConfigApplier @@ -21,68 +23,72 @@ import snd.komf.providers.ehentai.model.EHentaiBook class EHentaiMetadataMapper( private val metadataConfig: SeriesMetadataConfig, private val authorRoles: Collection, + private val preferredLanguages: List, ) { fun toSeriesMetadata( book: EHentaiBook, thumbnail: Image? ): ProviderSeriesMetadata { - val rawTags = book.tags ?: emptyList() - - val languageTag = rawTags.firstOrNull { it.startsWith("language:") } - val language = when (languageTag) { - "language:english" -> "en" - "language:chinese" -> "zh" - "language:japanese" -> "ja" - "language:korean" -> "ko" - "language:russian" -> "ru" - "language:french" -> "fr" - "language:spanish" -> "es" - else -> null - } + val rawTags = book.tags.orEmpty() - val artists = rawTags.filter { it.startsWith("artist:") }.map { it.removePrefix("artist:") } - val groups = rawTags.filter { it.startsWith("group:") }.map { it.removePrefix("group:") } - val authors = (artists + groups).flatMap { authorName -> - authorRoles.map { role -> Author(authorName, role) } - } + val language = EHentaiTagMapper.BCP47_MAP[rawTags.find { it.startsWith("language:") }] + + val authors = rawTags.asSequence() + .filter { it.startsWith("artist:") || it.startsWith("group:") } + .map { it.substringAfter(":") } + .flatMap { name -> authorRoles.map { Author(name, it) } } + .toList() - val finalTags = rawTags - .asSequence() - .filterNot { it.startsWith("language:") } - .filterNot { it.startsWith("artist:") } - .filterNot { it.startsWith("group:") } - .map { tag -> if (tag.contains(":")) tag.substringAfter(":") else tag } + val finalTags = rawTags.asSequence() + .filterNot { it.startsWith("language:") || it.startsWith("artist:") || it.startsWith("group:") } + .map { it.substringAfter(":") } .distinct() .toList() - val titles = listOfNotNull( - book.title?.let { SeriesTitle(name = it, type = null, language = null) }, - book.titleJpn?.ifBlank { null }?.let { SeriesTitle(name = it, type = null, language = "ja") } - ) + val title = when { + language == "ja" && !book.titleJpn.isNullOrBlank() -> { + SeriesTitle( + EHentaiParser.parseTitle(book.titleJpn).bestMatch, + TitleType.NATIVE, + "ja" + ) + } - val token = book.token ?: "unknown" - val link = WebLink("e-hentai", "https://e-hentai.org/g/${book.gid}/$token") + else -> { + val type = when (language) { + null -> null + "ja" -> TitleType.ROMAJI + else -> TitleType.LOCALIZED + } + SeriesTitle( + EHentaiParser.parseTitle(book.title).bestMatch, + type, + language + ) + } + } + + val link = WebLink("e-hentai", "https://e-hentai.org/g/${book.gid}/${book.token}") val metadata = SeriesMetadata( - titles = titles, + title = title, language = language, - releaseDate = book.posted?.toLocalDateTime(TimeZone.UTC)?.let { date -> - ReleaseDate( - year = date.year, - month = date.month.number, - day = date.day - ) + releaseDate = book.posted?.toLocalDateTime(TimeZone.UTC)?.let { + ReleaseDate(it.year, it.month.number, it.day) }, tags = finalTags, authors = authors, links = listOf(link), - thumbnail = thumbnail + thumbnail = thumbnail, + score = book.rating, + status = SeriesStatus.ENDED, + ageRating = EHentaiTagMapper.mapAgeRating(book.category, rawTags) ) return MetadataConfigApplier.apply( ProviderSeriesMetadata( - id = ProviderSeriesId("${book.gid};$token"), + id = ProviderSeriesId("${book.gid};${book.token}"), metadata = metadata ), metadataConfig @@ -90,13 +96,50 @@ class EHentaiMetadataMapper( } fun toSeriesSearchResult(result: EHentaiBook): SeriesSearchResult { - val token = result.token ?: "unknown" return SeriesSearchResult( - resultId = "${result.gid};$token", - url = "https://e-hentai.org/g/${result.gid}/$token", + resultId = "${result.gid};${result.token}", + url = "https://e-hentai.org/g/${result.gid}/${result.token}", imageUrl = result.thumb, - title = result.titleJpn?.ifBlank { null } ?: result.title ?: "Unknown Title", + title = result.title, provider = CoreProviders.EHENTAI ) } + + fun applyLanguagePreference(books: List): List { + val validLanguages = preferredLanguages + .filter { EHentaiTagMapper.BCP47_MAP.containsValue(it) } + .distinct() + + if (validLanguages.isEmpty()) { + return books + } + + val preferredBooksGrouped = mutableMapOf>() + validLanguages.forEach { preferredBooksGrouped[it] = mutableListOf() } + + val noLanguageBooks = mutableListOf() + + books.forEach { book -> + val hasLanguageTag = book.tags?.any { it.startsWith("language:") } == true + val langTag = book.tags?.firstOrNull { it.startsWith("language:") } + val bookLang = EHentaiTagMapper.BCP47_MAP[langTag] + + when { + bookLang in validLanguages -> preferredBooksGrouped[bookLang]!!.add(book) + !hasLanguageTag -> noLanguageBooks.add(book) + } + } + + val sortedPreferredBooks = validLanguages.flatMap { lang -> + preferredBooksGrouped[lang]!!.sortedByDescending { it.rating ?: 0.0 } + } + + noLanguageBooks.sortByDescending { it.rating ?: 0.0 } + + if (sortedPreferredBooks.isNotEmpty() || noLanguageBooks.isNotEmpty()) { + return sortedPreferredBooks + noLanguageBooks + } + + return emptyList() + } } \ No newline at end of file diff --git a/komf-core/src/commonMain/kotlin/snd/komf/providers/ehentai/EHentaiMetadataProvider.kt b/komf-core/src/commonMain/kotlin/snd/komf/providers/ehentai/EHentaiMetadataProvider.kt index d933dcd9..678e4662 100644 --- a/komf-core/src/commonMain/kotlin/snd/komf/providers/ehentai/EHentaiMetadataProvider.kt +++ b/komf-core/src/commonMain/kotlin/snd/komf/providers/ehentai/EHentaiMetadataProvider.kt @@ -1,6 +1,8 @@ package snd.komf.providers.ehentai import io.github.reactivecircus.cache4k.Cache +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll import snd.komf.model.Image import snd.komf.model.MatchQuery import snd.komf.model.ProviderBookId @@ -18,7 +20,7 @@ class EHentaiMetadataProvider( private val eHentaiClient: EHentaiClient, private val metadataMapper: EHentaiMetadataMapper, private val nameMatcher: NameSimilarityMatcher, - private val fetchSeriesCovers: Boolean, + private val fetchSeriesCovers: Boolean ) : MetadataProvider { private val cache = Cache.Builder() @@ -29,7 +31,7 @@ class EHentaiMetadataProvider( private suspend fun getBookOrThrow(seriesId: ProviderSeriesId): EHentaiBook { return cache.get(seriesId) { - val response = eHentaiClient.searchByGidList(listOf(seriesId.parseEHentaiGid())) + val response = eHentaiClient.searchByGidList(listOf(EHentaiParser.parseGid(seriesId))) val book = response.gmetadata.firstOrNull() ?: throw RuntimeException("Gallery not found") if (book.error != null) { throw RuntimeException("E-Hentai API Error for $seriesId: ${book.error}") @@ -54,21 +56,44 @@ class EHentaiMetadataProvider( } override suspend fun searchSeries(seriesName: String, limit: Int): Collection { - return eHentaiClient.searchByTitle(seriesName).gmetadata - .filter { it.error == null && it.token != null } - .map { result -> - metadataMapper.toSeriesSearchResult(result) - .also { cache.put(ProviderSeriesId(it.resultId), result) } - } + val queries = EHentaiParser.getSearchQueries(seriesName) + + val rawResults = kotlinx.coroutines.coroutineScope { + queries.map { query -> + async { eHentaiClient.searchByTitle(query.take(400)).gmetadata } + }.awaitAll().flatten() + }.filter { it.error == null }.distinctBy { it.gid } + + val processedResults = metadataMapper.applyLanguagePreference(rawResults) + + return processedResults.map { result -> + metadataMapper.toSeriesSearchResult(result) + .also { cache.put(ProviderSeriesId(it.resultId), result) } + } } override suspend fun matchSeriesMetadata(matchQuery: MatchQuery): ProviderSeriesMetadata? { - val seriesName = matchQuery.seriesName - val searchResults = eHentaiClient.searchByTitle(seriesName.take(400)).gmetadata - .filter { it.error == null && it.token != null && it.title != null } + // Usually downloaded books from E-Hentai have complete title name. + // We assume that user have not renamed their books and directly put them into the library. + // So this method is based on book's title and use regex to search the gallery. + val searchName = matchQuery.bookQualifier?.name ?: matchQuery.seriesName - return searchResults - .firstOrNull { matchesName(seriesName, it.title!!) } + val searchResults = kotlinx.coroutines.coroutineScope { + EHentaiParser.getSearchQueries(searchName).map { query -> + async { eHentaiClient.searchByTitle(query.take(400)).gmetadata } + }.awaitAll().flatten() + } + .filter { it.error == null } + .distinctBy { it.gid } + + val processedResults = metadataMapper.applyLanguagePreference(searchResults) + + return processedResults + .firstOrNull { book -> + val matchTitle = matchesName(searchName, book.title) + val matchTitleJpn = book.titleJpn?.let { matchesName(searchName, it) } ?: false + matchTitle || matchTitleJpn + } ?.let { book -> val cover = if (fetchSeriesCovers) eHentaiClient.getThumbnail(book) else null metadataMapper.toSeriesMetadata(book, cover).also { cache.put(it.id, book) } @@ -76,15 +101,8 @@ class EHentaiMetadataProvider( } private fun matchesName(name: String, nameToMatch: String): Boolean { - return nameMatcher.matches(name, nameToMatch) || - nameMatcher.matches( - removeParentheses(name), - removeParentheses(nameToMatch) - ) - } - - private fun removeParentheses(name: String): String { - val strippedName = name.replace("[(\\[{]([^)\\]}]+)[)\\]}]".toRegex(), "").trim() - return strippedName.ifBlank { name } + val localVariants = EHentaiParser.getSearchQueries(name) + val remoteVariants = EHentaiParser.getSearchQueries(nameToMatch) + return localVariants.any { local -> nameMatcher.matches(local, remoteVariants) } } } \ No newline at end of file diff --git a/komf-core/src/commonMain/kotlin/snd/komf/providers/ehentai/EHentaiParser.kt b/komf-core/src/commonMain/kotlin/snd/komf/providers/ehentai/EHentaiParser.kt new file mode 100644 index 00000000..bc7095eb --- /dev/null +++ b/komf-core/src/commonMain/kotlin/snd/komf/providers/ehentai/EHentaiParser.kt @@ -0,0 +1,77 @@ +package snd.komf.providers.ehentai + +import snd.komf.model.ProviderSeriesId +import snd.komf.providers.ehentai.model.EHentaiParsedTitle + +object EHentaiParser { + + private val platformPrefixRegex = """ + ^\[(pixiv|fanbox|fantia|patreon|gumroad|ci-en)([\s/,&|]+(pixiv|fanbox|fantia|patreon|gumroad|ci-en))*] + """ + .trimIndent() + .toRegex(RegexOption.IGNORE_CASE) + + fun parseGid(seriesId: ProviderSeriesId): Pair { + val parts = seriesId.value.split(";", limit = 2) + + require(parts.size == 2) { "Invalid E-Hentai ID format: ${seriesId.value}" } + val gid = parts[0].toIntOrNull() + ?: throw IllegalArgumentException("Invalid GID (Not a number) in ID: ${seriesId.value}") + val token = parts[1] + + return Pair(gid, token) + } + + fun parseTitle(rawTitle: String): EHentaiParsedTitle { + if (rawTitle.isBlank()) return EHentaiParsedTitle("", "") + + // Remove all trailing [xxx] tags + val trailingRegex = """(?:\s*\[[^]]+])+$""".toRegex() + val withoutTrailing = rawTitle.replace(trailingRegex, "").trim() + + /// Remove leading convention and artist information + val leadingRegex = """^(?:\([^)]+\)\s*)?(?:\[[^]]+]\s*)?""".toRegex() + var baseTitle = withoutTrailing.replace(leadingRegex, "").trim() + + // Pure date digits or artist's image pack only + if (baseTitle.matches("""^[\d\-.\s]+$""".toRegex()) || + platformPrefixRegex.containsMatchIn(rawTitle) + ) { + baseTitle = withoutTrailing + } + + // Take the final translated name or original name to the right of "|" + val bestMatch = if (baseTitle.contains("|")) { + baseTitle.substringAfterLast("|").trim() + } else { + baseTitle + } + + if (baseTitle.isBlank()) { + baseTitle = rawTitle + } + + return EHentaiParsedTitle(match = baseTitle, bestMatch = bestMatch.ifBlank { baseTitle }) + } + + /** + * ### Generate up to 3 search variants + * ``` md + * 1. [a (b)] c (d) [e] [f] + * 2. c (d) + * 3. c + * ``` + */ + fun getSearchQueries(rawTitle: String): List { + val parsed = parseTitle(rawTitle) + + val coreTitleRegex = """(?:\s*[((][^))]+[))])+$""".toRegex() + val coreTitle = parsed.bestMatch.replace(coreTitleRegex, "").trim() + val finalCoreTitle = coreTitle.ifBlank { parsed.bestMatch } + + return listOf(rawTitle, parsed.bestMatch, finalCoreTitle) + .map { it.trim() } + .filter { it.isNotBlank() } + .distinct() + } +} \ No newline at end of file diff --git a/komf-core/src/commonMain/kotlin/snd/komf/providers/ehentai/EHentaiTagMapper.kt b/komf-core/src/commonMain/kotlin/snd/komf/providers/ehentai/EHentaiTagMapper.kt new file mode 100644 index 00000000..4578a4a7 --- /dev/null +++ b/komf-core/src/commonMain/kotlin/snd/komf/providers/ehentai/EHentaiTagMapper.kt @@ -0,0 +1,113 @@ +package snd.komf.providers.ehentai + +object EHentaiTagMapper { + + val BCP47_MAP: Map = mapOf( + "language:afrikaans" to "af", + "language:albanian" to "sq", + "language:arabic" to "ar", + "language:aramaic" to "arc", // ISO 639-2 + "language:armenian" to "hy", + "language:bengali" to "bn", + "language:bosnian" to "bs", + "language:bulgarian" to "bg", + "language:burmese" to "my", + "language:catalan" to "ca", + "language:cebuano" to "ceb", // ISO 639-2 + "language:chinese" to "zh", + "language:cree" to "cr", + "language:creole" to "ht", + "language:croatian" to "hr", + "language:czech" to "cs", + "language:danish" to "da", + "language:dutch" to "nl", + "language:english" to "en", + "language:esperanto" to "eo", + "language:estonian" to "et", + "language:finnish" to "fi", + "language:french" to "fr", + "language:georgian" to "ka", + "language:german" to "de", + "language:greek" to "el", + "language:gujarati" to "gu", + "language:hebrew" to "he", + "language:hindi" to "hi", + "language:hmong" to "hmn", // ISO 639-2 + "language:hungarian" to "hu", + "language:icelandic" to "is", + "language:indonesian" to "id", + "language:irish" to "ga", + "language:italian" to "it", + "language:japanese" to "ja", + "language:javanese" to "jv", + "language:kannada" to "kn", + "language:kazakh" to "kk", + "language:khmer" to "km", + "language:korean" to "ko", + "language:kurdish" to "ku", + "language:ladino" to "lad", // ISO 639-2 + "language:lao" to "lo", + "language:latin" to "la", + "language:latvian" to "lv", + "language:marathi" to "mr", + "language:mongolian" to "mn", + "language:ndebele" to "nd", + "language:nepali" to "ne", + "language:norwegian" to "no", + "language:oromo" to "om", + "language:papiamento" to "pap", // ISO 639-2 + "language:pashto" to "ps", + "language:persian" to "fa", + "language:polish" to "pl", + "language:portuguese" to "pt", + "language:punjabi" to "pa", + "language:romanian" to "ro", + "language:russian" to "ru", + "language:sango" to "sg", + "language:sanskrit" to "sa", + "language:serbian" to "sr", + "language:shona" to "sn", + "language:slovak" to "sk", + "language:slovenian" to "sl", + "language:somali" to "so", + "language:spanish" to "es", + "language:swahili" to "sw", + "language:swedish" to "sv", + "language:tagalog" to "tl", + "language:tamil" to "ta", + "language:telugu" to "te", + "language:thai" to "th", + "language:tibetan" to "bo", + "language:tigrinya" to "ti", + "language:turkish" to "tr", + "language:ukrainian" to "uk", + "language:urdu" to "ur", + "language:vietnamese" to "vi", + "language:welsh" to "cy", + "language:yiddish" to "yi", + "language:zulu" to "zu" + ) + + fun mapAgeRating(category: String?, tags: List): Int? { + val hasExtremeTags = tags.any { tag -> + val cleanTag = if (tag.contains(":")) tag.substringAfter(":") else tag + cleanTag in listOf("guro", "ryona", "snuff", "scat") + } + if (hasExtremeTags) return 18 + + return when (category?.lowercase()) { + "non-h" -> 15 + "doujinshi", + "manga", + "artist cg", + "game cg", + "image set", + "western", + "cosplay", + "misc", + "private" -> 18 + + else -> null + } + } +} \ No newline at end of file diff --git a/komf-core/src/commonMain/kotlin/snd/komf/providers/ehentai/model/EHentaiBook.kt b/komf-core/src/commonMain/kotlin/snd/komf/providers/ehentai/model/EHentaiBook.kt index 8c44a435..2be5f68a 100644 --- a/komf-core/src/commonMain/kotlin/snd/komf/providers/ehentai/model/EHentaiBook.kt +++ b/komf-core/src/commonMain/kotlin/snd/komf/providers/ehentai/model/EHentaiBook.kt @@ -8,21 +8,17 @@ import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder +import kotlin.math.round import kotlin.time.Instant -@Serializable -data class EHentaiResponse( - val gmetadata: List = emptyList(), - val gid: Int? = null, - val error: String? = null -) - @Serializable data class EHentaiBook( val gid: Int, - val token: String? = null, - val title: String? = null, + val token: String, + @Serializable(with = HtmlUnescapeStringSerializer::class) + val title: String, @SerialName("title_jpn") + @Serializable(with = HtmlUnescapeStringSerializer::class) val titleJpn: String? = null, val category: String? = null, val thumb: String? = null, @@ -34,7 +30,8 @@ data class EHentaiBook( @SerialName("filesize") val fileSize: Long? = null, val expunged: Boolean? = null, - val rating: String? = null, + @Serializable(with = StringToRoundedDoubleSerializer::class) + val rating: Double? = null, @SerialName("torrentcount") val torrentCount: String? = null, val torrents: List? = null, @@ -59,6 +56,7 @@ data class EHentaiTorrent( val hash: String, @Serializable(with = InstantEpochSecondsSerializer::class) val added: Instant, + @Serializable(with = HtmlUnescapeStringSerializer::class) val name: String, @SerialName("tsize") val tSize: String, @@ -66,6 +64,46 @@ data class EHentaiTorrent( val fSize: String ) +/** + * Automatically convert a rating string (e.g. "4.68") to a rounded Double (e.g. 5.0) + */ +object StringToRoundedDoubleSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("StringToRoundedDouble", PrimitiveKind.STRING) + + override fun deserialize(decoder: Decoder): Double { + val stringValue = decoder.decodeString() + val doubleValue = stringValue.toDoubleOrNull() ?: 0.0 + return round(doubleValue) + } + + override fun serialize(encoder: Encoder, value: Double) { + encoder.encodeString(value.toString()) + } +} + +/** + * Automatically unescape HTML entities during JSON parsing + */ +object HtmlUnescapeStringSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("HtmlUnescapeString", PrimitiveKind.STRING) + + override fun deserialize(decoder: Decoder): String { + return decoder.decodeString() + .replace("&", "&") + .replace(""", "\"") + .replace("'", "'") + .replace("'", "'") + .replace("<", "<") + .replace(">", ">") + } + + override fun serialize(encoder: Encoder, value: String) { + encoder.encodeString(value) + } +} + object InstantEpochSecondsSerializer : KSerializer { override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("kotlinx.datetime.Instant", PrimitiveKind.STRING) diff --git a/komf-core/src/commonMain/kotlin/snd/komf/providers/ehentai/model/EHentaiParsedTitle.kt b/komf-core/src/commonMain/kotlin/snd/komf/providers/ehentai/model/EHentaiParsedTitle.kt new file mode 100644 index 00000000..8f8f5868 --- /dev/null +++ b/komf-core/src/commonMain/kotlin/snd/komf/providers/ehentai/model/EHentaiParsedTitle.kt @@ -0,0 +1,18 @@ +package snd.komf.providers.ehentai.model + +data class EHentaiParsedTitle( + /** + * ### Match title + * ``` md + * (Convention)[Artist/Circle] Title [Language] [Attribute tags] -> Title + * ``` + */ + val match: String, + /** + * ### The rightmost title from a matched title + * ```md + * Title1 | Title2 | Title3 -> Title3 + * ``` + */ + val bestMatch: String +) \ No newline at end of file