diff --git a/src/all/hdoujin/build.gradle b/src/all/hdoujin/build.gradle new file mode 100644 index 000000000..f34ff3dc3 --- /dev/null +++ b/src/all/hdoujin/build.gradle @@ -0,0 +1,8 @@ +ext { + extName = 'HDoujin' + extClass = '.HDoujinFactory' + extVersionCode = 1 + isNsfw = true +} + +apply from: "$rootDir/common.gradle" diff --git a/src/all/hdoujin/res/mipmap-hdpi/ic_launcher.png b/src/all/hdoujin/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..cd413fd63 Binary files /dev/null and b/src/all/hdoujin/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/all/hdoujin/res/mipmap-mdpi/ic_launcher.png b/src/all/hdoujin/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..4f38b837a Binary files /dev/null and b/src/all/hdoujin/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/all/hdoujin/res/mipmap-xhdpi/ic_launcher.png b/src/all/hdoujin/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..077989341 Binary files /dev/null and b/src/all/hdoujin/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/all/hdoujin/res/mipmap-xxhdpi/ic_launcher.png b/src/all/hdoujin/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..acc7f2189 Binary files /dev/null and b/src/all/hdoujin/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/all/hdoujin/res/mipmap-xxxhdpi/ic_launcher.png b/src/all/hdoujin/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..5beac0505 Binary files /dev/null and b/src/all/hdoujin/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/all/hdoujin/src/eu/kanade/tachiyomi/extension/all/hdoujin/Dto.kt b/src/all/hdoujin/src/eu/kanade/tachiyomi/extension/all/hdoujin/Dto.kt new file mode 100644 index 000000000..e165b2048 --- /dev/null +++ b/src/all/hdoujin/src/eu/kanade/tachiyomi/extension/all/hdoujin/Dto.kt @@ -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 = 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, + ) + fun toSManga() = SManga.create().apply { + val artists = mutableListOf() + val circles = mutableListOf() + val parodies = mutableListOf() + val characters = mutableListOf() + val females = mutableListOf() + val males = mutableListOf() + val mixed = mutableListOf() + val language = mutableListOf() + val other = mutableListOf() + val uploaders = mutableListOf() + val tags = mutableListOf() + 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.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 Collection.emptyToNull(): Collection? { + 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, + 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, +) + +@Serializable +class ImagePath( + val path: String, +) diff --git a/src/all/hdoujin/src/eu/kanade/tachiyomi/extension/all/hdoujin/Filters.kt b/src/all/hdoujin/src/eu/kanade/tachiyomi/extension/all/hdoujin/Filters.kt new file mode 100644 index 000000000..98daa4354 --- /dev/null +++ b/src/all/hdoujin/src/eu/kanade/tachiyomi/extension/all/hdoujin/Filters.kt @@ -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( + 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( + 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>, state: Int = 2) : + Filter.Select(name, vals.map { it.first }.toTypedArray(), state) { + val selected get() = vals[state].second.takeIf { it.isNotEmpty() } +} + +private val getSortsList: List> = listOf( + Pair("Title", "2"), + Pair("Pages", "3"), + Pair("Date", ""), + Pair("Views", "8"), + Pair("Favourites", "9"), + Pair("Popular This Week", "popular"), +) diff --git a/src/all/hdoujin/src/eu/kanade/tachiyomi/extension/all/hdoujin/HDoujin.kt b/src/all/hdoujin/src/eu/kanade/tachiyomi/extension/all/hdoujin/HDoujin.kt new file mode 100644 index 000000000..4acf073ac --- /dev/null +++ b/src/all/hdoujin/src/eu/kanade/tachiyomi/extension/all/hdoujin/HDoujin.kt @@ -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 = include?.plus(exclude ?: emptyList()) ?: exclude?.plus(include ?: emptyList()) ?: emptyList() + if (tags.isNotEmpty()) { + val tagGroups: Map> = 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 = 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() + + 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 = 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 { + val data = entry.data + fun getIPK( + ori: DataKey?, + alt1: DataKey?, + alt2: DataKey?, + alt3: DataKey?, + alt4: DataKey?, + ): Pair { + 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() 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() + 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 { + val manga = response.parseAs() + 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> { + return clearanceClient.newCall(pageListRequest(chapter)) + .asObservableSuccess() + .map { response -> + pageListParse(response) + } + } + override fun pageListParse(response: Response): List { + val mangaData = response.parseAs() + 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 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" + } +} diff --git a/src/all/hdoujin/src/eu/kanade/tachiyomi/extension/all/hdoujin/HDoujinFactory.kt b/src/all/hdoujin/src/eu/kanade/tachiyomi/extension/all/hdoujin/HDoujinFactory.kt new file mode 100644 index 000000000..6d75a5c48 --- /dev/null +++ b/src/all/hdoujin/src/eu/kanade/tachiyomi/extension/all/hdoujin/HDoujinFactory.kt @@ -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"), + ) +}