diff --git a/src/zh/yidan/AndroidManifest.xml b/src/zh/yidan/AndroidManifest.xml
new file mode 100644
index 000000000..30deb7f79
--- /dev/null
+++ b/src/zh/yidan/AndroidManifest.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/src/zh/yidan/build.gradle b/src/zh/yidan/build.gradle
new file mode 100644
index 000000000..f4b77e699
--- /dev/null
+++ b/src/zh/yidan/build.gradle
@@ -0,0 +1,13 @@
+apply plugin: 'com.android.application'
+apply plugin: 'kotlin-android'
+apply plugin: 'kotlinx-serialization'
+
+ext {
+ extName = 'Yidan Girl'
+ pkgNameSuffix = 'zh.yidan'
+ extClass = '.Yidan'
+ extVersionCode = 1
+ isNsfw = true
+}
+
+apply from: "$rootDir/common.gradle"
diff --git a/src/zh/yidan/res/mipmap-hdpi/ic_launcher.png b/src/zh/yidan/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 000000000..bb66c1d21
Binary files /dev/null and b/src/zh/yidan/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/src/zh/yidan/res/mipmap-mdpi/ic_launcher.png b/src/zh/yidan/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 000000000..f953246d9
Binary files /dev/null and b/src/zh/yidan/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/src/zh/yidan/res/mipmap-xhdpi/ic_launcher.png b/src/zh/yidan/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 000000000..0d31fd5fb
Binary files /dev/null and b/src/zh/yidan/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/src/zh/yidan/res/mipmap-xxhdpi/ic_launcher.png b/src/zh/yidan/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 000000000..9f1a27be2
Binary files /dev/null and b/src/zh/yidan/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/src/zh/yidan/res/mipmap-xxxhdpi/ic_launcher.png b/src/zh/yidan/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 000000000..4c0aafc21
Binary files /dev/null and b/src/zh/yidan/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/src/zh/yidan/res/web_hi_res_512.png b/src/zh/yidan/res/web_hi_res_512.png
new file mode 100644
index 000000000..863e1eb97
Binary files /dev/null and b/src/zh/yidan/res/web_hi_res_512.png differ
diff --git a/src/zh/yidan/src/eu/kanade/tachiyomi/extension/zh/yidan/Dto.kt b/src/zh/yidan/src/eu/kanade/tachiyomi/extension/zh/yidan/Dto.kt
new file mode 100644
index 000000000..84d998610
--- /dev/null
+++ b/src/zh/yidan/src/eu/kanade/tachiyomi/extension/zh/yidan/Dto.kt
@@ -0,0 +1,62 @@
+package eu.kanade.tachiyomi.extension.zh.yidan
+
+import eu.kanade.tachiyomi.source.model.SChapter
+import eu.kanade.tachiyomi.source.model.SManga
+import kotlinx.serialization.Serializable
+import org.jsoup.nodes.Entities
+
+@Serializable
+class MangaDto(
+ private val title: String,
+ private val mhcate: String?,
+ private val cateids: String?,
+ private val author: String?,
+ private val summary: String?,
+ private val coverPic: String?,
+ private val id: Int,
+) {
+ fun toSManga() = SManga.create().apply {
+ url = id.toString()
+ title = this@MangaDto.title
+ author = this@MangaDto.author
+ description = summary?.trim()
+ genre = when {
+ cateids.isNullOrEmpty() -> null
+ else -> cateids.split(",").joinToString { GENRES[it.toInt()] }
+ }
+ status = when {
+ mhcate.isNullOrEmpty() -> SManga.ONGOING
+ "5" in mhcate.split(",") -> SManga.COMPLETED
+ else -> SManga.ONGOING
+ }
+ thumbnail_url = coverPic
+ initialized = true
+ }
+}
+
+@Serializable
+class ChapterDto(
+ private val createTime: Long,
+ private val mhid: String,
+ private val title: String,
+ private val jiNo: Int
+) {
+ fun toSChapter() = SChapter.create().apply {
+ url = "$mhid/$jiNo"
+ name = Entities.unescape(title)
+ date_upload = createTime * 1000L
+ }
+}
+
+@Serializable
+class PageListDto(private val pics: String) {
+ val images get() = pics.split(",")
+}
+
+@Serializable
+class ListingDto(val list: List, private val total: String) {
+ val totalCount get() = total.toInt()
+}
+
+@Serializable
+class ResponseDto(val data: T)
diff --git a/src/zh/yidan/src/eu/kanade/tachiyomi/extension/zh/yidan/Filters.kt b/src/zh/yidan/src/eu/kanade/tachiyomi/extension/zh/yidan/Filters.kt
new file mode 100644
index 000000000..b58bf6bd5
--- /dev/null
+++ b/src/zh/yidan/src/eu/kanade/tachiyomi/extension/zh/yidan/Filters.kt
@@ -0,0 +1,52 @@
+package eu.kanade.tachiyomi.extension.zh.yidan
+
+import eu.kanade.tachiyomi.source.model.Filter
+import eu.kanade.tachiyomi.source.model.FilterList
+import okhttp3.HttpUrl
+
+fun getFilterListInternal() = FilterList(ListingFilter(), GenreFilter())
+
+fun parseFilters(filters: FilterList, builder: HttpUrl.Builder) {
+ for (filter in filters) when (filter) {
+ is ListingFilter -> {
+ if (filter.state > 0)
+ builder.addEncodedQueryParameter("mhcate", LISTING_VALUES[filter.state].toString())
+ }
+ is GenreFilter -> {
+ if (filter.state > 0)
+ builder.addEncodedQueryParameter("cateid", String.format("%02d", filter.state))
+ }
+ else -> {}
+ }
+}
+
+class ListingFilter : Filter.Select("分类", LISTINGS)
+
+val LISTINGS = arrayOf("全部", "排行榜", "新作", "完结漫", "分类0", "分类1", "分类3", "分类7")
+val LISTING_VALUES = arrayOf(0, 2, 4, 5, 0, 1, 3, 7)
+
+class GenreFilter : Filter.Select("标签", GENRES)
+
+val GENRES = arrayOf(
+ "全部",
+ "短漫", // 01
+ "甜漫", // 02
+ "强强", // 03
+ "年下攻", // 04
+ "诱受", // 05
+ "骨科", // 06
+ "调教", // 07
+ "健气受", // 08
+ "ABO", // 09
+ "重生/重逢", // 10
+ "财阀", // 11
+ "校园", // 12
+ "女王受", // 13
+ "NP/SM", // 14
+ "韩国榜单", // 15
+ "高H", // 16
+ "架空", // 17
+ "娱乐圈", // 18
+ "办公室", // 19
+ "青梅竹马", // 20
+)
diff --git a/src/zh/yidan/src/eu/kanade/tachiyomi/extension/zh/yidan/Yidan.kt b/src/zh/yidan/src/eu/kanade/tachiyomi/extension/zh/yidan/Yidan.kt
new file mode 100644
index 000000000..5f2275767
--- /dev/null
+++ b/src/zh/yidan/src/eu/kanade/tachiyomi/extension/zh/yidan/Yidan.kt
@@ -0,0 +1,137 @@
+package eu.kanade.tachiyomi.extension.zh.yidan
+
+import android.app.Application
+import androidx.preference.ListPreference
+import androidx.preference.PreferenceScreen
+import eu.kanade.tachiyomi.network.GET
+import eu.kanade.tachiyomi.network.asObservableSuccess
+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 kotlinx.serialization.json.Json
+import kotlinx.serialization.json.decodeFromStream
+import okhttp3.Headers
+import okhttp3.HttpUrl.Companion.toHttpUrl
+import okhttp3.Request
+import okhttp3.Response
+import rx.Observable
+import uy.kohesive.injekt.Injekt
+import uy.kohesive.injekt.api.get
+import uy.kohesive.injekt.injectLazy
+
+class Yidan : HttpSource(), ConfigurableSource {
+ override val name get() = "一耽女孩"
+ override val lang get() = "zh"
+ override val supportsLatest get() = true
+
+ override val baseUrl: String
+
+ init {
+ val mirrors = MIRRORS
+ val index = Injekt.get().getSharedPreferences("source_$id", 0x0000)
+ .getString(MIRROR_PREF, "0")!!.toInt().coerceAtMost(mirrors.size - 1)
+ baseUrl = "https://" + mirrors[index]
+ }
+
+ override fun headersBuilder() = Headers.Builder()
+ .add("User-Agent", System.getProperty("http.agent")!!)
+
+ private val json: Json by injectLazy()
+
+ override fun popularMangaRequest(page: Int) =
+ GET("$baseUrl/prod-api/app-api/vv/mh-list/page?mhcate=2&pageSize=50&pageNo=$page", headers)
+
+ override fun popularMangaParse(response: Response): MangasPage {
+ val listing: ListingDto = response.parseAs()
+ val mangas = listing.list.map { it.toSManga() }
+ val hasNextPage = run {
+ val url = response.request.url
+ val pageSize = url.queryParameter("pageSize")!!.toInt()
+ val pageNumber = url.queryParameter("pageNo")!!.toInt()
+ pageSize * pageNumber < listing.totalCount
+ }
+ return MangasPage(mangas, hasNextPage)
+ }
+
+ override fun latestUpdatesRequest(page: Int) =
+ GET("$baseUrl/prod-api/app-api/vv/mh-list/page?mhcate=4&pageSize=50&pageNo=$page", headers)
+
+ override fun latestUpdatesParse(response: Response) = popularMangaParse(response)
+
+ override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
+ val url = "$baseUrl/prod-api/app-api/vv/mh-list/page".toHttpUrl().newBuilder()
+ .apply { if (query.isNotBlank()) addQueryParameter("word", query) }
+ .apply { parseFilters(filters, this) }
+ .addEncodedQueryParameter("pageSize", "50")
+ .addEncodedQueryParameter("pageNo", page.toString())
+ .build()
+ return Request.Builder().url(url).headers(headers).build()
+ }
+
+ override fun searchMangaParse(response: Response) = popularMangaParse(response)
+
+ // for WebView
+ override fun mangaDetailsRequest(manga: SManga) =
+ GET("$baseUrl/#/pages/detail/detail?id=${manga.url}")
+
+ override fun fetchMangaDetails(manga: SManga): Observable {
+ val request = GET("$baseUrl/prod-api/app-api/vv/mh-list/get?id=${manga.url}", headers)
+ return client.newCall(request).asObservableSuccess().map { mangaDetailsParse(it) }
+ }
+
+ override fun mangaDetailsParse(response: Response) =
+ response.parseAs().toSManga()
+
+ override fun chapterListRequest(manga: SManga) =
+ GET("$baseUrl/prod-api/app-api/vv/mh-episodes/list?mhid=${manga.url}", headers)
+
+ override fun chapterListParse(response: Response) =
+ response.parseAs>().map { it.toSChapter() }
+
+ // for WebView
+ override fun pageListRequest(chapter: SChapter): Request {
+ val (mangaId, chapterIndex) = chapter.url.split("/")
+ return GET("$baseUrl/#/pages/read/read?no=$chapterIndex&id=$mangaId")
+ }
+
+ override fun fetchPageList(chapter: SChapter): Observable> {
+ val (mangaId, chapterIndex) = chapter.url.split("/")
+ val url = "$baseUrl/prod-api/app-api/vv/mh-episodes/get?jiNo=$chapterIndex&mhid=$mangaId"
+ return client.newCall(GET(url, headers)).asObservableSuccess().map { pageListParse(it) }
+ }
+
+ override fun pageListParse(response: Response) =
+ response.parseAs().images.mapIndexed { index, url ->
+ val imageUrl = if (url.startsWith("http")) url else baseUrl + url
+ Page(index, imageUrl = imageUrl)
+ }
+
+ override fun imageUrlParse(response: Response) = throw UnsupportedOperationException()
+
+ private inline fun Response.parseAs(): T = use {
+ json.decodeFromStream>(body!!.byteStream()).data
+ }
+
+ override fun getFilterList() = getFilterListInternal()
+
+ override fun setupPreferenceScreen(screen: PreferenceScreen) {
+ ListPreference(screen.context).apply {
+ val mirrors = MIRRORS
+ key = MIRROR_PREF
+ title = "镜像网址(重启生效)"
+ summary = "%s"
+ entries = mirrors
+ entryValues = Array(mirrors.size, Int::toString)
+ setDefaultValue("0")
+ }.let(screen::addPreference)
+ }
+
+ companion object {
+ private const val MIRROR_PREF = "MIRROR"
+ private val MIRRORS get() = arrayOf("ydan.cc", "ydan.vip", "dans.cc")
+ }
+}