diff --git a/src/zh/dm5/AndroidManifest.xml b/src/zh/dm5/AndroidManifest.xml
new file mode 100644
index 000000000..8072ee00d
--- /dev/null
+++ b/src/zh/dm5/AndroidManifest.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/src/zh/dm5/build.gradle b/src/zh/dm5/build.gradle
new file mode 100644
index 000000000..2186c57a2
--- /dev/null
+++ b/src/zh/dm5/build.gradle
@@ -0,0 +1,12 @@
+ext {
+ extName = 'Dm5'
+ extClass = '.Dm5'
+ extVersionCode = 1
+ isNsfw = true
+}
+
+apply from: "$rootDir/common.gradle"
+
+dependencies {
+ implementation(project(':lib:unpacker'))
+}
diff --git a/src/zh/dm5/res/mipmap-hdpi/ic_launcher.png b/src/zh/dm5/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 000000000..01e0cf479
Binary files /dev/null and b/src/zh/dm5/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/src/zh/dm5/res/mipmap-mdpi/ic_launcher.png b/src/zh/dm5/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 000000000..c05b218a2
Binary files /dev/null and b/src/zh/dm5/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/src/zh/dm5/res/mipmap-xhdpi/ic_launcher.png b/src/zh/dm5/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 000000000..186bdac67
Binary files /dev/null and b/src/zh/dm5/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/src/zh/dm5/res/mipmap-xxhdpi/ic_launcher.png b/src/zh/dm5/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 000000000..e13bc2bf9
Binary files /dev/null and b/src/zh/dm5/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/src/zh/dm5/res/mipmap-xxxhdpi/ic_launcher.png b/src/zh/dm5/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 000000000..733d5853f
Binary files /dev/null and b/src/zh/dm5/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/src/zh/dm5/src/eu/kanade/tachiyomi/extension/zh/dm5/CommentsInterceptor.kt b/src/zh/dm5/src/eu/kanade/tachiyomi/extension/zh/dm5/CommentsInterceptor.kt
new file mode 100644
index 000000000..dc8190866
--- /dev/null
+++ b/src/zh/dm5/src/eu/kanade/tachiyomi/extension/zh/dm5/CommentsInterceptor.kt
@@ -0,0 +1,91 @@
+package eu.kanade.tachiyomi.extension.zh.dm5
+
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.graphics.Color
+import android.text.Layout
+import android.text.StaticLayout
+import android.text.TextPaint
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.decodeFromString
+import kotlinx.serialization.json.Json
+import okhttp3.Interceptor
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.Response
+import okhttp3.ResponseBody.Companion.toResponseBody
+import org.jsoup.parser.Parser
+import uy.kohesive.injekt.injectLazy
+import java.io.ByteArrayOutputStream
+
+// This file is modified from DMZJ extension
+
+val json: Json by injectLazy()
+
+@Serializable
+class ChapterCommentDto(
+ val PostContent: String,
+ val Poster: String,
+) {
+ override fun toString() = "$Poster: $PostContent"
+}
+
+fun parseChapterComments(response: Response): List {
+ val result: List = json.decodeFromString(response.body.string())
+ if (result.isEmpty()) return listOf("没有吐槽")
+ return result.map {
+ Parser.unescapeEntities(it.toString(), false)
+ }
+}
+
+object CommentsInterceptor : Interceptor {
+ private const val MAX_HEIGHT = 1920
+ private const val WIDTH = 1080
+ private const val UNIT = 32
+ private const val UNIT_F = UNIT.toFloat()
+
+ override fun intercept(chain: Interceptor.Chain): Response {
+ val request = chain.request()
+ val response = chain.proceed(request)
+ if (!response.request.url.toString().contains("pagerdata.ashx")) return response
+
+ val comments = parseChapterComments(response)
+
+ val paint = TextPaint().apply {
+ color = Color.BLACK
+ textSize = UNIT_F
+ isAntiAlias = true
+ }
+
+ var height = UNIT
+ val layouts = comments.map {
+ @Suppress("DEPRECATION")
+ StaticLayout(it, paint, WIDTH - 2 * UNIT, Layout.Alignment.ALIGN_NORMAL, 1f, 0f, false)
+ }.takeWhile {
+ val lineHeight = it.height + UNIT
+ if (height + lineHeight <= MAX_HEIGHT) {
+ height += lineHeight
+ true
+ } else {
+ false
+ }
+ }
+
+ val bitmap = Bitmap.createBitmap(WIDTH, height, Bitmap.Config.ARGB_8888)
+ bitmap.eraseColor(Color.WHITE)
+ val canvas = Canvas(bitmap)
+
+ var y = UNIT
+ for (layout in layouts) {
+ canvas.save()
+ canvas.translate(UNIT_F, y.toFloat())
+ layout.draw(canvas)
+ canvas.restore()
+ y += layout.height + UNIT
+ }
+
+ val output = ByteArrayOutputStream()
+ bitmap.compress(Bitmap.CompressFormat.PNG, 0, output)
+ val body = output.toByteArray().toResponseBody("image/png".toMediaType())
+ return response.newBuilder().body(body).build()
+ }
+}
diff --git a/src/zh/dm5/src/eu/kanade/tachiyomi/extension/zh/dm5/Dm5.kt b/src/zh/dm5/src/eu/kanade/tachiyomi/extension/zh/dm5/Dm5.kt
new file mode 100644
index 000000000..bb24d775d
--- /dev/null
+++ b/src/zh/dm5/src/eu/kanade/tachiyomi/extension/zh/dm5/Dm5.kt
@@ -0,0 +1,218 @@
+package eu.kanade.tachiyomi.extension.zh.dm5
+
+import android.app.Application
+import android.content.SharedPreferences
+import androidx.preference.PreferenceScreen
+import androidx.preference.SwitchPreferenceCompat
+import eu.kanade.tachiyomi.lib.unpacker.Unpacker
+import eu.kanade.tachiyomi.network.GET
+import eu.kanade.tachiyomi.source.ConfigurableSource
+import eu.kanade.tachiyomi.source.model.FilterList
+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.ParsedHttpSource
+import eu.kanade.tachiyomi.util.asJsoup
+import okhttp3.HttpUrl.Companion.toHttpUrl
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.Response
+import org.jsoup.nodes.Document
+import org.jsoup.nodes.Element
+import uy.kohesive.injekt.Injekt
+import uy.kohesive.injekt.api.get
+import java.text.SimpleDateFormat
+import java.util.Locale
+
+class Dm5 : ParsedHttpSource(), ConfigurableSource {
+ override val lang = "zh"
+ override val supportsLatest = true
+ override val name = "动漫屋"
+ override val baseUrl = "https://www.dm5.cn"
+ override val client: OkHttpClient = network.client.newBuilder()
+ .addInterceptor(CommentsInterceptor)
+ .build()
+
+ private val preferences: SharedPreferences =
+ Injekt.get().getSharedPreferences("source_$id", 0x0000)
+
+ // Some mangas are blocked without this
+ override fun headersBuilder() = super.headersBuilder().set("Accept-Language", "zh-TW")
+
+ override fun popularMangaRequest(page: Int) = GET("$baseUrl/manhua-list-p$page/", headers)
+ override fun popularMangaNextPageSelector(): String = "div.page-pagination a:contains(>)"
+ override fun popularMangaSelector(): String = "ul.mh-list > li > div.mh-item"
+ override fun popularMangaFromElement(element: Element): SManga = SManga.create().apply {
+ title = element.selectFirst("h2.title > a")!!.text()
+ thumbnail_url = element.selectFirst("p.mh-cover")!!.attr("style")
+ .substringAfter("url(").substringBefore(")")
+ url = element.selectFirst("h2.title > a")!!.attr("href")
+ }
+
+ override fun latestUpdatesRequest(page: Int) = GET("$baseUrl/manhua-list-s2-p$page/", headers)
+ override fun latestUpdatesNextPageSelector(): String = popularMangaNextPageSelector()
+ override fun latestUpdatesSelector() = popularMangaSelector()
+ override fun latestUpdatesFromElement(element: Element) = popularMangaFromElement(element)
+
+ override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
+ return GET("$baseUrl/search?title=$query&language=1&page=$page", headers)
+ }
+ override fun searchMangaNextPageSelector(): String = popularMangaNextPageSelector()
+ override fun searchMangaSelector(): String = "ul.mh-list > li, div.banner_detail_form"
+ override fun searchMangaFromElement(element: Element): SManga = SManga.create().apply {
+ title = element.selectFirst(".title > a")!!.text()
+ thumbnail_url = element.selectFirst("img")?.attr("src")
+ ?: element.selectFirst("p.mh-cover")!!.attr("style")
+ .substringAfter("url(").substringBefore(")")
+ url = element.selectFirst(".title > a")!!.attr("href")
+ }
+
+ override fun mangaDetailsParse(document: Document) = SManga.create().apply {
+ title = document.selectFirst("div.banner_detail_form p.title")!!.ownText()
+ thumbnail_url = document.selectFirst("div.banner_detail_form img")!!.attr("abs:src")
+ author = document.selectFirst("div.banner_detail_form p.subtitle > a")!!.text()
+ artist = author
+ genre = document.select("div.banner_detail_form p.tip a").eachText().joinToString(", ")
+ val el = document.selectFirst("div.banner_detail_form p.content")!!
+ description = el.ownText() + el.selectFirst("span")?.ownText().orEmpty()
+ status = when (document.selectFirst("div.banner_detail_form p.tip > span > span")!!.text()) {
+ "连载中" -> SManga.ONGOING
+ "已完结" -> SManga.COMPLETED
+ else -> SManga.UNKNOWN
+ }
+ }
+
+ override fun chapterListParse(response: Response): List {
+ val document = response.asJsoup()
+ // May need to click button on website to read
+ document.selectFirst("ul#detail-list-select-1")?.attr("class")
+ ?: throw Exception("請到webview確認")
+ val li = document.select("div#chapterlistload li > a").map {
+ SChapter.create().apply {
+ url = it.attr("href")
+ name = if (it.selectFirst("span.detail-lock, span.view-lock") != null) {
+ "\uD83D\uDD12"
+ } else {
+ ""
+ } + (it.selectFirst("p.title")?.text() ?: it.text())
+
+ val dateStr = it.selectFirst("p.tip")
+ if (dateStr != null) {
+ date_upload = dateFormat.parse(dateStr.text())?.time ?: 0L
+ }
+ }
+ }
+
+ // Sort chapter by url (related to upload time)
+ if (preferences.getBoolean(SORT_CHAPTER_PREF, false)) {
+ return li.sortedByDescending { it.url.drop(2).dropLast(1).toInt() }
+ }
+
+ // Sometimes list is in ascending order, probably unread paid manga
+ return if (document.selectFirst("div.detail-list-title a.order")!!.text() == "正序") {
+ li.reversed()
+ } else {
+ li
+ }
+ }
+ override fun chapterListSelector(): String = throw UnsupportedOperationException()
+ override fun chapterFromElement(element: Element): SChapter = throw UnsupportedOperationException()
+
+ override fun pageListParse(document: Document): List {
+ val images = document.select("div#barChapter > img.load-src")
+ val result: ArrayList
+ val script = document.selectFirst("script:containsData(DM5_MID)")!!.data()
+ if (!script.contains("DM5_VIEWSIGN_DT")) {
+ throw Exception(document.selectFirst("div.view-pay-form p.subtitle")!!.text())
+ }
+ val cid = script.substringAfter("var DM5_CID=").substringBefore(";")
+ if (!images.isEmpty()) {
+ result = images.mapIndexed { index, it ->
+ Page(index, "", it.attr("data-src"))
+ } as ArrayList
+ } else {
+ val mid = script.substringAfter("var DM5_MID=").substringBefore(";")
+ val dt = script.substringAfter("var DM5_VIEWSIGN_DT=\"").substringBefore("\";")
+ val sign = script.substringAfter("var DM5_VIEWSIGN=\"").substringBefore("\";")
+ val requestUrl = document.location()
+ val imageCount = script.substringAfter("var DM5_IMAGE_COUNT=").substringBefore(";").toInt()
+ result = (1..imageCount).map {
+ val url = requestUrl.toHttpUrl().newBuilder()
+ .addPathSegment("chapterfun.ashx")
+ .addQueryParameter("cid", cid)
+ .addQueryParameter("page", it.toString())
+ .addQueryParameter("key", "")
+ .addQueryParameter("language", "1")
+ .addQueryParameter("gtk", "6")
+ .addQueryParameter("_cid", cid)
+ .addQueryParameter("_mid", mid)
+ .addQueryParameter("_dt", dt)
+ .addQueryParameter("_sign", sign)
+ .build()
+ Page(it, url.toString())
+ } as ArrayList
+ }
+
+ if (preferences.getBoolean(CHAPTER_COMMENTS_PREF, false)) {
+ val pageSize = script.substringAfter("var DM5_PAGEPCOUNT = ").substringBefore(";")
+ val tid = script.substringAfter("var DM5_TIEBATOPICID='").substringBefore("'")
+ for (i in 1..pageSize.toInt()) {
+ result.add(
+ Page(
+ result.size,
+ "",
+ "$baseUrl/m$cid/pagerdata.ashx?pageindex=$i&pagesize=$pageSize&tid=$tid&cid=$cid&t=9",
+ ),
+ )
+ }
+ }
+ return result
+ }
+
+ override fun imageUrlRequest(page: Page): Request {
+ val referer = page.url.substringBefore("chapterfun.ashx")
+ val header = headers.newBuilder().add("Referer", referer).build()
+ return GET(page.url, header)
+ }
+
+ override fun imageUrlParse(response: Response): String {
+ val script = Unpacker.unpack(response.body.string())
+ val pix = script.substringAfter("var pix=\"").substringBefore("\"")
+ val pvalue = script.substringAfter("var pvalue=[\"").substringBefore("\"")
+ val query = script.substringAfter("pix+pvalue[i]+\"").substringBefore("\"")
+ return pix + pvalue + query
+ }
+ override fun imageUrlParse(document: Document): String = throw UnsupportedOperationException()
+
+ override fun imageRequest(page: Page): Request {
+ if (!page.imageUrl!!.contains("pagerdata.ashx")) {
+ return GET(page.imageUrl!!, headers)
+ }
+
+ val referer = page.imageUrl!!.substringBefore("pagerdata.ashx")
+ val header = headers.newBuilder().add("Referer", referer).build()
+ return GET(page.imageUrl!!, header)
+ }
+
+ override fun setupPreferenceScreen(screen: PreferenceScreen) {
+ val chapterCommentsPreference = SwitchPreferenceCompat(screen.context).apply {
+ key = CHAPTER_COMMENTS_PREF
+ title = "章末吐槽页"
+ summary = "修改后,已加载的章节需要清除章节缓存才能生效。"
+ setDefaultValue(false)
+ }
+ val sortChapterPreference = SwitchPreferenceCompat(screen.context).apply {
+ key = SORT_CHAPTER_PREF
+ title = "依照上傳時間排序章節"
+ setDefaultValue(false)
+ }
+ screen.addPreference(chapterCommentsPreference)
+ screen.addPreference(sortChapterPreference)
+ }
+
+ companion object {
+ private const val CHAPTER_COMMENTS_PREF = "chapterComments"
+ private const val SORT_CHAPTER_PREF = "sortChapter"
+ private val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.ROOT)
+ }
+}