HDoujin | Added HDoujin (#11548)

* Added HDoujin

* Update build.gradle

Wrong version, unused dependency

* Page Filter

* Fixed Sort Filter

* Apply AwkwardPeak's Suggestion

Co-authored-by: AwkwardPeak7 <48650614+AwkwardPeak7@users.noreply.github.com>

---------

Co-authored-by: AwkwardPeak7 <48650614+AwkwardPeak7@users.noreply.github.com>
This commit is contained in:
KenjieDec 2025-11-13 02:47:13 +07:00 committed by Draff
parent 68af18e453
commit 9194e31208
Signed by: Draff
GPG Key ID: E8A89F3211677653
10 changed files with 678 additions and 0 deletions

View File

@ -0,0 +1,8 @@
ext {
extName = 'HDoujin'
extClass = '.HDoujinFactory'
extVersionCode = 1
isNsfw = true
}
apply from: "$rootDir/common.gradle"

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

View File

@ -0,0 +1,195 @@
package eu.kanade.tachiyomi.extension.all.hdoujin
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.model.UpdateStrategy
import kotlinx.serialization.Serializable
import java.text.SimpleDateFormat
import java.util.Locale
private val dateFormat = SimpleDateFormat("EEEE, d MMM yyyy HH:mm (z)", Locale.ENGLISH)
@Serializable
class MangaDetail(
val id: Int,
val key: String,
val title: String,
val title_short: String?,
val created_at: Long = 0L,
val updated_at: Long?,
val subtitle: String?,
val subtitle_short: String?,
val thumbnails: Thumbnails,
val tags: List<Tag> = emptyList(),
) {
@Serializable
class Tag(
val name: String,
val namespace: Int = 0,
)
@Serializable
class Thumbnail(
val path: String,
)
@Serializable
class Thumbnails(
val base: String,
val main: Thumbnail,
val entries: List<Thumbnail>,
)
fun toSManga() = SManga.create().apply {
val artists = mutableListOf<String>()
val circles = mutableListOf<String>()
val parodies = mutableListOf<String>()
val characters = mutableListOf<String>()
val females = mutableListOf<String>()
val males = mutableListOf<String>()
val mixed = mutableListOf<String>()
val language = mutableListOf<String>()
val other = mutableListOf<String>()
val uploaders = mutableListOf<String>()
val tags = mutableListOf<String>()
this@MangaDetail.tags.forEach { tag ->
when (tag.namespace) {
1 -> artists.add(tag.name)
2 -> circles.add(tag.name)
3 -> parodies.add(tag.name)
5 -> characters.add(tag.name)
7 -> tag.name.takeIf { it != "anonymous" }?.let { uploaders.add(it) }
8 -> males.add(tag.name + "")
9 -> females.add(tag.name + "")
10 -> mixed.add(tag.name)
11 -> language.add(tag.name)
12 -> other.add(tag.name)
else -> tags.add(tag.name)
}
}
var appended = false
fun List<String>.joinAndCapitalizeEach(): String? = this.emptyToNull()?.joinToString { it.capitalizeEach() }?.apply { appended = true }
thumbnail_url = thumbnails.base + thumbnails.main.path
author = (circles.emptyToNull() ?: artists).joinToString { it.capitalizeEach() }
artist = artists.joinToString { it.capitalizeEach() }
genre = (artists + circles + parodies + characters + tags + females + males + mixed + other).joinToString { it.capitalizeEach() }
description = buildString {
circles.joinAndCapitalizeEach()?.let {
append("Circles: ", it, "\n")
}
uploaders.joinAndCapitalizeEach()?.let {
append("Uploaders: ", it, "\n")
}
parodies.joinAndCapitalizeEach()?.let {
append("Parodies: ", it, "\n")
}
characters.joinAndCapitalizeEach()?.let {
append("Characters: ", it, "\n")
}
if (appended) append("\n")
try {
append("Posted: ", dateFormat.format(created_at), "\n")
} catch (_: Exception) {}
append("Pages: ", thumbnails.entries.size, "\n\n")
if (!subtitle.isNullOrBlank() || !subtitle_short.isNullOrBlank()) {
append("Alternative Title(s): ", mutableSetOf(subtitle, subtitle_short).filter { !it.isNullOrBlank() }.joinToString { "\n- $it" }, "\n\n")
}
}
status = SManga.COMPLETED
update_strategy = UpdateStrategy.ONLY_FETCH_ONCE
initialized = true
}
private fun String.capitalizeEach() = this.split(" ").joinToString(" ") { s ->
s.replaceFirstChar { sr ->
if (sr.isLowerCase()) sr.titlecase(Locale.getDefault()) else sr.toString()
}
}
private fun <T> Collection<T>.emptyToNull(): Collection<T>? {
return this.ifEmpty { null }
}
}
@Serializable
class Data(
val `0`: DataKey,
val `780`: DataKey? = null,
val `980`: DataKey? = null,
val `1280`: DataKey? = null,
val `1600`: DataKey? = null,
)
@Serializable
class DataKey(
val id: Int? = null,
val size: Double = 0.0,
val key: String? = null,
) {
fun readableSize() = when {
size >= 300 * 1000 * 1000 -> "${"%.2f".format(size / (1000.0 * 1000.0 * 1000.0))} GB"
size >= 100 * 1000 -> "${"%.2f".format(size / (1000.0 * 1000.0))} MB"
size >= 1000 -> "${"%.2f".format(size / (1000.0))} kB"
else -> "$size B"
}
}
@Serializable
class MangaData(
val data: Data,
) {
fun size(quality: String): String {
val dataKey = when (quality) {
"1600" -> data.`1600` ?: data.`1280` ?: data.`0`
"1280" -> data.`1280` ?: data.`1600` ?: data.`0`
"980" -> data.`980` ?: data.`1280` ?: data.`0`
"780" -> data.`780` ?: data.`980` ?: data.`0`
else -> data.`0`
}
return dataKey.readableSize()
}
}
@Serializable
class Entries(
val entries: List<Entry>,
val limit: Int,
val page: Int,
val total: Int,
) {
@Serializable
class Entry(
val id: Int,
val key: String,
val title: String,
val subtitle: String?,
val thumbnail: Thumbnail,
) {
fun toSManga() = SManga.create().apply {
url = "$id/$key"
title = this@Entry.title
thumbnail_url = thumbnail.path
}
}
@Serializable
class Thumbnail(
val path: String,
)
}
@Serializable
class ImagesInfo(
val base: String,
val entries: List<ImagePath>,
)
@Serializable
class ImagePath(
val path: String,
)

View File

@ -0,0 +1,65 @@
import eu.kanade.tachiyomi.source.model.Filter
import eu.kanade.tachiyomi.source.model.FilterList
fun getFilters(): FilterList {
return FilterList(
SelectFilter("Sort by", getSortsList),
CategoryFilter("Categories"),
Filter.Separator(),
TagType("Tags Include Type", "i"),
TagType("Tags Exclude Type", "e"),
Filter.Separator(),
Filter.Header("Separate tags with commas (,)"),
Filter.Header("Prepend with dash (-) to exclude"),
TextFilter("Tags", "tag"),
TextFilter("Male Tags", "male"),
TextFilter("Female Tags", "female"),
TextFilter("Mixed Tags", "mixed"),
TextFilter("Other Tags", "other"),
Filter.Separator(),
TextFilter("Artists", "artist"),
TextFilter("Parodies", "parody"),
TextFilter("Characters", "character"),
Filter.Separator(),
TextFilter("Uploader", "reason"),
TextFilter("Circles", "circle"),
TextFilter("Languages", "language"),
Filter.Separator(),
Filter.Header("Filter by pages, for example: (>20)"),
TextFilter("Pages", "pages"),
)
}
class CheckBoxFilter(name: String, val value: Int, state: Boolean) : Filter.CheckBox(name, state)
internal class CategoryFilter(name: String) :
Filter.Group<CheckBoxFilter>(
name,
listOf(
Pair("Manga", 2),
Pair("Doujinshi", 4),
Pair("Illustration", 8),
).map { CheckBoxFilter(it.first, it.second, true) },
)
internal class TagType(title: String, val type: String) : Filter.Select<String>(
title,
arrayOf("AND", "OR"),
)
internal open class TextFilter(name: String, val type: String) : Filter.Text(name)
internal open class SelectFilter(name: String, val vals: List<Pair<String, String>>, state: Int = 2) :
Filter.Select<String>(name, vals.map { it.first }.toTypedArray(), state) {
val selected get() = vals[state].second.takeIf { it.isNotEmpty() }
}
private val getSortsList: List<Pair<String, String>> = listOf(
Pair("Title", "2"),
Pair("Pages", "3"),
Pair("Date", ""),
Pair("Views", "8"),
Pair("Favourites", "9"),
Pair("Popular This Week", "popular"),
)

View File

@ -0,0 +1,397 @@
package eu.kanade.tachiyomi.extension.all.hdoujin
import CategoryFilter
import SelectFilter
import TagType
import TextFilter
import android.annotation.SuppressLint
import android.app.Application
import android.os.Handler
import android.os.Looper
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.preference.EditTextPreference
import androidx.preference.ListPreference
import androidx.preference.PreferenceScreen
import androidx.preference.SwitchPreferenceCompat
import eu.kanade.tachiyomi.extension.all.hdoujin.Entries.Entry
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.asObservableSuccess
import eu.kanade.tachiyomi.network.interceptor.rateLimit
import eu.kanade.tachiyomi.source.ConfigurableSource
import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.MangasPage
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.HttpSource
import getFilters
import keiyoushi.utils.getPreferences
import keiyoushi.utils.jsonInstance
import kotlinx.serialization.decodeFromString
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Request
import okhttp3.Response
import okio.IOException
import rx.Observable
import uy.kohesive.injekt.injectLazy
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
class HDoujin(
override val lang: String,
private val siteLang: String = lang,
) : HttpSource(), ConfigurableSource {
override val name = "HDoujin"
override val supportsLatest = true
private val preferences = getPreferences()
private fun quality() = preferences.getString(PREF_IMAGE_RES, "1280")!!
private fun remadd() = preferences.getBoolean(PREF_REM_ADD, false)
private fun alwaysIncludeTags() = preferences.getString(PREF_INCLUDE_TAGS, "")
private fun alwaysExcludeTags() = preferences.getString(PREF_EXCLUDE_TAGS, "")
private fun getTagsPreference(): String {
val include = alwaysIncludeTags()
?.split(",")
?.map(String::trim)
?.filter(String::isNotBlank)
val exclude = alwaysExcludeTags()
?.split(",")
?.map(String::trim)
?.filter(String::isNotBlank)
?.map { "-$it" }
val tags: List<String> = include?.plus(exclude ?: emptyList()) ?: exclude?.plus(include ?: emptyList()) ?: emptyList()
if (tags.isNotEmpty()) {
val tagGroups: Map<String, Set<String>> = tags
.groupBy {
val tag = it.removePrefix("-")
val parts = tag.split(":", limit = 2)
if (parts.size == 2 && parts[0].isNotBlank()) parts[0] else "tag"
}
.mapValues { (_, values) ->
values.mapTo(mutableSetOf()) {
val tag = it.removePrefix("-").split(":").last().trim()
if (it.startsWith("-")) "-$tag" else tag
}
}
return tagGroups.entries.joinToString(" ") { (key, values) ->
"$key:\"${values.joinToString(",")}\""
}
}
return ""
}
override val baseUrl: String = "https://hdoujin.org"
private val baseApiUrl: String = "https://api.hdoujin.org"
private val bookApiUrl: String = "$baseApiUrl/books"
override fun headersBuilder() = super.headersBuilder()
.set("Referer", "$baseUrl/")
.set("Origin", baseUrl)
private val context: Application by injectLazy()
private val handler by lazy { Handler(Looper.getMainLooper()) }
private var _clearance: String? = null
@SuppressLint("SetJavaScriptEnabled")
fun getClearance(): String? {
_clearance?.also { return it }
val latch = CountDownLatch(1)
handler.post {
val webview = WebView(context)
with(webview.settings) {
javaScriptEnabled = true
domStorageEnabled = true
databaseEnabled = true
blockNetworkImage = true
}
webview.webViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) {
view!!.evaluateJavascript("window.localStorage.getItem('clearance')") { clearance ->
webview.stopLoading()
webview.destroy()
_clearance = clearance.takeUnless { it == "null" }?.removeSurrounding("\"")
latch.countDown()
}
}
}
webview.loadDataWithBaseURL("$baseUrl/", " ", "text/html", null, null)
}
latch.await(10, TimeUnit.SECONDS)
return _clearance
}
private val clearanceClient = network.cloudflareClient.newBuilder()
.addInterceptor { chain ->
val request = chain.request()
val url = request.url
val clearance = getClearance()
?: throw IOException("Open webview to refresh token")
val newUrl = url.newBuilder()
.setQueryParameter("crt", clearance)
.build()
val newRequest = request.newBuilder()
.url(newUrl)
.build()
val response = chain.proceed(newRequest)
if (response.code !in listOf(400, 403)) {
return@addInterceptor response
}
response.close()
_clearance = null
throw IOException("Open webview to refresh token")
}
.rateLimit(3)
.build()
override fun popularMangaRequest(page: Int): Request = GET(
bookApiUrl.toHttpUrl().newBuilder().apply {
addQueryParameter("sort", "8")
addQueryParameter("page", page.toString())
val tags = getTagsPreference()
val terms: MutableList<String> = mutableListOf()
if (lang != "all") terms += "language:\"^$siteLang\""
if (tags.isNotBlank()) terms += tags
if (terms.isNotEmpty()) addQueryParameter("s", terms.joinToString(" "))
}.build(),
headers,
)
override fun popularMangaParse(response: Response): MangasPage {
val data = response.parseAs<Entries>()
with(data) {
return MangasPage(
mangas = entries.map(Entry::toSManga),
hasNextPage = limit * page < total,
)
}
}
override fun latestUpdatesRequest(page: Int) = GET(
bookApiUrl.toHttpUrl().newBuilder().apply {
addQueryParameter("page", page.toString())
val tags = getTagsPreference()
val terms: MutableList<String> = mutableListOf()
if (lang != "all") terms += "language:\"^$siteLang\""
if (tags.isNotBlank()) terms += tags
if (terms.isNotEmpty()) addQueryParameter("s", terms.joinToString(" "))
}.build(),
headers,
)
override fun latestUpdatesParse(response: Response): MangasPage = popularMangaParse(response)
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
val url = bookApiUrl.toHttpUrl().newBuilder().apply {
val terms = mutableListOf(query.trim())
if (lang != "all") terms += "language:\"^$siteLang$\""
filters.forEach { filter ->
when (filter) {
is SelectFilter -> {
val value = filter.selected
if (value == "popular") {
addPathSegment(value)
} else {
addQueryParameter("sort", value)
}
}
is CategoryFilter -> {
val activeFilter = filter.state.filter { it.state }
if (activeFilter.isNotEmpty()) {
addQueryParameter("cat", activeFilter.sumOf { it.value }.toString())
}
}
is TextFilter -> {
if (filter.state.isNotEmpty()) {
val tags = filter.state.split(",").filter(String::isNotBlank).joinToString(",")
if (tags.isNotBlank()) {
terms += "${filter.type}:${if (filter.type == "pages") tags else "\"$tags\""}"
}
}
}
is TagType -> {
if (filter.state > 0) {
addQueryParameter(
filter.type,
when {
filter.type == "i" && filter.state == 0 -> ""
filter.type == "e" && filter.state == 0 -> "1"
else -> ""
},
)
}
}
else -> {}
}
}
if (query.isNotEmpty()) terms.add("title:\"$query\"")
if (terms.isNotEmpty()) addQueryParameter("s", terms.joinToString(" "))
addQueryParameter("page", page.toString())
}.build()
return GET(url, headers)
}
override fun searchMangaParse(response: Response): MangasPage = popularMangaParse(response)
override fun getFilterList(): FilterList = getFilters()
private fun getImagesByMangaData(entry: MangaData, entryId: String, entryKey: String): Pair<ImagesInfo, String> {
val data = entry.data
fun getIPK(
ori: DataKey?,
alt1: DataKey?,
alt2: DataKey?,
alt3: DataKey?,
alt4: DataKey?,
): Pair<Int?, String?> {
return Pair(
ori?.id ?: alt1?.id ?: alt2?.id ?: alt3?.id ?: alt4?.id,
ori?.key ?: alt1?.key ?: alt2?.key ?: alt3?.key ?: alt4?.key,
)
}
val (id, public_key) = when (quality()) {
"1600" -> getIPK(data.`1600`, data.`1280`, data.`0`, data.`980`, data.`780`)
"1280" -> getIPK(data.`1280`, data.`1600`, data.`0`, data.`980`, data.`780`)
"980" -> getIPK(data.`980`, data.`1280`, data.`0`, data.`1600`, data.`780`)
"780" -> getIPK(data.`780`, data.`980`, data.`0`, data.`1280`, data.`1600`)
else -> getIPK(data.`0`, data.`1600`, data.`1280`, data.`980`, data.`780`)
}
if (id == null || public_key == null) {
throw Exception("No Images Found")
}
val realQuality = when (id) {
data.`1600`?.id -> "1600"
data.`1280`?.id -> "1280"
data.`980`?.id -> "980"
data.`780`?.id -> "780"
else -> "0"
}
val imagesResponse = clearanceClient.newCall(GET("$bookApiUrl/data/$entryId/$entryKey/$id/$public_key/$realQuality", headers)).execute()
val images = imagesResponse.parseAs<ImagesInfo>() to realQuality
return images
}
private val shortenTitleRegex = Regex("""(\[[^]]*]|[({][^)}]*[)}])""")
private fun String.shortenTitle() = replace(shortenTitleRegex, "").trim()
override fun mangaDetailsRequest(manga: SManga) =
GET("$bookApiUrl/detail/${manga.url}", headers)
override fun mangaDetailsParse(response: Response): SManga {
val mangaDetail = response.parseAs<MangaDetail>()
with(mangaDetail) {
return toSManga().apply {
setUrlWithoutDomain("${mangaDetail.id}/${mangaDetail.key}")
title = if (remadd()) {
title_short
?: mangaDetail.title.shortenTitle()
} else {
mangaDetail.title
}
}
}
}
override fun getMangaUrl(manga: SManga) = "$baseUrl/g/${manga.url}"
override fun chapterListRequest(manga: SManga) = GET("$bookApiUrl/detail/${manga.url}", headers)
override fun chapterListParse(response: Response): List<SChapter> {
val manga = response.parseAs<MangaDetail>()
return listOf(
SChapter.create().apply {
name = "Chapter"
url = "${manga.id}/${manga.key}"
date_upload = (manga.updated_at ?: manga.created_at)
},
)
}
override fun pageListRequest(chapter: SChapter): Request =
POST("$bookApiUrl/detail/${chapter.url}", headers)
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
return clearanceClient.newCall(pageListRequest(chapter))
.asObservableSuccess()
.map { response ->
pageListParse(response)
}
}
override fun pageListParse(response: Response): List<Page> {
val mangaData = response.parseAs<MangaData>()
val url = response.request.url.toString()
val matches = Regex("""/detail/(\d+)/([a-z\d]+)""").find(url)
if (matches == null || matches.groupValues.size < 3) return emptyList()
val imagesInfo = getImagesByMangaData(mangaData, matches.groupValues[1], matches.groupValues[2])
return imagesInfo.first.entries.mapIndexed { index, image ->
Page(index, imageUrl = "${imagesInfo.first.base}/${image.path}?w=${imagesInfo.second}")
}
}
override fun imageRequest(page: Page): Request {
return GET(page.imageUrl!!, headers)
}
override fun imageUrlParse(response: Response) = throw UnsupportedOperationException()
private inline fun <reified T> Response.parseAs(): T {
return jsonInstance.decodeFromString(body.string())
}
// Settings
override fun setupPreferenceScreen(screen: PreferenceScreen) {
ListPreference(screen.context).apply {
key = PREF_IMAGE_RES
title = "Image Resolution"
entries = arrayOf("780x", "980x", "1280x", "1600x", "Original")
entryValues = arrayOf("780", "980", "1280", "1600", "0")
summary = "%s"
setDefaultValue("1280")
}.also(screen::addPreference)
SwitchPreferenceCompat(screen.context).apply {
key = PREF_REM_ADD
title = "Remove additional information in title"
summary = "Remove anything in brackets from manga titles.\n" +
"Reload manga to apply changes to loaded manga."
setDefaultValue(false)
}.also(screen::addPreference)
EditTextPreference(screen.context).apply {
key = PREF_INCLUDE_TAGS
title = "Tags to include from browse/search"
summary = "Separate tags with commas (,).\n" +
"Excluding: ${alwaysIncludeTags()}"
}.also(screen::addPreference)
EditTextPreference(screen.context).apply {
key = PREF_EXCLUDE_TAGS
title = "Tags to exclude from browse/search"
summary = "Separate tags with commas (,). Supports tag types (females, male, etc), defaults to 'tag' if not specified.\n" +
"Example: 'ai generated, female:hairy, male:hairy'\n" +
"Excluding: ${alwaysExcludeTags()}"
}.also(screen::addPreference)
}
companion object {
private const val PREF_REM_ADD = "pref_remove_additional"
private const val PREF_IMAGE_RES = "pref_image_quality"
private const val PREF_INCLUDE_TAGS = "pref_include_tags"
private const val PREF_EXCLUDE_TAGS = "pref_exclude_tags"
}
}

View File

@ -0,0 +1,13 @@
package eu.kanade.tachiyomi.extension.all.hdoujin
import eu.kanade.tachiyomi.source.SourceFactory
class HDoujinFactory : SourceFactory {
override fun createSources() = listOf(
HDoujin("all"),
HDoujin("en", "english"),
HDoujin("ja", "japanese"),
HDoujin("kr", "korean"),
HDoujin("zh", "chinese"),
)
}