Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ enum class KomfCoreProviders : KomfProviders {
BANGUMI,
BOOK_WALKER,
COMIC_VINE,
EHENTAI,
HENTAG,
KODANSHA,
MAL,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ data class ProvidersConfigUpdateRequest(
val bangumi: PatchValue<ProviderConfigUpdateRequest> = PatchValue.Unset,
val comicVine: PatchValue<ProviderConfigUpdateRequest> = PatchValue.Unset,
val hentag: PatchValue<ProviderConfigUpdateRequest> = PatchValue.Unset,
val ehentai: PatchValue<ProviderConfigUpdateRequest> = PatchValue.Unset,
val mangaBaka: PatchValue<MangaBakaConfigUpdateRequest> = PatchValue.Unset,
val webtoons: PatchValue<ProviderConfigUpdateRequest> = PatchValue.Unset,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ enum class CoreProviders {
BANGUMI,
BOOK_WALKER,
COMIC_VINE,
EHENTAI,
HENTAG,
KODANSHA,
MAL,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,

Expand All @@ -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 }
)
Expand All @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Pair<Int, String>>
): 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<EHentaiResponse>(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<ByteArray>()
Image(bytes)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package snd.komf.providers.ehentai

import snd.komf.model.ProviderSeriesId

fun ProviderSeriesId.parseEHentaiGid(): Pair<Int, String> {
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)
}
Original file line number Diff line number Diff line change
@@ -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<AuthorRole>,
) {

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