diff --git a/src/vi/hangtruyen/build.gradle b/src/vi/hangtruyen/build.gradle new file mode 100644 index 000000000..502ddcb0d --- /dev/null +++ b/src/vi/hangtruyen/build.gradle @@ -0,0 +1,8 @@ +ext { + extName = 'HangTruyen' + extClass = '.HangTruyen' + extVersionCode = 1 + isNsfw = true +} + +apply from: "$rootDir/common.gradle" diff --git a/src/vi/hangtruyen/res/mipmap-hdpi/ic_launcher.png b/src/vi/hangtruyen/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..59b285359 Binary files /dev/null and b/src/vi/hangtruyen/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/vi/hangtruyen/res/mipmap-mdpi/ic_launcher.png b/src/vi/hangtruyen/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..a0844c0ed Binary files /dev/null and b/src/vi/hangtruyen/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/vi/hangtruyen/res/mipmap-xhdpi/ic_launcher.png b/src/vi/hangtruyen/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..c2983d253 Binary files /dev/null and b/src/vi/hangtruyen/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/vi/hangtruyen/res/mipmap-xxhdpi/ic_launcher.png b/src/vi/hangtruyen/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..849663c95 Binary files /dev/null and b/src/vi/hangtruyen/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/vi/hangtruyen/res/mipmap-xxxhdpi/ic_launcher.png b/src/vi/hangtruyen/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..88cfa52e3 Binary files /dev/null and b/src/vi/hangtruyen/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/vi/hangtruyen/src/eu/kanade/tachiyomi/extension/vi/hangtruyen/Filters.kt b/src/vi/hangtruyen/src/eu/kanade/tachiyomi/extension/vi/hangtruyen/Filters.kt new file mode 100644 index 000000000..a6289c6b7 --- /dev/null +++ b/src/vi/hangtruyen/src/eu/kanade/tachiyomi/extension/vi/hangtruyen/Filters.kt @@ -0,0 +1,119 @@ +package eu.kanade.tachiyomi.extension.vi.hangtruyen + +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.source.model.Filter +import eu.kanade.tachiyomi.util.asJsoup +import okhttp3.OkHttpClient +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger + +private val genresFetchAttempts = AtomicInteger(0) +private val genresFetched = AtomicBoolean(false) + +@Volatile +private var genresList: List = emptyList() + +private val genreRegex = Regex("""\s*#\s*(.*)\s*""") + +fun fetchMetadata(baseUrl: String, client: OkHttpClient) { + if (genresFetchAttempts.get() < 3 && !genresFetched.get()) { + try { + client.newCall(GET("$baseUrl/tim-kiem")) + .execute().asJsoup() + .let { document -> + genresList = document.select(".list-genres span") + .mapNotNull { + genreRegex.find(it.ownText()) + ?.groupValues?.getOrNull(1)?.trim() + ?.let { name -> FilterData(it.attr("data-value"), name) } + } + genresFetched.set(true) + } + } catch (_: Exception) { + } finally { + genresFetchAttempts.incrementAndGet() + } + } +} + +internal class SortFilter( + selection: Selection = Selection(0, false), + private val options: List = getSortFilter(), +) : Filter.Sort( + "Sắp xếp", + options.map { it.name }.toTypedArray(), + selection, +) { + val selected: SelectFilterOption + get() = state?.index?.let { options.getOrNull(it) } ?: options[0] + + fun toUriPart(): String { + val base = selected.value + val order = if (state?.ascending == true) "_asc" else "_desc" + return if (base.isNotEmpty()) base + order else "" + } +} + +private fun getSortFilter() = listOf( + SelectFilterOption("Liên quan", ""), + SelectFilterOption("Lượt xem", "view"), + SelectFilterOption("Ngày cập nhật", "udpated_at_date"), + SelectFilterOption("Ngày đăng", "created_at_date"), +) + +internal class SelectFilterOption(val name: String, val value: String) + +internal class GenresFilter( + genres: List = genresList, +) : UriPartMultiSelectFilter( + "Genres", + "genreIds", + genres.map { + MultiSelectOption(it.name, it.id) + }, +) + +internal class CategoriesFilter( + categories: List = getCategoriesList(), +) : UriPartMultiSelectFilter( + "Thể loại", + "categoryIds", + categories.map { + MultiSelectOption(it.name, it.id) + }, +) + +private fun getCategoriesList() = listOf( + FilterData("1", "Manga"), + FilterData("2", "Manhua"), + FilterData("3", "Manhwa"), + FilterData("4", "Marvel Comics"), + FilterData("5", "DC Comics"), +) + +internal class FilterData( + val id: String, + val name: String, +) + +internal open class MultiSelectOption(name: String, val id: String = name) : Filter.CheckBox(name, false) + +internal open class UriPartMultiSelectFilter( + name: String, + val param: String, + genres: List, +) : Filter.Group(name, genres), UriPartFilter { + override fun toUriPart(): String { + val whatToInclude = state.filter { it.state }.map { it.id } + + return if (whatToInclude.isNotEmpty()) { + whatToInclude.joinToString(",") + } else { + "" + } + } +} + +internal interface UriPartFilter { + fun toUriPart(): String +} diff --git a/src/vi/hangtruyen/src/eu/kanade/tachiyomi/extension/vi/hangtruyen/HangTruyen.kt b/src/vi/hangtruyen/src/eu/kanade/tachiyomi/extension/vi/hangtruyen/HangTruyen.kt new file mode 100644 index 000000000..93edff767 --- /dev/null +++ b/src/vi/hangtruyen/src/eu/kanade/tachiyomi/extension/vi/hangtruyen/HangTruyen.kt @@ -0,0 +1,304 @@ +package eu.kanade.tachiyomi.extension.vi.hangtruyen + +import android.text.Editable +import android.text.TextWatcher +import android.widget.Button +import androidx.preference.EditTextPreference +import androidx.preference.PreferenceScreen +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.source.ConfigurableSource +import eu.kanade.tachiyomi.source.model.Filter +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.ParsedHttpSource +import keiyoushi.utils.getPreferencesLazy +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.Request +import org.jsoup.nodes.Document +import org.jsoup.nodes.Element +import rx.Observable +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Locale +import java.util.TimeZone + +class HangTruyen : ParsedHttpSource(), ConfigurableSource { + override val name = "HangTruyen" + override val lang = "vi" + override val supportsLatest = true + + private val preferences by getPreferencesLazy() + private val prefsLock = Any() + + override val baseUrl: String + get() { + return getCustomDomain().ifBlank { "https://hangtruyen.top" } + } + + override val client = network.cloudflareClient.newBuilder() + .followRedirects(false) + .addInterceptor { chain -> + val maxRedirects = 5 + var request = chain.request() + var response = chain.proceed(request) + var redirectCount = 0 + + while (response.isRedirect && redirectCount < maxRedirects) { + val newUrl = response.header("Location") ?: break + val newUrlHttp = newUrl.toHttpUrl() + val redirectedDomain = newUrlHttp.run { "$scheme://$host" } + if (redirectedDomain != baseUrl) { + synchronized(prefsLock) { + preferences.edit().putString(CUSTOM_URL_PREF, redirectedDomain).commit() + } + } + response.close() + request = request.newBuilder() + .url(newUrlHttp) + .build() + response = chain.proceed(request) + redirectCount++ + } + if (redirectCount >= maxRedirects) { + response.close() + throw java.io.IOException("Too many redirects: $maxRedirects") + } + response + } + .build() + + // Popular + override fun fetchPopularManga(page: Int): Observable { + return super.fetchPopularManga(page) + .map { + if (page == 1) { + MangasPage(it.mangas, true) + } else { + it + } + } + } + + override fun popularMangaRequest(page: Int): Request { + return if (page == 1) { + GET("$baseUrl/hot-nhat?type=week") + } else { + searchMangaRequest(page - 1, "", FilterList(SortFilter(Filter.Sort.Selection(1, false)))) + } + } + + override fun popularMangaSelector() = searchMangaSelector() + override fun popularMangaNextPageSelector() = searchMangaNextPageSelector() + override fun popularMangaFromElement(element: Element) = searchMangaFromElement(element) + + // Latest + override fun latestUpdatesRequest(page: Int) = + searchMangaRequest(page, "", FilterList(SortFilter(Filter.Sort.Selection(2, false)))) + + override fun latestUpdatesSelector() = searchMangaSelector() + override fun latestUpdatesNextPageSelector() = searchMangaNextPageSelector() + override fun latestUpdatesFromElement(element: Element) = searchMangaFromElement(element) + + // Search + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + val filterList = filters.ifEmpty { getFilterList() } + .filterNotNull() + val url = "$baseUrl/tim-kiem".toHttpUrl().newBuilder().apply { + addQueryParameter("page", page.toString()) + if (query.isNotBlank()) { + addQueryParameter("keyword", query) + } + filterList.forEach { filter -> + when (filter) { + is SortFilter -> { + val uriPart = filter.toUriPart() + if (uriPart.isNotEmpty()) { + addQueryParameter("orderBy", uriPart) + } + } + is UriPartMultiSelectFilter -> { + val uriPart = filter.toUriPart() + if (uriPart.isNotEmpty()) { + addQueryParameter(filter.param, uriPart) + } + } + else -> {} + } + } + } + + return GET(url.toString(), headers) + } + + override fun searchMangaSelector() = "div.search-result .m-post, div.list-managas .m-post" + override fun searchMangaNextPageSelector() = ".next-page" + + override fun searchMangaFromElement(element: Element) = SManga.create().apply { + val a = element.selectFirst("a")!! + setUrlWithoutDomain(a.attr("abs:href")) + title = a.attr("title") + thumbnail_url = element.selectFirst("img")?.attr("abs:data-src") + } + + // Details + override fun mangaDetailsParse(document: Document) = SManga.create().apply { + title = document.selectFirst("h1.title-detail a")!!.text() + author = document.selectFirst("div.author p")?.text() + description = document.selectFirst("div.sort-des div.line-clamp")?.text() + genre = document.select("div.kind a, div.m-tags a").joinToString { it.text() } + status = when (document.selectFirst("div.status p")?.text()) { + "Đang tiến hành" -> SManga.ONGOING + "Hoàn thành" -> SManga.COMPLETED + else -> SManga.UNKNOWN + } + thumbnail_url = document.selectFirst("div.col-image img")?.attr("abs:src") + } + + // Chapters + override fun chapterListSelector() = "div.list-chapters div.l-chapter" + + override fun chapterFromElement(element: Element) = SChapter.create().apply { + val a = element.selectFirst("a.ll-chap")!! + setUrlWithoutDomain(a.attr("href")) + name = a.text() + date_upload = element.selectFirst("span.ll-update")?.text()?.toDate() ?: 0L + } + + // Pages + override fun pageListParse(document: Document): List { + return document.select("#read-chaps .mi-item img.reading-img").mapIndexed { index, element -> + val img = when { + element.hasAttr("data-src") -> element.attr("abs:data-src") + else -> element.attr("abs:src") + } + Page(index, imageUrl = img) + }.distinctBy { it.imageUrl } + } + + override fun imageUrlParse(document: Document) = throw UnsupportedOperationException() + + private fun getCustomDomain(): String = synchronized(prefsLock) { + preferences.getString(CUSTOM_URL_PREF, "")!! + } + + override fun setupPreferenceScreen(screen: PreferenceScreen) { + EditTextPreference(screen.context).apply { + key = CUSTOM_URL_PREF + title = CUSTOM_URL_PREF_TITLE + summary = "$CUSTOM_URL_PREF_SUMMARY${getCustomDomain()}" + setDefaultValue("") + dialogTitle = CUSTOM_URL_PREF_TITLE + + val validate = { str: String -> + if (str.isBlank()) { + true + } else { + runCatching { str.toHttpUrl() }.isSuccess && domainRegex.matchEntire(str) != null + } + } + + setOnBindEditTextListener { editText -> + editText.addTextChangedListener( + object : TextWatcher { + override fun beforeTextChanged(text: CharSequence?, start: Int, count: Int, after: Int) {} + override fun onTextChanged(text: CharSequence?, start: Int, before: Int, count: Int) {} + + override fun afterTextChanged(editable: Editable?) { + editable ?: return + val text = editable.toString() + val valid = validate(text) + editText.error = if (!valid) "https://example.com" else null + editText.rootView.findViewById