re-add removed extensions

This commit is contained in:
Draff 2024-01-09 00:40:47 +00:00
parent 51f35d8d75
commit 33f80ae4c6
72 changed files with 5706 additions and 0 deletions

View File

@ -0,0 +1,34 @@
package eu.kanade.tachiyomi.lib.unpacker
/**
* A helper class to extract substrings efficiently.
*
* Note that all methods move [startIndex] over the ending delimiter.
*/
class SubstringExtractor(private val text: String) {
private var startIndex = 0
fun skipOver(str: String) {
val index = text.indexOf(str, startIndex)
if (index == -1) return
startIndex = index + str.length
}
fun substringBefore(str: String): String {
val index = text.indexOf(str, startIndex)
if (index == -1) return ""
val result = text.substring(startIndex, index)
startIndex = index + str.length
return result
}
fun substringBetween(left: String, right: String): String {
val index = text.indexOf(left, startIndex)
if (index == -1) return ""
val leftIndex = index + left.length
val rightIndex = text.indexOf(right, leftIndex)
if (rightIndex == -1) return ""
startIndex = rightIndex + right.length
return text.substring(leftIndex, rightIndex)
}
}

View File

@ -0,0 +1,76 @@
package eu.kanade.tachiyomi.lib.unpacker
/**
* Helper class to unpack JavaScript code compressed by [packer](http://dean.edwards.name/packer/).
*
* Source code of packer can be found [here](https://github.com/evanw/packer/blob/master/packer.js).
*/
object Unpacker {
/**
* Unpacks JavaScript code compressed by packer.
*
* Specify [left] and [right] to unpack only the data between them.
*
* Note: single quotes `\'` in the data will be replaced with double quotes `"`.
*/
fun unpack(script: String, left: String? = null, right: String? = null): String =
unpack(SubstringExtractor(script), left, right)
/**
* Unpacks JavaScript code compressed by packer.
*
* Specify [left] and [right] to unpack only the data between them.
*
* Note: single quotes `\'` in the data will be replaced with double quotes `"`.
*/
fun unpack(script: SubstringExtractor, left: String? = null, right: String? = null): String {
val packed = script
.substringBetween("}('", ".split('|'),0,{}))")
.replace("\\'", "\"")
val parser = SubstringExtractor(packed)
val data: String
if (left != null && right != null) {
data = parser.substringBetween(left, right)
parser.skipOver("',")
} else {
data = parser.substringBefore("',")
}
if (data.isEmpty()) return ""
val dictionary = parser.substringBetween("'", "'").split("|")
val size = dictionary.size
return wordRegex.replace(data) {
val key = it.value
val index = parseRadix62(key)
if (index >= size) return@replace key
dictionary[index].ifEmpty { key }
}
}
private val wordRegex by lazy { Regex("""\w+""") }
private fun parseRadix62(str: String): Int {
var result = 0
for (ch in str.toCharArray()) {
result = result * 62 + when {
ch.code <= '9'.code -> { // 0-9
ch.code - '0'.code
}
ch.code >= 'a'.code -> { // a-z
// ch - 'a' + 10
ch.code - ('a'.code - 10)
}
else -> { // A-Z
// ch - 'A' + 36
ch.code - ('A'.code - 36)
}
}
}
return result
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 338 KiB

View File

@ -0,0 +1,11 @@
package eu.kanade.tachiyomi.extension.en.s2manga
import eu.kanade.tachiyomi.multisrc.madara.Madara
class S2Manga : Madara("S2Manga", "https://www.s2manga.com", "en") {
override fun headersBuilder() = super.headersBuilder()
.add("Referer", "$baseUrl/")
override val pageListParseSelector = "div.page-break img[src*=\"https\"]"
}

View File

@ -422,6 +422,7 @@ class MadaraGenerator : ThemeSourceGenerator {
SingleLang("ROG Mangás", "https://rogmangas.com", "pt-BR", pkgName = "mangasoverall", className = "RogMangas", overrideVersionCode = 1),
SingleLang("Romantik Manga", "https://romantikmanga.com", "tr"),
SingleLang("Rüya Manga", "https://www.ruyamanga.com", "tr", className = "RuyaManga", overrideVersionCode = 1),
SingleLang("S2Manga", "https://www.s2manga.com", "en", overrideVersionCode = 2),
SingleLang("Sagrado Império da Britannia", "https://imperiodabritannia.com", "pt-BR", className = "ImperioDaBritannia"),
SingleLang("SamuraiScan", "https://samuraiscan.com", "es", overrideVersionCode = 3),
SingleLang("Sawamics", "https://sawamics.com", "en"),

View File

@ -0,0 +1,53 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity
android:name=".all.batoto.BatoToUrlActivity"
android:excludeFromRecents="true"
android:exported="true"
android:theme="@android:style/Theme.NoDisplay">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:host="*.bato.to" />
<data android:host="bato.to" />
<data android:host="*.batocc.com" />
<data android:host="batocc.com" />
<data android:host="*.batotoo.com" />
<data android:host="batotoo.com" />
<data android:host="*.batotwo.com" />
<data android:host="batotwo.com" />
<data android:host="*.battwo.com" />
<data android:host="battwo.com" />
<data android:host="*.comiko.net" />
<data android:host="comiko.net" />
<data android:host="*.mangatoto.com" />
<data android:host="mangatoto.com" />
<data android:host="*.mangatoto.net" />
<data android:host="mangatoto.net" />
<data android:host="*.mangatoto.org" />
<data android:host="mangatoto.org" />
<data android:host="*.mycordant.co.uk" />
<data android:host="mycordant.co.uk" />
<data android:host="*.dto.to" />
<data android:host="dto.to" />
<data android:host="*.hto.to" />
<data android:host="hto.to" />
<data android:host="*.mto.to" />
<data android:host="mto.to" />
<data android:host="*.wto.to" />
<data android:host="wto.to" />
<data
android:pathPattern="/series/..*"
android:scheme="https" />
<data
android:pathPattern="/subject-overview/..*"
android:scheme="https" />
</intent-filter>
</activity>
</application>
</manifest>

201
src/all/batoto/CHANGELOG.md Normal file
View File

@ -0,0 +1,201 @@
## 1.3.30
### Refactor
* Replace CryptoJS with Native Kotlin Functions
* Remove QuickJS dependency
## 1.3.29
### Refactor
* Cleanup pageListParse function
* Replace Duktape with QuickJS
## 1.3.28
### Features
* Add mirror `batocc.com`
* Add mirror `batotwo.com`
* Add mirror `mangatoto.net`
* Add mirror `mangatoto.org`
* Add mirror `mycordant.co.uk`
* Add mirror `dto.to`
* Add mirror `hto.to`
* Add mirror `mto.to`
* Add mirror `wto.to`
* Remove mirror `mycdhands.com`
## 1.3.27
### Features
* Change default popular sort by `Most Views Totally`
## 1.3.26
### Fix
* Update author and artist parsing
## 1.3.25
### Fix
* Status parsing
* Artist name parsing
## 1.3.24
### Fix
* Bump versions for individual extension with URL handler activities
## 1.2.23
### Fix
* Update pageListParse logic to handle website changes
## 1.2.22
### Features
* Add `CHANGELOG.md` & `README.md`
## 1.2.21
### Fix
* Update lang codes
## 1.2.20
### Features
* Rework of search
## 1.2.19
### Features
* Support for alternative chapter list
* Personal lists filter
## 1.2.18
### Features
* Utils lists filter
* Letter matching filter
## 1.2.17
### Features
* Add mirror `mycdhands.com`
## 1.2.16
### Features
* Mirror support
* URL intent updates
## 1.2.15
### Fix
* Manga description
## 1.2.14
### Features
* Escape entities
## 1.2.13
### Refactor
* Replace Gson with kotlinx.serialization
## 1.2.12
### Fix
* Infinity search
## 1.2.11
### Fix
* No search result
## 1.2.10
### Features
* Support for URL intent
* Updated filters
## 1.2.9
### Fix
* Chapter parsing
## 1.2.8
### Features
* More chapter filtering
## 1.2.7
### Fix
* Language filtering in latest
* Parsing of seconds
## 1.2.6
### Features
* Scanlator support
### Fix
* Date parsing
## 1.2.5
### Features
* Update supported Language list
## 1.2.4
### Features
* Support for excluding genres
## 1.2.3
### Fix
* Typo in some genres
## 1.2.2
### Features
* Reworked filter option
## 1.2.1
### Features
* Conversion from Emerald to Bato.to
* First version

20
src/all/batoto/README.md Normal file
View File

@ -0,0 +1,20 @@
# Bato.to
Table of Content
- [FAQ](#FAQ)
- [Why are there Manga of diffrent languge than the selected one in Personal & Utils lists?](#why-are-there-manga-of-diffrent-languge-than-the-selected-one-in-personal--utils-lists)
- [Bato.to is not loading anything?](#batoto-is-not-loading-anything)
[Uncomment this if needed; and replace &#40; and &#41; with ( and )]: <> (- [Guides]&#40;#Guides&#41;)
Don't find the question you are look for go check out our general FAQs and Guides over at [Extension FAQ](https://tachiyomi.org/help/faq/#extensions) or [Getting Started](https://tachiyomi.org/help/guides/getting-started/#installation)
## FAQ
### Why are there Manga of diffrent languge than the selected one in Personal & Utils lists?
Personol & Utils lists have no way to difritiate between langueges.
### Bato.to is not loading anything?
Bato.to get blocked by some ISPs, try using a diffrent mirror of Bato.to from the settings.
[Uncomment this if needed]: <> (## Guides)

View File

@ -0,0 +1,17 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlinx-serialization'
ext {
extName = 'Bato.to'
pkgNameSuffix = 'all.batoto'
extClass = '.BatoToFactory'
extVersionCode = 32
isNsfw = true
}
apply from: "$rootDir/common.gradle"
dependencies {
implementation(project(':lib-cryptoaes'))
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

View File

@ -0,0 +1,974 @@
package eu.kanade.tachiyomi.extension.all.batoto
import android.app.Application
import android.content.SharedPreferences
import androidx.preference.CheckBoxPreference
import androidx.preference.ListPreference
import androidx.preference.PreferenceScreen
import eu.kanade.tachiyomi.lib.cryptoaes.CryptoAES
import eu.kanade.tachiyomi.lib.cryptoaes.Deobfuscator
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.asObservableSuccess
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 eu.kanade.tachiyomi.util.asJsoup
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.FormBody
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import org.jsoup.parser.Parser
import rx.Observable
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Locale
import java.util.concurrent.TimeUnit
open class BatoTo(
final override val lang: String,
private val siteLang: String,
) : ConfigurableSource, ParsedHttpSource() {
private val preferences: SharedPreferences by lazy {
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
}
override val name: String = "Bato.to"
override val baseUrl: String = getMirrorPref()!!
override val id: Long = when (lang) {
"zh-Hans" -> 2818874445640189582
"zh-Hant" -> 38886079663327225
"ro-MD" -> 8871355786189601023
else -> super.id
}
override fun setupPreferenceScreen(screen: PreferenceScreen) {
val mirrorPref = ListPreference(screen.context).apply {
key = "${MIRROR_PREF_KEY}_$lang"
title = MIRROR_PREF_TITLE
entries = MIRROR_PREF_ENTRIES
entryValues = MIRROR_PREF_ENTRY_VALUES
setDefaultValue(MIRROR_PREF_DEFAULT_VALUE)
summary = "%s"
setOnPreferenceChangeListener { _, newValue ->
val selected = newValue as String
val index = findIndexOfValue(selected)
val entry = entryValues[index] as String
preferences.edit().putString("${MIRROR_PREF_KEY}_$lang", entry).commit()
}
}
val altChapterListPref = CheckBoxPreference(screen.context).apply {
key = "${ALT_CHAPTER_LIST_PREF_KEY}_$lang"
title = ALT_CHAPTER_LIST_PREF_TITLE
summary = ALT_CHAPTER_LIST_PREF_SUMMARY
setDefaultValue(ALT_CHAPTER_LIST_PREF_DEFAULT_VALUE)
setOnPreferenceChangeListener { _, newValue ->
val checkValue = newValue as Boolean
preferences.edit().putBoolean("${ALT_CHAPTER_LIST_PREF_KEY}_$lang", checkValue).commit()
}
}
screen.addPreference(mirrorPref)
screen.addPreference(altChapterListPref)
}
private fun getMirrorPref(): String? = preferences.getString("${MIRROR_PREF_KEY}_$lang", MIRROR_PREF_DEFAULT_VALUE)
private fun getAltChapterListPref(): Boolean = preferences.getBoolean("${ALT_CHAPTER_LIST_PREF_KEY}_$lang", ALT_CHAPTER_LIST_PREF_DEFAULT_VALUE)
override val supportsLatest = true
private val json: Json by injectLazy()
override val client: OkHttpClient = network.cloudflareClient.newBuilder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
override fun latestUpdatesRequest(page: Int): Request {
return GET("$baseUrl/browse?langs=$siteLang&sort=update&page=$page")
}
override fun latestUpdatesSelector(): String {
return when (siteLang) {
"" -> "div#series-list div.col"
"en" -> "div#series-list div.col.no-flag"
else -> "div#series-list div.col:has([data-lang=\"$siteLang\"])"
}
}
override fun latestUpdatesFromElement(element: Element): SManga {
val manga = SManga.create()
val item = element.select("a.item-cover")
val imgurl = item.select("img").attr("abs:src")
manga.setUrlWithoutDomain(item.attr("href"))
manga.title = element.select("a.item-title").text().removeEntities()
manga.thumbnail_url = imgurl
return manga
}
override fun latestUpdatesNextPageSelector() = "div#mainer nav.d-none .pagination .page-item:last-of-type:not(.disabled)"
override fun popularMangaRequest(page: Int): Request {
return GET("$baseUrl/browse?langs=$siteLang&sort=views_a&page=$page")
}
override fun popularMangaSelector() = latestUpdatesSelector()
override fun popularMangaFromElement(element: Element) = latestUpdatesFromElement(element)
override fun popularMangaNextPageSelector() = latestUpdatesNextPageSelector()
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
return when {
query.startsWith("ID:") -> {
val id = query.substringAfter("ID:")
client.newCall(GET("$baseUrl/series/$id", headers)).asObservableSuccess()
.map { response ->
queryIDParse(response)
}
}
query.isNotBlank() -> {
val url = "$baseUrl/search".toHttpUrl().newBuilder()
.addQueryParameter("word", query)
.addQueryParameter("page", page.toString())
filters.forEach { filter ->
when (filter) {
is LetterFilter -> {
if (filter.state == 1) {
url.addQueryParameter("mode", "letter")
}
}
else -> { /* Do Nothing */ }
}
}
client.newCall(GET(url.build().toString(), headers)).asObservableSuccess()
.map { response ->
queryParse(response)
}
}
else -> {
val url = "$baseUrl/browse".toHttpUrlOrNull()!!.newBuilder()
var min = ""
var max = ""
filters.forEach { filter ->
when (filter) {
is UtilsFilter -> {
if (filter.state != 0) {
val filterUrl = "$baseUrl/_utils/comic-list?type=${filter.selected}"
return client.newCall(GET(filterUrl, headers)).asObservableSuccess()
.map { response ->
queryUtilsParse(response)
}
}
}
is HistoryFilter -> {
if (filter.state != 0) {
val filterUrl = "$baseUrl/ajax.my.${filter.selected}.paging"
return client.newCall(POST(filterUrl, headers, formBuilder().build())).asObservableSuccess()
.map { response ->
queryHistoryParse(response)
}
}
}
is LangGroupFilter -> {
if (filter.selected.isEmpty()) {
url.addQueryParameter("langs", siteLang)
} else {
val selection = "${filter.selected.joinToString(",")},$siteLang"
url.addQueryParameter("langs", selection)
}
}
is GenreGroupFilter -> {
with(filter) {
url.addQueryParameter(
"genres",
included.joinToString(",") + "|" + excluded.joinToString(","),
)
}
}
is StatusFilter -> url.addQueryParameter("release", filter.selected)
is SortFilter -> {
if (filter.state != null) {
val sort = getSortFilter()[filter.state!!.index].value
val value = when (filter.state!!.ascending) {
true -> "az"
false -> "za"
}
url.addQueryParameter("sort", "$sort.$value")
}
}
is OriginGroupFilter -> {
if (filter.selected.isNotEmpty()) {
url.addQueryParameter("origs", filter.selected.joinToString(","))
}
}
is MinChapterTextFilter -> min = filter.state
is MaxChapterTextFilter -> max = filter.state
else -> { /* Do Nothing */ }
}
}
url.addQueryParameter("page", page.toString())
if (max.isNotEmpty() or min.isNotEmpty()) {
url.addQueryParameter("chapters", "$min-$max")
}
client.newCall(GET(url.build().toString(), headers)).asObservableSuccess()
.map { response ->
queryParse(response)
}
}
}
}
private fun queryIDParse(response: Response): MangasPage {
val document = response.asJsoup()
val infoElement = document.select("div#mainer div.container-fluid")
val manga = SManga.create()
manga.title = infoElement.select("h3").text().removeEntities()
manga.thumbnail_url = document.select("div.attr-cover img")
.attr("abs:src")
manga.url = infoElement.select("h3 a").attr("abs:href")
return MangasPage(listOf(manga), false)
}
private fun queryParse(response: Response): MangasPage {
val document = response.asJsoup()
val mangas = document.select(latestUpdatesSelector())
.map { element -> latestUpdatesFromElement(element) }
val nextPage = document.select(latestUpdatesNextPageSelector()).first() != null
return MangasPage(mangas, nextPage)
}
private fun queryUtilsParse(response: Response): MangasPage {
val document = response.asJsoup()
val mangas = document.select("tbody > tr")
.map { element -> searchUtilsFromElement(element) }
return MangasPage(mangas, false)
}
private fun queryHistoryParse(response: Response): MangasPage {
val json = json.decodeFromString<JsonObject>(response.body.string())
val html = json.jsonObject["html"]!!.jsonPrimitive.content
val document = Jsoup.parse(html, response.request.url.toString())
val mangas = document.select(".my-history-item")
.map { element -> searchHistoryFromElement(element) }
return MangasPage(mangas, false)
}
private fun searchUtilsFromElement(element: Element): SManga {
val manga = SManga.create()
manga.setUrlWithoutDomain(element.select("td a").attr("href"))
manga.title = element.select("td a").text()
manga.thumbnail_url = element.select("img").attr("abs:src")
return manga
}
private fun searchHistoryFromElement(element: Element): SManga {
val manga = SManga.create()
manga.setUrlWithoutDomain(element.select(".position-relative a").attr("href"))
manga.title = element.select(".position-relative a").text()
manga.thumbnail_url = element.select("img").attr("abs:src")
return manga
}
open fun formBuilder() = FormBody.Builder().apply {
add("_where", "browse")
add("first", "0")
add("limit", "0")
add("prevPos", "null")
}
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request = throw UnsupportedOperationException("Not used")
override fun searchMangaSelector() = throw UnsupportedOperationException("Not used")
override fun searchMangaFromElement(element: Element) = throw UnsupportedOperationException("Not used")
override fun searchMangaNextPageSelector() = throw UnsupportedOperationException("Not used")
override fun mangaDetailsRequest(manga: SManga): Request {
if (manga.url.startsWith("http")) {
return GET(manga.url, headers)
}
return super.mangaDetailsRequest(manga)
}
override fun mangaDetailsParse(document: Document): SManga {
val infoElement = document.select("div#mainer div.container-fluid")
val manga = SManga.create()
val workStatus = infoElement.select("div.attr-item:contains(original work) span").text()
val uploadStatus = infoElement.select("div.attr-item:contains(upload status) span").text()
manga.title = infoElement.select("h3").text().removeEntities()
manga.author = infoElement.select("div.attr-item:contains(author) span").text()
manga.artist = infoElement.select("div.attr-item:contains(artist) span").text()
manga.status = parseStatus(workStatus, uploadStatus)
manga.genre = infoElement.select(".attr-item b:contains(genres) + span ").joinToString { it.text() }
manga.description = infoElement.select("div.limit-html").text() + "\n" + infoElement.select(".episode-list > .alert-warning").text().trim()
manga.thumbnail_url = document.select("div.attr-cover img")
.attr("abs:src")
return manga
}
private fun parseStatus(workStatus: String?, uploadStatus: String?) = when {
workStatus == null -> SManga.UNKNOWN
workStatus.contains("Ongoing") -> SManga.ONGOING
workStatus.contains("Cancelled") -> SManga.CANCELLED
workStatus.contains("Hiatus") -> SManga.ON_HIATUS
workStatus.contains("Completed") -> when {
uploadStatus?.contains("Ongoing") == true -> SManga.PUBLISHING_FINISHED
else -> SManga.COMPLETED
}
else -> SManga.UNKNOWN
}
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
val url = client.newCall(
GET(
when {
manga.url.startsWith("http") -> manga.url
else -> "$baseUrl${manga.url}"
},
),
).execute().asJsoup()
if (getAltChapterListPref() || checkChapterLists(url)) {
val id = manga.url.substringBeforeLast("/").substringAfterLast("/").trim()
return client.newCall(GET("$baseUrl/rss/series/$id.xml"))
.asObservableSuccess()
.map { altChapterParse(it, manga.title) }
}
return super.fetchChapterList(manga)
}
private fun altChapterParse(response: Response, title: String): List<SChapter> {
return Jsoup.parse(response.body.string(), response.request.url.toString(), Parser.xmlParser())
.select("channel > item").map { item ->
SChapter.create().apply {
url = item.selectFirst("guid")!!.text()
name = item.selectFirst("title")!!.text().substringAfter(title).trim()
date_upload = SimpleDateFormat("E, dd MMM yyyy H:m:s Z", Locale.US).parse(item.selectFirst("pubDate")!!.text())?.time ?: 0L
}
}
}
private fun checkChapterLists(document: Document): Boolean {
return document.select(".episode-list > .alert-warning").text().contains("This comic has been marked as deleted and the chapter list is not available.")
}
override fun chapterListRequest(manga: SManga): Request {
if (manga.url.startsWith("http")) {
return GET(manga.url, headers)
}
return super.chapterListRequest(manga)
}
override fun chapterListSelector() = "div.main div.p-2"
override fun chapterFromElement(element: Element): SChapter {
val chapter = SChapter.create()
val urlElement = element.select("a.chapt")
val group = element.select("div.extra > a:not(.ps-3)").text()
val time = element.select("div.extra > i.ps-3").text()
chapter.setUrlWithoutDomain(urlElement.attr("href"))
chapter.name = urlElement.text()
if (group != "") {
chapter.scanlator = group
}
if (time != "") {
chapter.date_upload = parseChapterDate(time)
}
return chapter
}
private fun parseChapterDate(date: String): Long {
val value = date.split(' ')[0].toInt()
return when {
"secs" in date -> Calendar.getInstance().apply {
add(Calendar.SECOND, value * -1)
}.timeInMillis
"mins" in date -> Calendar.getInstance().apply {
add(Calendar.MINUTE, value * -1)
}.timeInMillis
"hours" in date -> Calendar.getInstance().apply {
add(Calendar.HOUR_OF_DAY, value * -1)
}.timeInMillis
"days" in date -> Calendar.getInstance().apply {
add(Calendar.DATE, value * -1)
}.timeInMillis
"weeks" in date -> Calendar.getInstance().apply {
add(Calendar.DATE, value * 7 * -1)
}.timeInMillis
"months" in date -> Calendar.getInstance().apply {
add(Calendar.MONTH, value * -1)
}.timeInMillis
"years" in date -> Calendar.getInstance().apply {
add(Calendar.YEAR, value * -1)
}.timeInMillis
"sec" in date -> Calendar.getInstance().apply {
add(Calendar.SECOND, value * -1)
}.timeInMillis
"min" in date -> Calendar.getInstance().apply {
add(Calendar.MINUTE, value * -1)
}.timeInMillis
"hour" in date -> Calendar.getInstance().apply {
add(Calendar.HOUR_OF_DAY, value * -1)
}.timeInMillis
"day" in date -> Calendar.getInstance().apply {
add(Calendar.DATE, value * -1)
}.timeInMillis
"week" in date -> Calendar.getInstance().apply {
add(Calendar.DATE, value * 7 * -1)
}.timeInMillis
"month" in date -> Calendar.getInstance().apply {
add(Calendar.MONTH, value * -1)
}.timeInMillis
"year" in date -> Calendar.getInstance().apply {
add(Calendar.YEAR, value * -1)
}.timeInMillis
else -> {
return 0
}
}
}
override fun pageListRequest(chapter: SChapter): Request {
if (chapter.url.startsWith("http")) {
return GET(chapter.url, headers)
}
return super.pageListRequest(chapter)
}
override fun pageListParse(document: Document): List<Page> {
val script = document.selectFirst("script:containsData(imgHttpLis):containsData(batoWord):containsData(batoPass)")?.html()
?: throw RuntimeException("Couldn't find script with image data.")
val imgHttpLisString = script.substringAfter("const imgHttpLis =").substringBefore(";").trim()
val imgHttpLis = json.parseToJsonElement(imgHttpLisString).jsonArray.map { it.jsonPrimitive.content }
val batoWord = script.substringAfter("const batoWord =").substringBefore(";").trim()
val batoPass = script.substringAfter("const batoPass =").substringBefore(";").trim()
val evaluatedPass: String = Deobfuscator.deobfuscateJsPassword(batoPass)
val imgAccListString = CryptoAES.decrypt(batoWord.removeSurrounding("\""), evaluatedPass)
val imgAccList = json.parseToJsonElement(imgAccListString).jsonArray.map { it.jsonPrimitive.content }
return imgHttpLis.zip(imgAccList).mapIndexed { i, (imgUrl, imgAcc) ->
Page(i, imageUrl = "$imgUrl?$imgAcc")
}
}
override fun imageUrlParse(document: Document): String = throw UnsupportedOperationException("Not used")
private fun String.removeEntities(): String = Parser.unescapeEntities(this, true)
override fun getFilterList() = FilterList(
LetterFilter(getLetterFilter(), 0),
Filter.Separator(),
Filter.Header("NOTE: Ignored if using text search!"),
Filter.Separator(),
SortFilter(getSortFilter().map { it.name }.toTypedArray()),
StatusFilter(getStatusFilter(), 0),
GenreGroupFilter(getGenreFilter()),
OriginGroupFilter(getOrginFilter()),
LangGroupFilter(getLangFilter()),
MinChapterTextFilter(),
MaxChapterTextFilter(),
Filter.Separator(),
Filter.Header("NOTE: Filters below are incompatible with any other filters!"),
Filter.Header("NOTE: Login Required!"),
Filter.Separator(),
UtilsFilter(getUtilsFilter(), 0),
HistoryFilter(getHistoryFilter(), 0),
)
class SelectFilterOption(val name: String, val value: String)
class CheckboxFilterOption(val value: String, name: String, default: Boolean = false) : Filter.CheckBox(name, default)
class TriStateFilterOption(val value: String, name: String, default: Int = 0) : Filter.TriState(name, default)
abstract class SelectFilter(name: String, private val options: List<SelectFilterOption>, default: Int = 0) : Filter.Select<String>(name, options.map { it.name }.toTypedArray(), default) {
val selected: String
get() = options[state].value
}
abstract class CheckboxGroupFilter(name: String, options: List<CheckboxFilterOption>) : Filter.Group<CheckboxFilterOption>(name, options) {
val selected: List<String>
get() = state.filter { it.state }.map { it.value }
}
abstract class TriStateGroupFilter(name: String, options: List<TriStateFilterOption>) : Filter.Group<TriStateFilterOption>(name, options) {
val included: List<String>
get() = state.filter { it.isIncluded() }.map { it.value }
val excluded: List<String>
get() = state.filter { it.isExcluded() }.map { it.value }
}
abstract class TextFilter(name: String) : Filter.Text(name)
class SortFilter(sortables: Array<String>) : Filter.Sort("Sort", sortables, Selection(5, false))
class StatusFilter(options: List<SelectFilterOption>, default: Int) : SelectFilter("Status", options, default)
class OriginGroupFilter(options: List<CheckboxFilterOption>) : CheckboxGroupFilter("Origin", options)
class GenreGroupFilter(options: List<TriStateFilterOption>) : TriStateGroupFilter("Genre", options)
class MinChapterTextFilter : TextFilter("Min. Chapters")
class MaxChapterTextFilter : TextFilter("Max. Chapters")
class LangGroupFilter(options: List<CheckboxFilterOption>) : CheckboxGroupFilter("Languages", options)
class LetterFilter(options: List<SelectFilterOption>, default: Int) : SelectFilter("Letter matching mode (Slow)", options, default)
class UtilsFilter(options: List<SelectFilterOption>, default: Int) : SelectFilter("Utils comic list", options, default)
class HistoryFilter(options: List<SelectFilterOption>, default: Int) : SelectFilter("Personal list", options, default)
private fun getLetterFilter() = listOf(
SelectFilterOption("Disabled", "disabled"),
SelectFilterOption("Enabled", "enabled"),
)
private fun getSortFilter() = listOf(
SelectFilterOption("Z-A", "title"),
SelectFilterOption("Last Updated", "update"),
SelectFilterOption("Newest Added", "create"),
SelectFilterOption("Most Views Totally", "views_a"),
SelectFilterOption("Most Views 365 days", "views_y"),
SelectFilterOption("Most Views 30 days", "views_m"),
SelectFilterOption("Most Views 7 days", "views_w"),
SelectFilterOption("Most Views 24 hours", "views_d"),
SelectFilterOption("Most Views 60 minutes", "views_h"),
)
private fun getHistoryFilter() = listOf(
SelectFilterOption("None", ""),
SelectFilterOption("My History", "history"),
SelectFilterOption("My Updates", "updates"),
)
private fun getUtilsFilter() = listOf(
SelectFilterOption("None", ""),
SelectFilterOption("Comics: I Created", "i-created"),
SelectFilterOption("Comics: I Modified", "i-modified"),
SelectFilterOption("Comics: I Uploaded", "i-uploaded"),
SelectFilterOption("Comics: Authorized to me", "i-authorized"),
SelectFilterOption("Comics: Draft Status", "status-draft"),
SelectFilterOption("Comics: Hidden Status", "status-hidden"),
SelectFilterOption("Ongoing and Not updated in 30-60 days", "not-updated-30-60"),
SelectFilterOption("Ongoing and Not updated in 60-90 days", "not-updated-60-90"),
SelectFilterOption("Ongoing and Not updated in 90-180 days", "not-updated-90-180"),
SelectFilterOption("Ongoing and Not updated in 180-360 days", "not-updated-180-360"),
SelectFilterOption("Ongoing and Not updated in 360-1000 days", "not-updated-360-1000"),
SelectFilterOption("Ongoing and Not updated more than 1000 days", "not-updated-1000"),
)
private fun getStatusFilter() = listOf(
SelectFilterOption("All", ""),
SelectFilterOption("Pending", "pending"),
SelectFilterOption("Ongoing", "ongoing"),
SelectFilterOption("Completed", "completed"),
SelectFilterOption("Hiatus", "hiatus"),
SelectFilterOption("Cancelled", "cancelled"),
)
private fun getOrginFilter() = listOf(
// Values exported from publish.bato.to
CheckboxFilterOption("zh", "Chinese"),
CheckboxFilterOption("en", "English"),
CheckboxFilterOption("ja", "Japanese"),
CheckboxFilterOption("ko", "Korean"),
CheckboxFilterOption("af", "Afrikaans"),
CheckboxFilterOption("sq", "Albanian"),
CheckboxFilterOption("am", "Amharic"),
CheckboxFilterOption("ar", "Arabic"),
CheckboxFilterOption("hy", "Armenian"),
CheckboxFilterOption("az", "Azerbaijani"),
CheckboxFilterOption("be", "Belarusian"),
CheckboxFilterOption("bn", "Bengali"),
CheckboxFilterOption("bs", "Bosnian"),
CheckboxFilterOption("bg", "Bulgarian"),
CheckboxFilterOption("my", "Burmese"),
CheckboxFilterOption("km", "Cambodian"),
CheckboxFilterOption("ca", "Catalan"),
CheckboxFilterOption("ceb", "Cebuano"),
CheckboxFilterOption("zh_hk", "Chinese (Cantonese)"),
CheckboxFilterOption("zh_tw", "Chinese (Traditional)"),
CheckboxFilterOption("hr", "Croatian"),
CheckboxFilterOption("cs", "Czech"),
CheckboxFilterOption("da", "Danish"),
CheckboxFilterOption("nl", "Dutch"),
CheckboxFilterOption("en_us", "English (United States)"),
CheckboxFilterOption("eo", "Esperanto"),
CheckboxFilterOption("et", "Estonian"),
CheckboxFilterOption("fo", "Faroese"),
CheckboxFilterOption("fil", "Filipino"),
CheckboxFilterOption("fi", "Finnish"),
CheckboxFilterOption("fr", "French"),
CheckboxFilterOption("ka", "Georgian"),
CheckboxFilterOption("de", "German"),
CheckboxFilterOption("el", "Greek"),
CheckboxFilterOption("gn", "Guarani"),
CheckboxFilterOption("gu", "Gujarati"),
CheckboxFilterOption("ht", "Haitian Creole"),
CheckboxFilterOption("ha", "Hausa"),
CheckboxFilterOption("he", "Hebrew"),
CheckboxFilterOption("hi", "Hindi"),
CheckboxFilterOption("hu", "Hungarian"),
CheckboxFilterOption("is", "Icelandic"),
CheckboxFilterOption("ig", "Igbo"),
CheckboxFilterOption("id", "Indonesian"),
CheckboxFilterOption("ga", "Irish"),
CheckboxFilterOption("it", "Italian"),
CheckboxFilterOption("jv", "Javanese"),
CheckboxFilterOption("kn", "Kannada"),
CheckboxFilterOption("kk", "Kazakh"),
CheckboxFilterOption("ku", "Kurdish"),
CheckboxFilterOption("ky", "Kyrgyz"),
CheckboxFilterOption("lo", "Laothian"),
CheckboxFilterOption("lv", "Latvian"),
CheckboxFilterOption("lt", "Lithuanian"),
CheckboxFilterOption("lb", "Luxembourgish"),
CheckboxFilterOption("mk", "Macedonian"),
CheckboxFilterOption("mg", "Malagasy"),
CheckboxFilterOption("ms", "Malay"),
CheckboxFilterOption("ml", "Malayalam"),
CheckboxFilterOption("mt", "Maltese"),
CheckboxFilterOption("mi", "Maori"),
CheckboxFilterOption("mr", "Marathi"),
CheckboxFilterOption("mo", "Moldavian"),
CheckboxFilterOption("mn", "Mongolian"),
CheckboxFilterOption("ne", "Nepali"),
CheckboxFilterOption("no", "Norwegian"),
CheckboxFilterOption("ny", "Nyanja"),
CheckboxFilterOption("ps", "Pashto"),
CheckboxFilterOption("fa", "Persian"),
CheckboxFilterOption("pl", "Polish"),
CheckboxFilterOption("pt", "Portuguese"),
CheckboxFilterOption("pt_br", "Portuguese (Brazil)"),
CheckboxFilterOption("ro", "Romanian"),
CheckboxFilterOption("rm", "Romansh"),
CheckboxFilterOption("ru", "Russian"),
CheckboxFilterOption("sm", "Samoan"),
CheckboxFilterOption("sr", "Serbian"),
CheckboxFilterOption("sh", "Serbo-Croatian"),
CheckboxFilterOption("st", "Sesotho"),
CheckboxFilterOption("sn", "Shona"),
CheckboxFilterOption("sd", "Sindhi"),
CheckboxFilterOption("si", "Sinhalese"),
CheckboxFilterOption("sk", "Slovak"),
CheckboxFilterOption("sl", "Slovenian"),
CheckboxFilterOption("so", "Somali"),
CheckboxFilterOption("es", "Spanish"),
CheckboxFilterOption("es_419", "Spanish (Latin America)"),
CheckboxFilterOption("sw", "Swahili"),
CheckboxFilterOption("sv", "Swedish"),
CheckboxFilterOption("tg", "Tajik"),
CheckboxFilterOption("ta", "Tamil"),
CheckboxFilterOption("th", "Thai"),
CheckboxFilterOption("ti", "Tigrinya"),
CheckboxFilterOption("to", "Tonga"),
CheckboxFilterOption("tr", "Turkish"),
CheckboxFilterOption("tk", "Turkmen"),
CheckboxFilterOption("uk", "Ukrainian"),
CheckboxFilterOption("ur", "Urdu"),
CheckboxFilterOption("uz", "Uzbek"),
CheckboxFilterOption("vi", "Vietnamese"),
CheckboxFilterOption("yo", "Yoruba"),
CheckboxFilterOption("zu", "Zulu"),
CheckboxFilterOption("_t", "Other"),
)
private fun getGenreFilter() = listOf(
TriStateFilterOption("artbook", "Artbook"),
TriStateFilterOption("cartoon", "Cartoon"),
TriStateFilterOption("comic", "Comic"),
TriStateFilterOption("doujinshi", "Doujinshi"),
TriStateFilterOption("imageset", "Imageset"),
TriStateFilterOption("manga", "Manga"),
TriStateFilterOption("manhua", "Manhua"),
TriStateFilterOption("manhwa", "Manhwa"),
TriStateFilterOption("webtoon", "Webtoon"),
TriStateFilterOption("western", "Western"),
TriStateFilterOption("shoujo", "Shoujo(G)"),
TriStateFilterOption("shounen", "Shounen(B)"),
TriStateFilterOption("josei", "Josei(W)"),
TriStateFilterOption("seinen", "Seinen(M)"),
TriStateFilterOption("yuri", "Yuri(GL)"),
TriStateFilterOption("yaoi", "Yaoi(BL)"),
TriStateFilterOption("futa", "Futa(WL)"),
TriStateFilterOption("bara", "Bara(ML)"),
TriStateFilterOption("gore", "Gore"),
TriStateFilterOption("bloody", "Bloody"),
TriStateFilterOption("violence", "Violence"),
TriStateFilterOption("ecchi", "Ecchi"),
TriStateFilterOption("adult", "Adult"),
TriStateFilterOption("mature", "Mature"),
TriStateFilterOption("smut", "Smut"),
TriStateFilterOption("hentai", "Hentai"),
TriStateFilterOption("_4_koma", "4-Koma"),
TriStateFilterOption("action", "Action"),
TriStateFilterOption("adaptation", "Adaptation"),
TriStateFilterOption("adventure", "Adventure"),
TriStateFilterOption("age_gap", "Age Gap"),
TriStateFilterOption("aliens", "Aliens"),
TriStateFilterOption("animals", "Animals"),
TriStateFilterOption("anthology", "Anthology"),
TriStateFilterOption("beasts", "Beasts"),
TriStateFilterOption("bodyswap", "Bodyswap"),
TriStateFilterOption("cars", "cars"),
TriStateFilterOption("cheating_infidelity", "Cheating/Infidelity"),
TriStateFilterOption("childhood_friends", "Childhood Friends"),
TriStateFilterOption("college_life", "College Life"),
TriStateFilterOption("comedy", "Comedy"),
TriStateFilterOption("contest_winning", "Contest Winning"),
TriStateFilterOption("cooking", "Cooking"),
TriStateFilterOption("crime", "crime"),
TriStateFilterOption("crossdressing", "Crossdressing"),
TriStateFilterOption("delinquents", "Delinquents"),
TriStateFilterOption("dementia", "Dementia"),
TriStateFilterOption("demons", "Demons"),
TriStateFilterOption("drama", "Drama"),
TriStateFilterOption("dungeons", "Dungeons"),
TriStateFilterOption("emperor_daughte", "Emperor's Daughter"),
TriStateFilterOption("fantasy", "Fantasy"),
TriStateFilterOption("fan_colored", "Fan-Colored"),
TriStateFilterOption("fetish", "Fetish"),
TriStateFilterOption("full_color", "Full Color"),
TriStateFilterOption("game", "Game"),
TriStateFilterOption("gender_bender", "Gender Bender"),
TriStateFilterOption("genderswap", "Genderswap"),
TriStateFilterOption("ghosts", "Ghosts"),
TriStateFilterOption("gyaru", "Gyaru"),
TriStateFilterOption("harem", "Harem"),
TriStateFilterOption("harlequin", "Harlequin"),
TriStateFilterOption("historical", "Historical"),
TriStateFilterOption("horror", "Horror"),
TriStateFilterOption("incest", "Incest"),
TriStateFilterOption("isekai", "Isekai"),
TriStateFilterOption("kids", "Kids"),
TriStateFilterOption("loli", "Loli"),
TriStateFilterOption("magic", "Magic"),
TriStateFilterOption("magical_girls", "Magical Girls"),
TriStateFilterOption("martial_arts", "Martial Arts"),
TriStateFilterOption("mecha", "Mecha"),
TriStateFilterOption("medical", "Medical"),
TriStateFilterOption("military", "Military"),
TriStateFilterOption("monster_girls", "Monster Girls"),
TriStateFilterOption("monsters", "Monsters"),
TriStateFilterOption("music", "Music"),
TriStateFilterOption("mystery", "Mystery"),
TriStateFilterOption("netorare", "Netorare/NTR"),
TriStateFilterOption("ninja", "Ninja"),
TriStateFilterOption("office_workers", "Office Workers"),
TriStateFilterOption("omegaverse", "Omegaverse"),
TriStateFilterOption("oneshot", "Oneshot"),
TriStateFilterOption("parody", "parody"),
TriStateFilterOption("philosophical", "Philosophical"),
TriStateFilterOption("police", "Police"),
TriStateFilterOption("post_apocalyptic", "Post-Apocalyptic"),
TriStateFilterOption("psychological", "Psychological"),
TriStateFilterOption("regression", "Regression"),
TriStateFilterOption("reincarnation", "Reincarnation"),
TriStateFilterOption("reverse_harem", "Reverse Harem"),
TriStateFilterOption("reverse_isekai", "Reverse Isekai"),
TriStateFilterOption("romance", "Romance"),
TriStateFilterOption("royal_family", "Royal Family"),
TriStateFilterOption("royalty", "Royalty"),
TriStateFilterOption("samurai", "Samurai"),
TriStateFilterOption("school_life", "School Life"),
TriStateFilterOption("sci_fi", "Sci-Fi"),
TriStateFilterOption("shota", "Shota"),
TriStateFilterOption("shoujo_ai", "Shoujo Ai"),
TriStateFilterOption("shounen_ai", "Shounen Ai"),
TriStateFilterOption("showbiz", "Showbiz"),
TriStateFilterOption("slice_of_life", "Slice of Life"),
TriStateFilterOption("sm_bdsm", "SM/BDSM/SUB-DOM"),
TriStateFilterOption("space", "Space"),
TriStateFilterOption("sports", "Sports"),
TriStateFilterOption("super_power", "Super Power"),
TriStateFilterOption("superhero", "Superhero"),
TriStateFilterOption("supernatural", "Supernatural"),
TriStateFilterOption("survival", "Survival"),
TriStateFilterOption("thriller", "Thriller"),
TriStateFilterOption("time_travel", "Time Travel"),
TriStateFilterOption("tower_climbing", "Tower Climbing"),
TriStateFilterOption("traditional_games", "Traditional Games"),
TriStateFilterOption("tragedy", "Tragedy"),
TriStateFilterOption("transmigration", "Transmigration"),
TriStateFilterOption("vampires", "Vampires"),
TriStateFilterOption("villainess", "Villainess"),
TriStateFilterOption("video_games", "Video Games"),
TriStateFilterOption("virtual_reality", "Virtual Reality"),
TriStateFilterOption("wuxia", "Wuxia"),
TriStateFilterOption("xianxia", "Xianxia"),
TriStateFilterOption("xuanhuan", "Xuanhuan"),
TriStateFilterOption("zombies", "Zombies"),
// Hidden Genres
TriStateFilterOption("shotacon", "shotacon"),
TriStateFilterOption("lolicon", "lolicon"),
TriStateFilterOption("award_winning", "Award Winning"),
TriStateFilterOption("youkai", "Youkai"),
TriStateFilterOption("uncategorized", "Uncategorized"),
)
private fun getLangFilter() = listOf(
// Values exported from publish.bato.to
CheckboxFilterOption("en", "English"),
CheckboxFilterOption("ar", "Arabic"),
CheckboxFilterOption("bg", "Bulgarian"),
CheckboxFilterOption("zh", "Chinese"),
CheckboxFilterOption("cs", "Czech"),
CheckboxFilterOption("da", "Danish"),
CheckboxFilterOption("nl", "Dutch"),
CheckboxFilterOption("fil", "Filipino"),
CheckboxFilterOption("fi", "Finnish"),
CheckboxFilterOption("fr", "French"),
CheckboxFilterOption("de", "German"),
CheckboxFilterOption("el", "Greek"),
CheckboxFilterOption("he", "Hebrew"),
CheckboxFilterOption("hi", "Hindi"),
CheckboxFilterOption("hu", "Hungarian"),
CheckboxFilterOption("id", "Indonesian"),
CheckboxFilterOption("it", "Italian"),
CheckboxFilterOption("ja", "Japanese"),
CheckboxFilterOption("ko", "Korean"),
CheckboxFilterOption("ms", "Malay"),
CheckboxFilterOption("pl", "Polish"),
CheckboxFilterOption("pt", "Portuguese"),
CheckboxFilterOption("pt_br", "Portuguese (Brazil)"),
CheckboxFilterOption("ro", "Romanian"),
CheckboxFilterOption("ru", "Russian"),
CheckboxFilterOption("es", "Spanish"),
CheckboxFilterOption("es_419", "Spanish (Latin America)"),
CheckboxFilterOption("sv", "Swedish"),
CheckboxFilterOption("th", "Thai"),
CheckboxFilterOption("tr", "Turkish"),
CheckboxFilterOption("uk", "Ukrainian"),
CheckboxFilterOption("vi", "Vietnamese"),
CheckboxFilterOption("af", "Afrikaans"),
CheckboxFilterOption("sq", "Albanian"),
CheckboxFilterOption("am", "Amharic"),
CheckboxFilterOption("hy", "Armenian"),
CheckboxFilterOption("az", "Azerbaijani"),
CheckboxFilterOption("be", "Belarusian"),
CheckboxFilterOption("bn", "Bengali"),
CheckboxFilterOption("bs", "Bosnian"),
CheckboxFilterOption("my", "Burmese"),
CheckboxFilterOption("km", "Cambodian"),
CheckboxFilterOption("ca", "Catalan"),
CheckboxFilterOption("ceb", "Cebuano"),
CheckboxFilterOption("zh_hk", "Chinese (Cantonese)"),
CheckboxFilterOption("zh_tw", "Chinese (Traditional)"),
CheckboxFilterOption("hr", "Croatian"),
CheckboxFilterOption("en_us", "English (United States)"),
CheckboxFilterOption("eo", "Esperanto"),
CheckboxFilterOption("et", "Estonian"),
CheckboxFilterOption("fo", "Faroese"),
CheckboxFilterOption("ka", "Georgian"),
CheckboxFilterOption("gn", "Guarani"),
CheckboxFilterOption("gu", "Gujarati"),
CheckboxFilterOption("ht", "Haitian Creole"),
CheckboxFilterOption("ha", "Hausa"),
CheckboxFilterOption("is", "Icelandic"),
CheckboxFilterOption("ig", "Igbo"),
CheckboxFilterOption("ga", "Irish"),
CheckboxFilterOption("jv", "Javanese"),
CheckboxFilterOption("kn", "Kannada"),
CheckboxFilterOption("kk", "Kazakh"),
CheckboxFilterOption("ku", "Kurdish"),
CheckboxFilterOption("ky", "Kyrgyz"),
CheckboxFilterOption("lo", "Laothian"),
CheckboxFilterOption("lv", "Latvian"),
CheckboxFilterOption("lt", "Lithuanian"),
CheckboxFilterOption("lb", "Luxembourgish"),
CheckboxFilterOption("mk", "Macedonian"),
CheckboxFilterOption("mg", "Malagasy"),
CheckboxFilterOption("ml", "Malayalam"),
CheckboxFilterOption("mt", "Maltese"),
CheckboxFilterOption("mi", "Maori"),
CheckboxFilterOption("mr", "Marathi"),
CheckboxFilterOption("mo", "Moldavian"),
CheckboxFilterOption("mn", "Mongolian"),
CheckboxFilterOption("ne", "Nepali"),
CheckboxFilterOption("no", "Norwegian"),
CheckboxFilterOption("ny", "Nyanja"),
CheckboxFilterOption("ps", "Pashto"),
CheckboxFilterOption("fa", "Persian"),
CheckboxFilterOption("rm", "Romansh"),
CheckboxFilterOption("sm", "Samoan"),
CheckboxFilterOption("sr", "Serbian"),
CheckboxFilterOption("sh", "Serbo-Croatian"),
CheckboxFilterOption("st", "Sesotho"),
CheckboxFilterOption("sn", "Shona"),
CheckboxFilterOption("sd", "Sindhi"),
CheckboxFilterOption("si", "Sinhalese"),
CheckboxFilterOption("sk", "Slovak"),
CheckboxFilterOption("sl", "Slovenian"),
CheckboxFilterOption("so", "Somali"),
CheckboxFilterOption("sw", "Swahili"),
CheckboxFilterOption("tg", "Tajik"),
CheckboxFilterOption("ta", "Tamil"),
CheckboxFilterOption("ti", "Tigrinya"),
CheckboxFilterOption("to", "Tonga"),
CheckboxFilterOption("tk", "Turkmen"),
CheckboxFilterOption("ur", "Urdu"),
CheckboxFilterOption("uz", "Uzbek"),
CheckboxFilterOption("yo", "Yoruba"),
CheckboxFilterOption("zu", "Zulu"),
CheckboxFilterOption("_t", "Other"),
// Lang options from bato.to brows not in publish.bato.to
CheckboxFilterOption("eu", "Basque"),
CheckboxFilterOption("pt-PT", "Portuguese (Portugal)"),
).filterNot { it.value == siteLang }
companion object {
private const val MIRROR_PREF_KEY = "MIRROR"
private const val MIRROR_PREF_TITLE = "Mirror"
private val MIRROR_PREF_ENTRIES = arrayOf(
"bato.to",
"batocomic.com",
"batocomic.net",
"batocomic.org",
"batotoo.com",
"batotwo.com",
"battwo.com",
"comiko.net",
"comiko.org",
"mangatoto.com",
"mangatoto.net",
"mangatoto.org",
"readtoto.com",
"readtoto.net",
"readtoto.org",
"dto.to",
"hto.to",
"mto.to",
"wto.to",
"xbato.com",
"xbato.net",
"xbato.org",
"zbato.com",
"zbato.net",
"zbato.org",
)
private val MIRROR_PREF_ENTRY_VALUES = MIRROR_PREF_ENTRIES.map { "https://$it" }.toTypedArray()
private val MIRROR_PREF_DEFAULT_VALUE = MIRROR_PREF_ENTRY_VALUES[0]
private const val ALT_CHAPTER_LIST_PREF_KEY = "ALT_CHAPTER_LIST"
private const val ALT_CHAPTER_LIST_PREF_TITLE = "Alternative Chapter List"
private const val ALT_CHAPTER_LIST_PREF_SUMMARY = "If checked, uses an alternate chapter list"
private const val ALT_CHAPTER_LIST_PREF_DEFAULT_VALUE = false
}
}

View File

@ -0,0 +1,122 @@
package eu.kanade.tachiyomi.extension.all.batoto
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceFactory
class BatoToFactory : SourceFactory {
override fun createSources(): List<Source> = languages.map { BatoTo(it.lang, it.siteLang) }
}
class LanguageOption(val lang: String, val siteLang: String = lang)
private val languages = listOf(
LanguageOption("all", ""),
// Lang options from publish.bato.to
LanguageOption("en"),
LanguageOption("ar"),
LanguageOption("bg"),
LanguageOption("zh"),
LanguageOption("cs"),
LanguageOption("da"),
LanguageOption("nl"),
LanguageOption("fil"),
LanguageOption("fi"),
LanguageOption("fr"),
LanguageOption("de"),
LanguageOption("el"),
LanguageOption("he"),
LanguageOption("hi"),
LanguageOption("hu"),
LanguageOption("id"),
LanguageOption("it"),
LanguageOption("ja"),
LanguageOption("ko"),
LanguageOption("ms"),
LanguageOption("pl"),
LanguageOption("pt"),
LanguageOption("pt-BR", "pt_br"),
LanguageOption("ro"),
LanguageOption("ru"),
LanguageOption("es"),
LanguageOption("es-419", "es_419"),
LanguageOption("sv"),
LanguageOption("th"),
LanguageOption("tr"),
LanguageOption("uk"),
LanguageOption("vi"),
LanguageOption("af"),
LanguageOption("sq"),
LanguageOption("am"),
LanguageOption("hy"),
LanguageOption("az"),
LanguageOption("be"),
LanguageOption("bn"),
LanguageOption("bs"),
LanguageOption("my"),
LanguageOption("km"),
LanguageOption("ca"),
LanguageOption("ceb"),
LanguageOption("zh-Hans", "zh_hk"),
LanguageOption("zh-Hant", "zh_tw"),
LanguageOption("hr"),
LanguageOption("en-US", "en_us"),
LanguageOption("eo"),
LanguageOption("et"),
LanguageOption("fo"),
LanguageOption("ka"),
LanguageOption("gn"),
LanguageOption("gu"),
LanguageOption("ht"),
LanguageOption("ha"),
LanguageOption("is"),
LanguageOption("ig"),
LanguageOption("ga"),
LanguageOption("jv"),
LanguageOption("kn"),
LanguageOption("kk"),
LanguageOption("ku"),
LanguageOption("ky"),
LanguageOption("lo"),
LanguageOption("lv"),
LanguageOption("lt"),
LanguageOption("lb"),
LanguageOption("mk"),
LanguageOption("mg"),
LanguageOption("ml"),
LanguageOption("mt"),
LanguageOption("mi"),
LanguageOption("mr"),
LanguageOption("mo", "ro-MD"),
LanguageOption("mn"),
LanguageOption("ne"),
LanguageOption("no"),
LanguageOption("ny"),
LanguageOption("ps"),
LanguageOption("fa"),
LanguageOption("rm"),
LanguageOption("sm"),
LanguageOption("sr"),
LanguageOption("sh"),
LanguageOption("st"),
LanguageOption("sn"),
LanguageOption("sd"),
LanguageOption("si"),
LanguageOption("sk"),
LanguageOption("sl"),
LanguageOption("so"),
LanguageOption("sw"),
LanguageOption("tg"),
LanguageOption("ta"),
LanguageOption("ti"),
LanguageOption("to"),
LanguageOption("tk"),
LanguageOption("ur"),
LanguageOption("uz"),
LanguageOption("yo"),
LanguageOption("zu"),
LanguageOption("other", "_t"),
// Lang options from bato.to brows not in publish.bato.to
LanguageOption("eu"),
LanguageOption("pt-PT", "pt_pt"),
// Lang options that got removed
// Pair("xh", "xh"),
)

View File

@ -0,0 +1,51 @@
package eu.kanade.tachiyomi.extension.all.batoto
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Intent
import android.os.Bundle
import android.util.Log
import kotlin.system.exitProcess
class BatoToUrlActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val host = intent?.data?.host
val pathSegments = intent?.data?.pathSegments
if (host != null && pathSegments != null) {
val query = fromBatoTo(pathSegments)
if (query == null) {
Log.e("BatoToUrlActivity", "Unable to parse URI from intent $intent")
finish()
exitProcess(1)
}
val mainIntent = Intent().apply {
action = "eu.kanade.tachiyomi.SEARCH"
putExtra("query", query)
putExtra("filter", packageName)
}
try {
startActivity(mainIntent)
} catch (e: ActivityNotFoundException) {
Log.e("BatoToUrlActivity", e.toString())
}
}
finish()
exitProcess(0)
}
private fun fromBatoTo(pathSegments: MutableList<String>): String? {
return if (pathSegments.size >= 2) {
val id = pathSegments[1]
"ID:$id"
} else {
null
}
}
}

View File

@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity
android:name=".all.mangadex.MangadexUrlActivity"
android:excludeFromRecents="true"
android:exported="true"
android:theme="@android:style/Theme.NoDisplay">
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:host="mangadex.org" />
<data android:host="canary.mangadex.dev" />
<data android:scheme="https" />
<data android:pathPattern="/title/..*" />
<data android:pathPattern="/manga/..*" />
<data android:pathPattern="/chapter/..*" />
<data android:pathPattern="/group/..*" />
<data android:pathPattern="/author/..*" />
<data android:pathPattern="/user/..*" />
<data android:pathPattern="/list/..*" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@ -0,0 +1,70 @@
# MangaDex
Table of Content
- [FAQ](#FAQ)
- [Version 5 API Rewrite](#version-5-api-rewrite)
- [Guides](#Guides)
- [How can I block particular Scanlator Groups?](#how-can-i-block-particular-scanlator-groups)
Don't find the question you are look for go check out our general FAQs and Guides over at [Extension FAQ](https://tachiyomi.org/help/faq/#extensions) or [Getting Started](https://tachiyomi.org/help/guides/getting-started/#installation)
## FAQ
### Version 5 API Rewrite
#### Why are all my manga saying "Manga ID format has changed, migrate from MangaDex to MangaDex to continue reading"?
You need to [migrate](https://tachiyomi.org/help/guides/source-migration/) all your MangaDex manga from MangaDex to MangaDex as MangaDex has changed their manga ID system from IDs to UUIDs.
#### Why can I not restore from a JSON backup?
JSON backups are now unusable due to the ID change. You will have to manually re-add your manga.
## Guides
### What does the Status of a Manga in Tachiyomi mean?
Please refer to the following table
| Status in Tachiyomi | in MangaDex | Remarks |
|---------------------|------------------------|---------|
| Ongoing | Publication: Ongoing | |
| Cancelled | Publication: Cancelled | This title was abruptly stopped and will not resume |
| Publishing Finished | Publication: Completed | The title is finished in its original language. However, Translations remain |
| On_Hiatus | Publication: Hiatus | The title is not currently receiving any new chapters |
| Completed | Completed/Cancelled | All chapters are translated and available |
| Unknown | Unknown | There is no info about the Status of this Entry |
### How can I block particular Scanlator Groups?
The **MangaDex** extension allows blocking **Scanlator Groups**. Chapters uploaded by a **Blocked Scanlator Group** will not show up in **Latest** or in **Manga feed** (chapters list). For now, you can only block Groups by entering their UUIDs manually.
Follow the following steps to easily block a group from the Tachiyomi MangaDex extension:
A. Finding the **UUIDs**:
- Go to [https://mangadex.org](https://mangadex.org) and **Search** for the Scanlation Group that you wish to block and view their Group Details
- Using the URL of this page, get the 16-digit alphanumeric string which will be the UUID for that scanlation group
- For Example:
* The Group *Tristan's test scans* has the URL
- [https://mangadex.org/group/6410209a-0f39-4f51-a139-bc559ad61a4f/tristan-s-test-scans](https://mangadex.org/group/6410209a-0f39-4f51-a139-bc559ad61a4f/tristan-s-test-scans)
- Therefore, their UUID will be `6410209a-0f39-4f51-a139-bc559ad61a4f`
* Other Examples include:
+ Azuki Manga | `5fed0576-8b94-4f9a-b6a7-08eecd69800d`
+ Bilibili Comics | `06a9fecb-b608-4f19-b93c-7caab06b7f44`
+ Comikey | `8d8ecf83-8d42-4f8c-add8-60963f9f28d9`
+ INKR | `caa63201-4a17-4b7f-95ff-ed884a2b7e60`
+ MangaHot | `319c1b10-cbd0-4f55-a46e-c4ee17e65139`
+ MangaPlus | `4f1de6a2-f0c5-4ac5-bce5-02c7dbb67deb`
B. Blocking a group using their UUID in Tachiyomi MangaDex extension `v1.2.150+`:
1. Go to **Browse****Extensions**.
1. Click on **MangaDex** extension and then **Settings** under your Language of choice.
1. Tap on the option **Block Groups by UUID** and enter the UUIDs.
- By Default, the following groups are blocked:
```
Azuki Manga, Bilibili Comics, Comikey, INKR, MangaHot & MangaPlus
```
- Which are entered as:
```
5fed0576-8b94-4f9a-b6a7-08eecd69800d, 06a9fecb-b608-4f19-b93c-7caab06b7f44,
8d8ecf83-8d42-4f8c-add8-60963f9f28d9, caa63201-4a17-4b7f-95ff-ed884a2b7e60,
319c1b10-cbd0-4f55-a46e-c4ee17e65139, 4f1de6a2-f0c5-4ac5-bce5-02c7dbb67deb
```

View File

@ -0,0 +1,150 @@
alternative_titles=Alternative titles:
alternative_titles_in_description=Alternative titles in description
alternative_titles_in_description_summary=Include a manga's alternative titles at the end of its description
block_group_by_uuid=Block groups by UUID
block_group_by_uuid_summary=Chapters from blocked groups will not show up in Latest or Manga feed. Enter as a Comma-separated list of group UUIDs
block_uploader_by_uuid=Block uploader by UUID
block_uploader_by_uuid_summary=Chapters from blocked uploaders will not show up in Latest or Manga feed. Enter as a Comma-separated list of uploader UUIDs
content=Content
content_gore=Gore
content_rating=Content rating
content_rating_erotica=Erotica
content_rating_genre=Content rating: %s
content_rating_pornographic=Pornographic
content_rating_safe=Safe
content_rating_suggestive=Suggestive
content_sexual_violence=Sexual violence
cover_quality=Cover quality
cover_quality_low=Low
cover_quality_medium=Medium
cover_quality_original=Original
data_saver=Data saver
data_saver_summary=Enables smaller, more compressed images
excluded_tags_mode=Excluded tags mode
filter_original_languages=Filter original languages
filter_original_languages_summary=Only show content that was originally published in the selected languages in both latest and browse
format=Format
format_adaptation=Adaptation
format_anthology=Anthology
format_award_winning=Award Winning
format_doujinshi=Doujinshi
format_fan_colored=Fan Colored
format_full_color=Full Color
format_long_strip=Long Strip
format_official_colored=Official Colored
format_oneshot=Oneshot
format_user_created=User Created
format_web_comic=Web Comic
format_yonkoma=4-Koma
genre=Genre
genre_action=Action
genre_adventure=Adventure
genre_boys_love=Boy's Love
genre_comedy=Comedy
genre_crime=Crime
genre_drama=Drama
genre_fantasy=Fantasy
genre_girls_love=Girl's Love
genre_historical=Historical
genre_horror=Horror
genre_isekai=Isekai
genre_magical_girls=Magical Girls
genre_mecha=Mecha
genre_medical=Medical
genre_mystery=Mystery
genre_philosophical=Philosophical
genre_romance=Romance
genre_sci_fi=Sci-Fi
genre_slice_of_life=Slice of Life
genre_sports=Sports
genre_superhero=Superhero
genre_thriller=Thriller
genre_tragedy=Tragedy
genre_wuxia=Wuxia
has_available_chapters=Has available chapters
included_tags_mode=Included tags mode
invalid_author_id=Not a valid author ID
invalid_manga_id=Not a valid manga ID
invalid_group_id=Not a valid group ID
invalid_uuids=The text contains invalid UUIDs
migrate_warning=Migrate this entry from MangaDex to MangaDex to update it
mode_and=And
mode_or=Or
no_group=No Group
no_series_in_list=No series in the list
original_language=Original language
original_language_filter_chinese=%s (Manhua)
original_language_filter_japanese=%s (Manga)
original_language_filter_korean=%s (Manhwa)
publication_demographic=Publication demographic
publication_demographic_josei=Josei
publication_demographic_none=None
publication_demographic_seinen=Seinen
publication_demographic_shoujo=Shoujo
publication_demographic_shounen=Shounen
sort=Sort
sort_alphabetic=Alphabetic
sort_chapter_uploaded_at=Chapter uploaded at
sort_content_created_at=Content created at
sort_content_info_updated_at=Content info updated at
sort_number_of_follows=Number of follows
sort_rating=Rating
sort_relevance=Relevance
sort_year=Year
standard_content_rating=Default content rating
standard_content_rating_summary=Show content with the selected ratings by default
standard_https_port=Use HTTPS port 443 only
standard_https_port_summary=Enable to only request image servers that use port 443. This allows users with stricter firewall restrictions to access MangaDex images
status=Status
status_cancelled=Cancelled
status_completed=Completed
status_hiatus=Hiatus
status_ongoing=Ongoing
tags_mode=Tags mode
theme=Theme
theme_aliens=Aliens
theme_animals=Animals
theme_cooking=Cooking
theme_crossdressing=Crossdressing
theme_delinquents=Delinquents
theme_demons=Demons
theme_gender_swap=Genderswap
theme_ghosts=Ghosts
theme_gyaru=Gyaru
theme_harem=Harem
theme_incest=Incest
theme_loli=Loli
theme_mafia=Mafia
theme_magic=Magic
theme_martial_arts=Martial Arts
theme_military=Military
theme_monster_girls=Monster Girls
theme_monsters=Monsters
theme_music=Music
theme_ninja=Ninja
theme_office_workers=Office Workers
theme_police=Police
theme_post_apocalyptic=Post-Apocalyptic
theme_psychological=Psychological
theme_reincarnation=Reincarnation
theme_reverse_harem=Reverse Harem
theme_samurai=Samurai
theme_school_life=School Life
theme_shota=Shota
theme_supernatural=Supernatural
theme_survival=Survival
theme_time_travel=Time Travel
theme_traditional_games=Traditional Games
theme_vampires=Vampires
theme_video_games=Video Games
theme_villainess=Villainess
theme_virtual_reality=Virtual Reality
theme_zombies=Zombies
try_using_first_volume_cover=Attempt to use the first volume cover as cover
try_using_first_volume_cover_summary=May need to manually refresh entries already in library. Otherwise, clear database to have new covers to show up.
unable_to_process_chapter_request=Unable to process Chapter request. HTTP code: %d
uploaded_by=Uploaded by %s
set_custom_useragent=Set custom User-Agent
set_custom_useragent_summary=Keep it as default
set_custom_useragent_dialog=\n\nSpecify a custom user agent\n After each modification, the application needs to be restarted.\n\nDefault value:\n%s
set_custom_useragent_error_invalid=Invalid User-Agent: %s

View File

@ -0,0 +1,108 @@
block_group_by_uuid=Bloquear grupos por UUID
block_group_by_uuid_summary=Los capítulos de los grupos bloqueados no aparecerán en Recientes o en el Feed de mangas. Introduce una coma para separar la lista de UUIDs
block_uploader_by_uuid=Bloquear uploader por UUID
block_uploader_by_uuid_summary=Los capítulos de los uploaders bloqueados no aparecerán en Recientes o en el Feed de mangas. Introduce una coma para separar la lista de UUIDs
content=Contenido
content_rating=Clasificación de contenido
content_rating_erotica=Erótico
content_rating_genre=Clasificación: %s
content_rating_pornographic=Pornográfico
content_rating_safe=Seguro
content_rating_suggestive=Sugestivo
content_sexual_violence=Violencia sexual
cover_quality=Calidad de la portada
cover_quality_low=Bajo
cover_quality_medium=Medio
data_saver=Ahorro de datos
data_saver_summary=Utiliza imágenes más pequeñas y más comprimidas
excluded_tags_mode=Modo de etiquetas excluidas
filter_original_languages=Filtrar por lenguajes
filter_original_languages_summary=Muestra solo el contenido publicado en los idiomas seleccionados en recientes y en la búsqueda
format=Formato
format_adaptation=Adaptación
format_anthology=Antología
format_award_winning=Ganador de premio
format_fan_colored=Coloreado por fans
format_full_color=Todo a color
format_long_strip=Tira larga
format_official_colored=Coloreo oficial
format_user_created=Creado por usuario
genre=Genero
genre_action=Acción
genre_adventure=Aventura
genre_comedy=Comedia
genre_crime=Crimen
genre_fantasy=Fantasia
genre_historical=Histórico
genre_magical_girls=Chicas mágicas
genre_medical=Medico
genre_mystery=Misterio
genre_philosophical=Filosófico
genre_sci_fi=Ciencia ficción
genre_slice_of_life=Recuentos de la vida
genre_sports=Deportes
genre_superhero=Superhéroes
genre_tragedy=Tragedia
has_available_chapters=Tiene capítulos disponibles
included_tags_mode=Modo de etiquetas incluidas
invalid_author_id=ID de autor inválida
invalid_group_id=ID de grupo inválida
migrate_warning=Migre la entrada MangaDex a MangaDex para actualizarla
mode_and=Y
mode_or=O
no_group=Sin grupo
no_series_in_list=No hay series en la lista
original_language=Lenguaje original
publication_demographic=Demografía
publication_demographic_none=Ninguna
sort=Ordenar
sort_alphabetic=Alfabeticamente
sort_chapter_uploaded_at=Capítulo subido en
sort_content_created_at=Contenido creado en
sort_content_info_updated_at=Información del contenido actualizada en
sort_number_of_follows=Número de seguidores
sort_rating=Calificación
sort_relevance=Relevancia
sort_year=Año
standard_content_rating=Clasificación de contenido por defecto
standard_content_rating_summary=Muestra el contenido con la clasificación de contenido seleccionada por defecto
standard_https_port=Utilizar el puerto 443 de HTTPS
standard_https_port_summary=Habilite esta opción solicitar las imágenes a los servidores que usan el puerto 443. Esto permite a los usuarios con restricciones estrictas de firewall acceder a las imagenes en MangaDex
status=Estado
status_cancelled=Cancelado
status_completed=Completado
status_hiatus=Pausado
status_ongoing=Publicandose
tags_mode=Modo de etiquetas
theme=Tema
theme_aliens=Alienígenas
theme_animals=Animales
theme_cooking=Cocina
theme_crossdressing=Travestismo
theme_delinquents=Delincuentes
theme_demons=Demonios
theme_gender_swap=Cambio de sexo
theme_ghosts=Fantasmas
theme_incest=Incesto
theme_magic=Magia
theme_martial_arts=Artes marciales
theme_military=Militar
theme_monster_girls=Chicas monstruo
theme_monsters=Monstruos
theme_music=Musica
theme_office_workers=Oficinistas
theme_police=Policial
theme_post_apocalyptic=Post-apocalíptico
theme_psychological=Psicológico
theme_reincarnation=Reencarnación
theme_reverse_harem=Harem inverso
theme_school_life=Vida escolar
theme_supernatural=Sobrenatural
theme_survival=Supervivencia
theme_time_travel=Viaje en el tiempo
theme_traditional_games=Juegos tradicionales
theme_vampires=Vampiros
theme_villainess=Villana
theme_virtual_reality=Realidad virtual
unable_to_process_chapter_request=No se ha podido procesar la solicitud del capítulo. Código HTTP: %d
uploaded_by=Subido por %s

View File

@ -0,0 +1,119 @@
alternative_titles=Títulos alternativos:
alternative_titles_in_description=Títulos alternativos na descrição
alternative_titles_in_description_summary=Inclui os títulos alternativos das séries no final de cada descrição
block_group_by_uuid=Bloquear grupos por UUID
block_group_by_uuid_summary=Capítulos de grupos bloqueados não irão aparecer no feed de Recentes ou Mangás. Digite uma lista de UUIDs dos grupos separados por vírgulas
block_uploader_by_uuid=Bloquear uploaders por UUID
block_uploader_by_uuid_summary=Capítulos de usuários bloqueados não irão aparecer no feed de Recentes ou Mangás. Digite uma lista de UUIDs dos usuários separados por vírgulas
content=Conteúdo
content_rating=Classificação de conteúdo
content_rating_erotica=Erótico
content_rating_genre=Classificação: %s
content_rating_pornographic=Pornográfico
content_rating_safe=Seguro
content_rating_suggestive=Sugestivo
content_sexual_violence=Violência sexual
cover_quality=Qualidade da capa
cover_quality_low=Baixa
cover_quality_medium=Média
data_saver=Economia de dados
data_saver_summary=Utiliza imagens menores e mais compactadas
excluded_tags_mode=Modo de exclusão de tags
filter_original_languages=Filtrar os idiomas originais
filter_original_languages_summary=Mostra somente conteúdos que foram publicados originalmente nos idiomas selecionados nas seções de recentes e navegar
format=Formato
format_adaptation=Adaptação
format_anthology=Antologia
format_award_winning=Premiado
format_fan_colored=Colorizado por fãs
format_full_color=Colorido
format_long_strip=Vertical
format_official_colored=Colorizado oficialmente
format_user_created=Criado por usuários
genre=Gênero
genre_action=Ação
genre_adventure=Aventura
genre_comedy=Comédia
genre_crime=Crime
genre_fantasy=Fantasia
genre_historical=Histórico
genre_magical_girls=Garotas mágicas
genre_medical=Médico
genre_mystery=Mistério
genre_philosophical=Filosófico
genre_sci_fi=Ficção científica
genre_slice_of_life=Cotidiano
genre_sports=Esportes
genre_superhero=Super-heroi
genre_tragedy=Tragédia
has_available_chapters=Há capítulos disponíveis
included_tags_mode=Modo de inclusão de tags
invalid_author_id=ID do autor inválido
invalid_manga_id=ID do mangá inválido
invalid_group_id=ID do grupo inválido
invalid_uuids=O texto contém UUIDs inválidos
migrate_warning=Migre esta entrada do MangaDex para o MangaDex para atualizar
mode_and=E
mode_or=Ou
no_group=Sem grupo
no_series_in_list=Sem séries na lista
original_language=Idioma original
original_language_filter_japanese=%s (Mangá)
publication_demographic=Demografia da publicação
publication_demographic_none=Nenhuma
sort=Ordenar
sort_alphabetic=Alfabeticamente
sort_chapter_uploaded_at=Upload do capítulo
sort_content_created_at=Criação do conteúdo
sort_content_info_updated_at=Atualização das informações
sort_number_of_follows=Número de seguidores
sort_rating=Nota
sort_relevance=Relevância
sort_year=Ano de lançamento
standard_content_rating=Classificação de conteúdo padrão
standard_content_rating_summary=Mostra os conteúdos com as classificações selecionadas por padrão
standard_https_port=Utilizar somente a porta 443 do HTTPS
standard_https_port_summary=Ative para fazer requisições em somente servidores de imagem que usem a porta 443. Isso permite com que usuários com regras mais restritas de firewall possam acessar as imagens do MangaDex.
status=Estado
status_cancelled=Cancelado
status_completed=Completo
status_hiatus=Hiato
status_ongoing=Em andamento
tags_mode=Modo das tags
theme=Tema
theme_aliens=Alienígenas
theme_animals=Animais
theme_cooking=Culinária
theme_delinquents=Delinquentes
theme_demons=Demônios
theme_gender_swap=Troca de gêneros
theme_ghosts=Fantasmas
theme_harem=Harém
theme_incest=Incesto
theme_mafia=Máfia
theme_magic=Magia
theme_martial_arts=Artes marciais
theme_military=Militar
theme_monster_girls=Garotas monstro
theme_monsters=Monstros
theme_music=Musical
theme_office_workers=Funcionários de escritório
theme_police=Policial
theme_post_apocalyptic=Pós-apocalíptico
theme_psychological=Psicológico
theme_reincarnation=Reencarnação
theme_reverse_harem=Harém reverso
theme_school_life=Vida escolar
theme_supernatural=Sobrenatural
theme_survival=Sobrevivência
theme_time_travel=Viagem no tempo
theme_traditional_games=Jogos tradicionais
theme_vampires=Vampiros
theme_video_games=Videojuegos
theme_villainess=Villainess
theme_virtual_reality=Realidade virtual
theme_zombies=Zumbis
try_using_first_volume_cover=Tentar usar a capa do primeiro volume como capa
try_using_first_volume_cover_summary=Pode ser necessário atualizar os itens já adicionados na biblioteca. Alternativamente, limpe o banco de dados para as novas capas aparecerem.
unable_to_process_chapter_request=Não foi possível processar a requisição do capítulo. Código HTTP: %d
uploaded_by=Enviado por %s

View File

@ -0,0 +1,138 @@
block_group_by_uuid=Заблокировать группы по UUID
block_group_by_uuid_summary=Главы от заблокированных групп не будут отображаться в последних обновлениях и в списке глав тайтла. Введите через запятую список UUID групп.
block_uploader_by_uuid=Заблокировать загрузчика по UUID
block_uploader_by_uuid_summary=Главы от заблокированных загрузчиков не будут отображаться в последних обновлениях и в списке глав тайтла. Введите через запятую список UUID загрузчиков.
content=Неприемлемый контент
content_gore=Жестокость
content_rating=Рейтинг контента
content_rating_erotica=Эротический
content_rating_genre=Рейтинг контента: %s
content_rating_pornographic=Порнографический
content_rating_safe=Безопасный
content_rating_suggestive=Намекающий
content_sexual_violence=Сексуальное насилие
cover_quality=Качество обложки
cover_quality_low=Низкое
cover_quality_medium=Среднее
cover_quality_original=Оригинальное
data_saver=Экономия трафика
data_saver_summary=Использует меньшие по размеру, сжатые изображения
excluded_tags_mode=Исключая
filter_original_languages=Фильтр по языку оригинала
filter_original_languages_summary=Показывать тайтлы которые изначально были выпущены только в выбранных языках в последних обновлениях и при поиске
format=Формат
format_adaptation=Адаптация
format_anthology=Антология
format_award_winning=Отмеченный наградами
format_doujinshi=Додзинси
format_fan_colored=Раскрашенная фанатами
format_full_color=В цвете
format_long_strip=Веб
format_official_colored=Официально раскрашенная
format_oneshot=Сингл
format_user_created=Созданная пользователями
format_web_comic=Веб-комикс
format_yonkoma=Ёнкома
genre=Жанр
genre_action=Боевик
genre_adventure=Приключения
genre_boys_love=BL
genre_comedy=Комедия
genre_crime=Криминал
genre_drama=Драма
genre_fantasy=Фэнтези
genre_girls_love=GL
genre_historical=История
genre_horror=Ужасы
genre_isekai=Исекай
genre_magical_girls=Махо-сёдзё
genre_mecha=Меха
genre_medical=Медицина
genre_mystery=Мистика
genre_philosophical=Философия
genre_romance=Романтика
genre_sci_fi=Научная фантастика
genre_slice_of_life=Повседневность
genre_sports=Спорт
genre_superhero=Супергерои
genre_thriller=Триллер
genre_tragedy=Трагедия
genre_wuxia=Культивация
has_available_chapters=Есть главы
included_tags_mode=Включая
invalid_author_id=Недействительный ID автора
invalid_group_id=Недействительный ID группы
mode_and=И
mode_or=Или
no_group=Нет группы
no_series_in_list=Лист пуст
original_language=Язык оригинала
original_language_filter_chinese=%s (Манхуа)
original_language_filter_japanese=%s (Манга)
original_language_filter_korean=%s (Манхва)
publication_demographic=Целевая аудитория
publication_demographic_josei=Дзёсэй
publication_demographic_none=Нет
publication_demographic_seinen=Сэйнэн
publication_demographic_shoujo=Сёдзё
publication_demographic_shounen=Сёнэн
sort=Сортировать по
sort_alphabetic=Алфавиту
sort_chapter_uploaded_at=Загруженной главе
sort_content_created_at=По дате создания
sort_content_info_updated_at=По дате обновления
sort_number_of_follows=Количеству фолловеров
sort_rating=Популярности
sort_relevance=Лучшему соответствию
sort_year=Год
standard_content_rating=Рейтинг контента по умолчанию
standard_content_rating_summary=Показывать контент с выбранным рейтингом по умолчанию
standard_https_port=Использовать только HTTPS порт 443
standard_https_port_summary=Запрашивает изображения только с серверов которые используют порт 443. Это позволяет пользователям со строгими правилами брандмауэра загружать изображения с MangaDex.
status=Статус
status_cancelled=Отменён
status_completed=Завершён
status_hiatus=Приостановлен
status_ongoing=Онгоинг
tags_mode=Режим поиска
theme=Теги
theme_aliens=Инопланетяне
theme_animals=Животные
theme_cooking=Животные
theme_crossdressing=Кроссдрессинг
theme_delinquents=Хулиганы
theme_demons=Демоны
theme_gender_swap=Смена гендера
theme_ghosts=Призраки
theme_gyaru=Гяру
theme_harem=Гарем
theme_incest=Инцест
theme_loli=Лоли
theme_mafia=Мафия
theme_magic=Магия
theme_martial_arts=Боевые исскуства
theme_military=Военные
theme_monster_girls=Монстродевушки
theme_monsters=Монстры
theme_music=Музыка
theme_ninja=Ниндзя
theme_office_workers=Офисные работники
theme_police=Полиция
theme_post_apocalyptic=Постапокалиптика
theme_psychological=Психология
theme_reincarnation=Реинкарнация
theme_reverse_harem=Обратный гарем
theme_samurai=Самураи
theme_school_life=Школа
theme_shota=Шота
theme_supernatural=Сверхъестественное
theme_survival=Выживание
theme_time_travel=Путешествие во времени
theme_traditional_games=Путешествие во времени
theme_vampires=Вампиры
theme_video_games=Видеоигры
theme_villainess=Злодейка
theme_virtual_reality=Виртуальная реальность
theme_zombies=Зомби
unable_to_process_chapter_request=Не удалось обработать ссылку на главу. Ошибка: %d
uploaded_by=Загрузил %s

View File

@ -0,0 +1,17 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlinx-serialization'
ext {
extName = 'MangaDex'
pkgNameSuffix = 'all.mangadex'
extClass = '.MangaDexFactory'
extVersionCode = 192
isNsfw = true
}
dependencies {
implementation(project(":lib-i18n"))
}
apply from: "$rootDir/common.gradle"

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View File

@ -0,0 +1,163 @@
package eu.kanade.tachiyomi.extension.all.mangadex
import eu.kanade.tachiyomi.lib.i18n.Intl
import java.text.SimpleDateFormat
import java.util.Locale
import java.util.TimeZone
import kotlin.time.Duration.Companion.minutes
object MDConstants {
val uuidRegex =
Regex("[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}")
const val mangaLimit = 20
const val latestChapterLimit = 100
const val chapter = "chapter"
const val manga = "manga"
const val coverArt = "cover_art"
const val scanlationGroup = "scanlation_group"
const val user = "user"
const val author = "author"
const val artist = "artist"
const val tag = "tag"
const val list = "custom_list"
const val legacyNoGroupId = "00e03853-1b96-4f41-9542-c71b8692033b"
const val cdnUrl = "https://uploads.mangadex.org"
const val apiUrl = "https://api.mangadex.org"
const val apiMangaUrl = "$apiUrl/manga"
const val apiChapterUrl = "$apiUrl/chapter"
const val apiListUrl = "$apiUrl/list"
const val atHomePostUrl = "https://api.mangadex.network/report"
val whitespaceRegex = "\\s".toRegex()
val mdAtHomeTokenLifespan = 5.minutes.inWholeMilliseconds
val dateFormatter = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss+SSS", Locale.US)
.apply { timeZone = TimeZone.getTimeZone("UTC") }
const val prefixIdSearch = "id:"
const val prefixChSearch = "ch:"
const val prefixGrpSearch = "grp:"
const val prefixAuthSearch = "author:"
const val prefixUsrSearch = "usr:"
const val prefixListSearch = "list:"
private const val coverQualityPref = "thumbnailQuality"
fun getCoverQualityPreferenceKey(dexLang: String): String {
return "${coverQualityPref}_$dexLang"
}
fun getCoverQualityPreferenceEntries(intl: Intl) =
arrayOf(intl["cover_quality_original"], intl["cover_quality_medium"], intl["cover_quality_low"])
fun getCoverQualityPreferenceEntryValues() = arrayOf("", ".512.jpg", ".256.jpg")
fun getCoverQualityPreferenceDefaultValue() = getCoverQualityPreferenceEntryValues()[0]
private const val dataSaverPref = "dataSaverV5"
fun getDataSaverPreferenceKey(dexLang: String): String {
return "${dataSaverPref}_$dexLang"
}
private const val standardHttpsPortPref = "usePort443"
fun getStandardHttpsPreferenceKey(dexLang: String): String {
return "${standardHttpsPortPref}_$dexLang"
}
private const val contentRatingPref = "contentRating"
const val contentRatingPrefValSafe = "safe"
const val contentRatingPrefValSuggestive = "suggestive"
const val contentRatingPrefValErotica = "erotica"
const val contentRatingPrefValPornographic = "pornographic"
val contentRatingPrefDefaults = setOf(contentRatingPrefValSafe, contentRatingPrefValSuggestive)
val allContentRatings = setOf(
contentRatingPrefValSafe,
contentRatingPrefValSuggestive,
contentRatingPrefValErotica,
contentRatingPrefValPornographic,
)
fun getContentRatingPrefKey(dexLang: String): String {
return "${contentRatingPref}_$dexLang"
}
private const val originalLanguagePref = "originalLanguage"
const val originalLanguagePrefValJapanese = MangaDexIntl.JAPANESE
const val originalLanguagePrefValChinese = MangaDexIntl.CHINESE
const val originalLanguagePrefValChineseHk = "zh-hk"
const val originalLanguagePrefValKorean = MangaDexIntl.KOREAN
val originalLanguagePrefDefaults = emptySet<String>()
fun getOriginalLanguagePrefKey(dexLang: String): String {
return "${originalLanguagePref}_$dexLang"
}
private const val groupAzuki = "5fed0576-8b94-4f9a-b6a7-08eecd69800d"
private const val groupBilibili = "06a9fecb-b608-4f19-b93c-7caab06b7f44"
private const val groupComikey = "8d8ecf83-8d42-4f8c-add8-60963f9f28d9"
private const val groupInkr = "caa63201-4a17-4b7f-95ff-ed884a2b7e60"
private const val groupMangaHot = "319c1b10-cbd0-4f55-a46e-c4ee17e65139"
private const val groupMangaPlus = "4f1de6a2-f0c5-4ac5-bce5-02c7dbb67deb"
val defaultBlockedGroups = setOf(
groupAzuki,
groupBilibili,
groupComikey,
groupInkr,
groupMangaHot,
groupMangaPlus,
)
private const val blockedGroupsPref = "blockedGroups"
fun getBlockedGroupsPrefKey(dexLang: String): String {
return "${blockedGroupsPref}_$dexLang"
}
private const val blockedUploaderPref = "blockedUploader"
fun getBlockedUploaderPrefKey(dexLang: String): String {
return "${blockedUploaderPref}_$dexLang"
}
private const val hasSanitizedUuidsPref = "hasSanitizedUuids"
fun getHasSanitizedUuidsPrefKey(dexLang: String): String {
return "${hasSanitizedUuidsPref}_$dexLang"
}
private const val tryUsingFirstVolumeCoverPref = "tryUsingFirstVolumeCover"
const val tryUsingFirstVolumeCoverDefault = false
fun getTryUsingFirstVolumeCoverPrefKey(dexLang: String): String {
return "${tryUsingFirstVolumeCoverPref}_$dexLang"
}
private const val altTitlesInDescPref = "altTitlesInDesc"
fun getAltTitlesInDescPrefKey(dexLang: String): String {
return "${altTitlesInDescPref}_$dexLang"
}
private const val customUserAgentPref = "customUserAgent"
fun getCustomUserAgentPrefKey(dexLang: String): String {
return "${customUserAgentPref}_$dexLang"
}
val defaultUserAgent = "Tachiyomi " + System.getProperty("http.agent")
private const val tagGroupContent = "content"
private const val tagGroupFormat = "format"
private const val tagGroupGenre = "genre"
private const val tagGroupTheme = "theme"
val tagGroupsOrder = arrayOf(tagGroupContent, tagGroupFormat, tagGroupGenre, tagGroupTheme)
const val tagAnthologyUuid = "51d83883-4103-437c-b4b1-731cb73d786c"
const val tagOneShotUuid = "0234a31e-a729-4e28-9d6a-3f87c4966b9e"
val romanizedLangCodes = mapOf(
MangaDexIntl.JAPANESE to "ja-ro",
MangaDexIntl.KOREAN to "ko-ro",
MangaDexIntl.CHINESE to "zh-ro",
"zh-hk" to "zh-ro",
)
}

View File

@ -0,0 +1,903 @@
package eu.kanade.tachiyomi.extension.all.mangadex
import android.app.Application
import android.content.SharedPreferences
import android.os.Build
import android.widget.Toast
import androidx.preference.EditTextPreference
import androidx.preference.ListPreference
import androidx.preference.MultiSelectListPreference
import androidx.preference.PreferenceScreen
import androidx.preference.SwitchPreferenceCompat
import eu.kanade.tachiyomi.AppInfo
import eu.kanade.tachiyomi.extension.all.mangadex.dto.AggregateDto
import eu.kanade.tachiyomi.extension.all.mangadex.dto.AggregateVolume
import eu.kanade.tachiyomi.extension.all.mangadex.dto.AtHomeDto
import eu.kanade.tachiyomi.extension.all.mangadex.dto.ChapterDto
import eu.kanade.tachiyomi.extension.all.mangadex.dto.ChapterListDto
import eu.kanade.tachiyomi.extension.all.mangadex.dto.CoverArtDto
import eu.kanade.tachiyomi.extension.all.mangadex.dto.CoverArtListDto
import eu.kanade.tachiyomi.extension.all.mangadex.dto.ListDto
import eu.kanade.tachiyomi.extension.all.mangadex.dto.MangaDataDto
import eu.kanade.tachiyomi.extension.all.mangadex.dto.MangaDto
import eu.kanade.tachiyomi.extension.all.mangadex.dto.MangaListDto
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.asObservable
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 kotlinx.serialization.decodeFromString
import okhttp3.CacheControl
import okhttp3.Headers
import okhttp3.HttpUrl
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 java.util.Date
abstract class MangaDex(final override val lang: String, private val dexLang: String = lang) :
ConfigurableSource, HttpSource() {
override val name = MangaDexIntl.MANGADEX_NAME
override val baseUrl = "https://mangadex.org"
override val supportsLatest = true
private val preferences: SharedPreferences by lazy {
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
}
private val helper = MangaDexHelper(lang)
final override fun headersBuilder(): Headers.Builder {
val extraHeader = "Android/${Build.VERSION.RELEASE} " +
"Tachiyomi/${AppInfo.getVersionName()} " +
"MangaDex/1.4.190"
val builder = super.headersBuilder().apply {
set("Referer", "$baseUrl/")
set("Extra", extraHeader)
}
return builder
}
override val client = network.client.newBuilder()
.rateLimit(3)
.addInterceptor(MdAtHomeReportInterceptor(network.client, headers))
.addInterceptor(MdUserAgentInterceptor(preferences, dexLang))
.build()
init {
preferences.sanitizeExistingUuidPrefs()
}
// Popular manga section
override fun popularMangaRequest(page: Int): Request {
val url = MDConstants.apiMangaUrl.toHttpUrl().newBuilder()
.addQueryParameter("order[followedCount]", "desc")
.addQueryParameter("availableTranslatedLanguage[]", dexLang)
.addQueryParameter("limit", MDConstants.mangaLimit.toString())
.addQueryParameter("offset", helper.getMangaListOffset(page))
.addQueryParameter("includes[]", MDConstants.coverArt)
.addQueryParameter("contentRating[]", preferences.contentRating)
.addQueryParameter("originalLanguage[]", preferences.originalLanguages)
.build()
return GET(url, headers, CacheControl.FORCE_NETWORK)
}
override fun popularMangaParse(response: Response): MangasPage {
if (response.code == 204) {
return MangasPage(emptyList(), false)
}
val mangaListDto = response.parseAs<MangaListDto>()
val coverSuffix = preferences.coverQuality
val firstVolumeCovers = fetchFirstVolumeCovers(mangaListDto.data).orEmpty()
val mangaList = mangaListDto.data.map { mangaDataDto ->
val fileName = firstVolumeCovers.getOrElse(mangaDataDto.id) {
mangaDataDto.relationships
.firstInstanceOrNull<CoverArtDto>()
?.attributes?.fileName
}
helper.createBasicManga(mangaDataDto, fileName, coverSuffix, dexLang)
}
return MangasPage(mangaList, mangaListDto.hasNextPage)
}
// Latest manga section
override fun latestUpdatesRequest(page: Int): Request {
val url = MDConstants.apiChapterUrl.toHttpUrl().newBuilder()
.addQueryParameter("offset", helper.getLatestChapterOffset(page))
.addQueryParameter("limit", MDConstants.latestChapterLimit.toString())
.addQueryParameter("translatedLanguage[]", dexLang)
.addQueryParameter("order[publishAt]", "desc")
.addQueryParameter("includeFutureUpdates", "0")
.addQueryParameter("originalLanguage[]", preferences.originalLanguages)
.addQueryParameter("contentRating[]", preferences.contentRating)
.addQueryParameter(
"excludedGroups[]",
MDConstants.defaultBlockedGroups + preferences.blockedGroups,
)
.addQueryParameter("excludedUploaders[]", preferences.blockedUploaders)
.addQueryParameter("includeFuturePublishAt", "0")
.addQueryParameter("includeEmptyPages", "0")
.build()
return GET(url, headers, CacheControl.FORCE_NETWORK)
}
/**
* The API endpoint can't sort by date yet, so not implemented.
*/
override fun latestUpdatesParse(response: Response): MangasPage {
val chapterListDto = response.parseAs<ChapterListDto>()
val mangaIds = chapterListDto.data
.flatMap { it.relationships }
.filterIsInstance<MangaDataDto>()
.map { it.id }
.distinct()
.toSet()
val mangaApiUrl = MDConstants.apiMangaUrl.toHttpUrl().newBuilder()
.addQueryParameter("includes[]", MDConstants.coverArt)
.addQueryParameter("limit", mangaIds.size.toString())
.addQueryParameter("contentRating[]", preferences.contentRating)
.addQueryParameter("ids[]", mangaIds)
.build()
val mangaRequest = GET(mangaApiUrl, headers, CacheControl.FORCE_NETWORK)
val mangaResponse = client.newCall(mangaRequest).execute()
val mangaListDto = mangaResponse.parseAs<MangaListDto>()
val firstVolumeCovers = fetchFirstVolumeCovers(mangaListDto.data).orEmpty()
val mangaDtoMap = mangaListDto.data.associateBy({ it.id }, { it })
val coverSuffix = preferences.coverQuality
val mangaList = mangaIds.mapNotNull { mangaDtoMap[it] }.map { mangaDataDto ->
val fileName = firstVolumeCovers.getOrElse(mangaDataDto.id) {
mangaDataDto.relationships
.firstInstanceOrNull<CoverArtDto>()
?.attributes?.fileName
}
helper.createBasicManga(mangaDataDto, fileName, coverSuffix, dexLang)
}
return MangasPage(mangaList, chapterListDto.hasNextPage)
}
// Search manga section
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
return when {
query.startsWith(MDConstants.prefixChSearch) ->
getMangaIdFromChapterId(query.removePrefix(MDConstants.prefixChSearch))
.flatMap { mangaId ->
super.fetchSearchManga(
page = page,
query = MDConstants.prefixIdSearch + mangaId,
filters = filters,
)
}
query.startsWith(MDConstants.prefixUsrSearch) ->
client
.newCall(
request = searchMangaUploaderRequest(
page = page,
uploader = query.removePrefix(MDConstants.prefixUsrSearch),
),
)
.asObservableSuccess()
.map { latestUpdatesParse(it) }
query.startsWith(MDConstants.prefixListSearch) ->
client
.newCall(
request = searchMangaListRequest(
list = query.removePrefix(MDConstants.prefixListSearch),
),
)
.asObservableSuccess()
.map { searchMangaListParse(it, page, filters) }
else -> super.fetchSearchManga(page, query.trim(), filters)
}
}
private fun getMangaIdFromChapterId(id: String): Observable<String> {
return client.newCall(GET("${MDConstants.apiChapterUrl}/$id", headers))
.asObservable()
.map { response ->
if (response.isSuccessful.not()) {
throw Exception(helper.intl.format("unable_to_process_chapter_request", response.code))
}
response.parseAs<ChapterDto>().data!!.relationships
.firstInstanceOrNull<MangaDataDto>()!!.id
}
}
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
if (query.startsWith(MDConstants.prefixIdSearch)) {
val mangaId = query.removePrefix(MDConstants.prefixIdSearch)
if (!helper.containsUuid(mangaId)) {
throw Exception(helper.intl["invalid_manga_id"])
}
val url = MDConstants.apiMangaUrl.toHttpUrl().newBuilder()
.addQueryParameter("ids[]", query.removePrefix(MDConstants.prefixIdSearch))
.addQueryParameter("includes[]", MDConstants.coverArt)
.addQueryParameter("contentRating[]", MDConstants.allContentRatings)
.build()
return GET(url, headers, CacheControl.FORCE_NETWORK)
}
val tempUrl = MDConstants.apiMangaUrl.toHttpUrl().newBuilder()
.addQueryParameter("limit", MDConstants.mangaLimit.toString())
.addQueryParameter("offset", helper.getMangaListOffset(page))
.addQueryParameter("includes[]", MDConstants.coverArt)
when {
query.startsWith(MDConstants.prefixGrpSearch) -> {
val groupId = query.removePrefix(MDConstants.prefixGrpSearch)
if (!helper.containsUuid(groupId)) {
throw Exception(helper.intl["invalid_group_id"])
}
tempUrl.addQueryParameter("group", groupId)
}
query.startsWith(MDConstants.prefixAuthSearch) -> {
val authorId = query.removePrefix(MDConstants.prefixAuthSearch)
if (!helper.containsUuid(authorId)) {
throw Exception(helper.intl["invalid_author_id"])
}
tempUrl.addQueryParameter("authorOrArtist", authorId)
}
else -> {
val actualQuery = query.replace(MDConstants.whitespaceRegex, " ")
if (actualQuery.isNotBlank()) {
tempUrl.addQueryParameter("title", actualQuery)
}
}
}
val finalUrl = helper.mdFilters.addFiltersToUrl(
url = tempUrl,
filters = filters.ifEmpty { getFilterList() },
dexLang = dexLang,
)
return GET(finalUrl, headers, CacheControl.FORCE_NETWORK)
}
override fun searchMangaParse(response: Response): MangasPage = popularMangaParse(response)
private fun searchMangaListRequest(list: String): Request {
return GET("${MDConstants.apiListUrl}/$list", headers, CacheControl.FORCE_NETWORK)
}
private fun searchMangaListParse(response: Response, page: Int, filters: FilterList): MangasPage {
val listDto = response.parseAs<ListDto>()
val listDtoFiltered = listDto.data!!.relationships.filterIsInstance<MangaDataDto>()
val amount = listDtoFiltered.count()
if (amount < 1) {
throw Exception(helper.intl["no_series_in_list"])
}
val minIndex = (page - 1) * MDConstants.mangaLimit
val tempUrl = MDConstants.apiMangaUrl.toHttpUrl().newBuilder()
.addQueryParameter("limit", MDConstants.mangaLimit.toString())
.addQueryParameter("offset", "0")
.addQueryParameter("includes[]", MDConstants.coverArt)
val ids = listDtoFiltered
.filterIndexed { i, _ -> i >= minIndex && i < (minIndex + MDConstants.mangaLimit) }
.map(MangaDataDto::id)
.toSet()
tempUrl.addQueryParameter("ids[]", ids)
val finalUrl = helper.mdFilters.addFiltersToUrl(
url = tempUrl,
filters = filters.ifEmpty { getFilterList() },
dexLang = dexLang,
)
val mangaRequest = GET(finalUrl, headers, CacheControl.FORCE_NETWORK)
val mangaResponse = client.newCall(mangaRequest).execute()
val mangaList = searchMangaListParse(mangaResponse)
val hasNextPage = amount.toFloat() / MDConstants.mangaLimit - (page.toFloat() - 1) > 1 &&
ids.size == MDConstants.mangaLimit
return MangasPage(mangaList, hasNextPage)
}
private fun searchMangaListParse(response: Response): List<SManga> {
// This check will be used as the source is doing additional requests to this
// that are not parsed by the asObservableSuccess() method. It should throw the
// HttpException from the app if it becomes available in a future version of extensions-lib.
if (response.isSuccessful.not()) {
throw Exception("HTTP error ${response.code}")
}
val mangaListDto = response.parseAs<MangaListDto>()
val firstVolumeCovers = fetchFirstVolumeCovers(mangaListDto.data).orEmpty()
val coverSuffix = preferences.coverQuality
val mangaList = mangaListDto.data.map { mangaDataDto ->
val fileName = firstVolumeCovers.getOrElse(mangaDataDto.id) {
mangaDataDto.relationships
.firstInstanceOrNull<CoverArtDto>()
?.attributes?.fileName
}
helper.createBasicManga(mangaDataDto, fileName, coverSuffix, dexLang)
}
return mangaList
}
private fun searchMangaUploaderRequest(page: Int, uploader: String): Request {
val url = MDConstants.apiChapterUrl.toHttpUrl().newBuilder()
.addQueryParameter("offset", helper.getLatestChapterOffset(page))
.addQueryParameter("limit", MDConstants.latestChapterLimit.toString())
.addQueryParameter("translatedLanguage[]", dexLang)
.addQueryParameter("order[publishAt]", "desc")
.addQueryParameter("includeFutureUpdates", "0")
.addQueryParameter("includeFuturePublishAt", "0")
.addQueryParameter("includeEmptyPages", "0")
.addQueryParameter("uploader", uploader)
.addQueryParameter("originalLanguage[]", preferences.originalLanguages)
.addQueryParameter("contentRating[]", preferences.contentRating)
.addQueryParameter(
"excludedGroups[]",
MDConstants.defaultBlockedGroups + preferences.blockedGroups,
)
.addQueryParameter("excludedUploaders[]", preferences.blockedUploaders)
.build()
return GET(url, headers, CacheControl.FORCE_NETWORK)
}
// Manga Details section
override fun getMangaUrl(manga: SManga): String {
return baseUrl + manga.url + "/" + helper.titleToSlug(manga.title)
}
/**
* Get the API endpoint URL for the entry details.
*
* @throws Exception if the url is the old format so people migrate
*/
override fun mangaDetailsRequest(manga: SManga): Request {
if (!helper.containsUuid(manga.url.trim())) {
throw Exception(helper.intl["migrate_warning"])
}
val url = (MDConstants.apiUrl + manga.url).toHttpUrl().newBuilder()
.addQueryParameter("includes[]", MDConstants.coverArt)
.addQueryParameter("includes[]", MDConstants.author)
.addQueryParameter("includes[]", MDConstants.artist)
.build()
return GET(url, headers, CacheControl.FORCE_NETWORK)
}
override fun mangaDetailsParse(response: Response): SManga {
val manga = response.parseAs<MangaDto>()
return helper.createManga(
manga.data!!,
fetchSimpleChapterList(manga, dexLang),
fetchFirstVolumeCover(manga),
dexLang,
preferences.coverQuality,
preferences.altTitlesInDesc,
)
}
/**
* Get a quick-n-dirty list of the chapters to be used in determining the manga status.
* Uses the 'aggregate' endpoint.
*
* @see MangaDexHelper.getPublicationStatus
* @see AggregateDto
*/
private fun fetchSimpleChapterList(manga: MangaDto, langCode: String): Map<String, AggregateVolume> {
val url = "${MDConstants.apiMangaUrl}/${manga.data!!.id}/aggregate?translatedLanguage[]=$langCode"
val response = client.newCall(GET(url, headers)).execute()
return runCatching { response.parseAs<AggregateDto>() }
.getOrNull()?.volumes.orEmpty()
}
/**
* Attempt to get the first volume cover if the setting is enabled.
* Uses the 'covers' endpoint.
*
* @see CoverArtListDto
*/
private fun fetchFirstVolumeCover(manga: MangaDto): String? {
return fetchFirstVolumeCovers(listOf(manga.data!!))?.get(manga.data.id)
}
/**
* Attempt to get the first volume cover if the setting is enabled.
* Uses the 'covers' endpoint.
*
* @see CoverArtListDto
*/
private fun fetchFirstVolumeCovers(mangaList: List<MangaDataDto>): Map<String, String>? {
if (!preferences.tryUsingFirstVolumeCover || mangaList.isEmpty()) {
return null
}
val safeMangaList = mangaList.filterNot { it.attributes?.originalLanguage.isNullOrEmpty() }
val mangaMap = safeMangaList.associate { it.id to it.attributes!! }
val locales = safeMangaList.mapNotNull { it.attributes!!.originalLanguage }.distinct()
val limit = (mangaMap.size * locales.size).coerceAtMost(100)
val apiUrl = "${MDConstants.apiUrl}/cover".toHttpUrl().newBuilder()
.addQueryParameter("order[volume]", "asc")
.addQueryParameter("manga[]", mangaMap.keys)
.addQueryParameter("locales[]", locales.toSet())
.addQueryParameter("limit", limit.toString())
.addQueryParameter("offset", "0")
.build()
val result = runCatching {
client.newCall(GET(apiUrl, headers)).execute().parseAs<CoverArtListDto>().data
}
val covers = result.getOrNull() ?: return null
return covers
.groupBy { it.relationships.firstInstanceOrNull<MangaDataDto>()!!.id }
.mapValues {
it.value.find { c -> c.attributes?.locale == mangaMap[it.key]?.originalLanguage }
}
.filterValues { !it?.attributes?.fileName.isNullOrEmpty() }
.mapValues { it.value!!.attributes!!.fileName!! }
}
// Chapter list section
/**
* Get the API endpoint URL for the first page of chapter list.
*
* @throws Exception if the url is the old format so people migrate
*/
override fun chapterListRequest(manga: SManga): Request {
if (!helper.containsUuid(manga.url)) {
throw Exception(helper.intl["migrate_warning"])
}
return paginatedChapterListRequest(helper.getUUIDFromUrl(manga.url), 0)
}
/**
* Required because the chapter list API endpoint is paginated.
*/
private fun paginatedChapterListRequest(mangaId: String, offset: Int): Request {
val url = helper.getChapterEndpoint(mangaId, offset, dexLang).toHttpUrl().newBuilder()
.addQueryParameter("contentRating[]", MDConstants.allContentRatings)
.addQueryParameter("excludedGroups[]", preferences.blockedGroups)
.addQueryParameter("excludedUploaders[]", preferences.blockedUploaders)
.build()
return GET(url, headers, CacheControl.FORCE_NETWORK)
}
override fun chapterListParse(response: Response): List<SChapter> {
if (response.code == 204) {
return emptyList()
}
val chapterListResponse = response.parseAs<ChapterListDto>()
val chapterListResults = chapterListResponse.data.toMutableList()
val mangaId = response.request.url.toString()
.substringBefore("/feed")
.substringAfter("${MDConstants.apiMangaUrl}/")
var offset = chapterListResponse.offset
var hasNextPage = chapterListResponse.hasNextPage
// Max results that can be returned is 500 so need to make more API
// calls if the chapter list response has a next page.
while (hasNextPage) {
offset += chapterListResponse.limit
val newRequest = paginatedChapterListRequest(mangaId, offset)
val newResponse = client.newCall(newRequest).execute()
val newChapterList = newResponse.parseAs<ChapterListDto>()
chapterListResults.addAll(newChapterList.data)
hasNextPage = newChapterList.hasNextPage
}
return chapterListResults
.filterNot { it.attributes!!.isInvalid }
.map(helper::createChapter)
}
override fun getChapterUrl(chapter: SChapter): String = baseUrl + chapter.url
override fun pageListRequest(chapter: SChapter): Request {
if (!helper.containsUuid(chapter.url)) {
throw Exception(helper.intl["migrate_warning"])
}
val chapterId = chapter.url.substringAfter("/chapter/")
val atHomeRequestUrl = if (preferences.forceStandardHttps) {
"${MDConstants.apiUrl}/at-home/server/$chapterId?forcePort443=true"
} else {
"${MDConstants.apiUrl}/at-home/server/$chapterId"
}
return helper.mdAtHomeRequest(atHomeRequestUrl, headers, CacheControl.FORCE_NETWORK)
}
override fun pageListParse(response: Response): List<Page> {
val atHomeRequestUrl = response.request.url
val atHomeDto = response.parseAs<AtHomeDto>()
val host = atHomeDto.baseUrl
// Have to add the time, and url to the page because pages timeout within 30 minutes now.
val now = Date().time
val hash = atHomeDto.chapter.hash
val pageSuffix = if (preferences.useDataSaver) {
atHomeDto.chapter.dataSaver.map { "/data-saver/$hash/$it" }
} else {
atHomeDto.chapter.data.map { "/data/$hash/$it" }
}
return pageSuffix.mapIndexed { index, imgUrl ->
val mdAtHomeMetadataUrl = "$host,$atHomeRequestUrl,$now"
Page(index, mdAtHomeMetadataUrl, imgUrl)
}
}
override fun imageRequest(page: Page): Request {
return helper.getValidImageUrlForPage(page, headers, client)
}
override fun imageUrlParse(response: Response): String = ""
@Suppress("UNCHECKED_CAST")
override fun setupPreferenceScreen(screen: PreferenceScreen) {
val coverQualityPref = ListPreference(screen.context).apply {
key = MDConstants.getCoverQualityPreferenceKey(dexLang)
title = helper.intl["cover_quality"]
entries = MDConstants.getCoverQualityPreferenceEntries(helper.intl)
entryValues = MDConstants.getCoverQualityPreferenceEntryValues()
setDefaultValue(MDConstants.getCoverQualityPreferenceDefaultValue())
summary = "%s"
setOnPreferenceChangeListener { _, newValue ->
val selected = newValue as String
val index = findIndexOfValue(selected)
val entry = entryValues[index] as String
preferences.edit()
.putString(MDConstants.getCoverQualityPreferenceKey(dexLang), entry)
.commit()
}
}
val tryUsingFirstVolumeCoverPref = SwitchPreferenceCompat(screen.context).apply {
key = MDConstants.getTryUsingFirstVolumeCoverPrefKey(dexLang)
title = helper.intl["try_using_first_volume_cover"]
summary = helper.intl["try_using_first_volume_cover_summary"]
setDefaultValue(MDConstants.tryUsingFirstVolumeCoverDefault)
setOnPreferenceChangeListener { _, newValue ->
val checkValue = newValue as Boolean
preferences.edit()
.putBoolean(MDConstants.getTryUsingFirstVolumeCoverPrefKey(dexLang), checkValue)
.commit()
}
}
val dataSaverPref = SwitchPreferenceCompat(screen.context).apply {
key = MDConstants.getDataSaverPreferenceKey(dexLang)
title = helper.intl["data_saver"]
summary = helper.intl["data_saver_summary"]
setDefaultValue(false)
setOnPreferenceChangeListener { _, newValue ->
val checkValue = newValue as Boolean
preferences.edit()
.putBoolean(MDConstants.getDataSaverPreferenceKey(dexLang), checkValue)
.commit()
}
}
val standardHttpsPortPref = SwitchPreferenceCompat(screen.context).apply {
key = MDConstants.getStandardHttpsPreferenceKey(dexLang)
title = helper.intl["standard_https_port"]
summary = helper.intl["standard_https_port_summary"]
setDefaultValue(false)
setOnPreferenceChangeListener { _, newValue ->
val checkValue = newValue as Boolean
preferences.edit()
.putBoolean(MDConstants.getStandardHttpsPreferenceKey(dexLang), checkValue)
.commit()
}
}
val contentRatingPref = MultiSelectListPreference(screen.context).apply {
key = MDConstants.getContentRatingPrefKey(dexLang)
title = helper.intl["standard_content_rating"]
summary = helper.intl["standard_content_rating_summary"]
entries = arrayOf(
helper.intl["content_rating_safe"],
helper.intl["content_rating_suggestive"],
helper.intl["content_rating_erotica"],
helper.intl["content_rating_pornographic"],
)
entryValues = arrayOf(
MDConstants.contentRatingPrefValSafe,
MDConstants.contentRatingPrefValSuggestive,
MDConstants.contentRatingPrefValErotica,
MDConstants.contentRatingPrefValPornographic,
)
setDefaultValue(MDConstants.contentRatingPrefDefaults)
setOnPreferenceChangeListener { _, newValue ->
val checkValue = newValue as Set<String>
preferences.edit()
.putStringSet(MDConstants.getContentRatingPrefKey(dexLang), checkValue)
.commit()
}
}
val originalLanguagePref = MultiSelectListPreference(screen.context).apply {
key = MDConstants.getOriginalLanguagePrefKey(dexLang)
title = helper.intl["filter_original_languages"]
summary = helper.intl["filter_original_languages_summary"]
entries = arrayOf(
helper.intl.languageDisplayName(MangaDexIntl.JAPANESE),
helper.intl.languageDisplayName(MangaDexIntl.CHINESE),
helper.intl.languageDisplayName(MangaDexIntl.KOREAN),
)
entryValues = arrayOf(
MDConstants.originalLanguagePrefValJapanese,
MDConstants.originalLanguagePrefValChinese,
MDConstants.originalLanguagePrefValKorean,
)
setDefaultValue(MDConstants.originalLanguagePrefDefaults)
setOnPreferenceChangeListener { _, newValue ->
val checkValue = newValue as Set<String>
preferences.edit()
.putStringSet(MDConstants.getOriginalLanguagePrefKey(dexLang), checkValue)
.commit()
}
}
val blockedGroupsPref = EditTextPreference(screen.context).apply {
key = MDConstants.getBlockedGroupsPrefKey(dexLang)
title = helper.intl["block_group_by_uuid"]
summary = helper.intl["block_group_by_uuid_summary"]
setOnBindEditTextListener(helper::setupEditTextUuidValidator)
setOnPreferenceChangeListener { _, newValue ->
preferences.edit()
.putString(MDConstants.getBlockedGroupsPrefKey(dexLang), newValue.toString())
.commit()
}
}
val blockedUploaderPref = EditTextPreference(screen.context).apply {
key = MDConstants.getBlockedUploaderPrefKey(dexLang)
title = helper.intl["block_uploader_by_uuid"]
summary = helper.intl["block_uploader_by_uuid_summary"]
setOnBindEditTextListener(helper::setupEditTextUuidValidator)
setOnPreferenceChangeListener { _, newValue ->
preferences.edit()
.putString(MDConstants.getBlockedUploaderPrefKey(dexLang), newValue.toString())
.commit()
}
}
val altTitlesInDescPref = SwitchPreferenceCompat(screen.context).apply {
key = MDConstants.getAltTitlesInDescPrefKey(dexLang)
title = helper.intl["alternative_titles_in_description"]
summary = helper.intl["alternative_titles_in_description_summary"]
setDefaultValue(false)
setOnPreferenceChangeListener { _, newValue ->
val checkValue = newValue as Boolean
preferences.edit()
.putBoolean(MDConstants.getAltTitlesInDescPrefKey(dexLang), checkValue)
.commit()
}
}
val userAgentPref = EditTextPreference(screen.context).apply {
key = MDConstants.getCustomUserAgentPrefKey(dexLang)
title = helper.intl["set_custom_useragent"]
summary = helper.intl["set_custom_useragent_summary"]
dialogMessage = helper.intl.format(
"set_custom_useragent_dialog",
MDConstants.defaultUserAgent,
)
setDefaultValue(MDConstants.defaultUserAgent)
setOnPreferenceChangeListener { _, newValue ->
try {
Headers.Builder().add("User-Agent", newValue as String)
summary = newValue
true
} catch (e: Throwable) {
val errorMessage = helper.intl.format("set_custom_useragent_error_invalid", e.message)
Toast.makeText(screen.context, errorMessage, Toast.LENGTH_LONG).show()
false
}
}
}
screen.addPreference(coverQualityPref)
screen.addPreference(tryUsingFirstVolumeCoverPref)
screen.addPreference(dataSaverPref)
screen.addPreference(standardHttpsPortPref)
screen.addPreference(altTitlesInDescPref)
screen.addPreference(contentRatingPref)
screen.addPreference(originalLanguagePref)
screen.addPreference(blockedGroupsPref)
screen.addPreference(blockedUploaderPref)
screen.addPreference(userAgentPref)
}
override fun getFilterList(): FilterList =
helper.mdFilters.getMDFilterList(preferences, dexLang, helper.intl)
private fun HttpUrl.Builder.addQueryParameter(name: String, value: Set<String>?) = apply {
value?.forEach { addQueryParameter(name, it) }
}
private inline fun <reified T> Response.parseAs(): T = use {
helper.json.decodeFromString(body.string())
}
private inline fun <reified T> List<*>.firstInstanceOrNull(): T? =
firstOrNull { it is T } as? T?
private val SharedPreferences.contentRating
get() = getStringSet(
MDConstants.getContentRatingPrefKey(dexLang),
MDConstants.contentRatingPrefDefaults,
)
private val SharedPreferences.originalLanguages: Set<String>
get() {
val prefValues = getStringSet(
MDConstants.getOriginalLanguagePrefKey(dexLang),
MDConstants.originalLanguagePrefDefaults,
)
val originalLanguages = prefValues.orEmpty().toMutableSet()
if (MDConstants.originalLanguagePrefValChinese in originalLanguages) {
originalLanguages.add(MDConstants.originalLanguagePrefValChineseHk)
}
return originalLanguages
}
private val SharedPreferences.coverQuality
get() = getString(MDConstants.getCoverQualityPreferenceKey(dexLang), "")
private val SharedPreferences.tryUsingFirstVolumeCover
get() = getBoolean(
MDConstants.getTryUsingFirstVolumeCoverPrefKey(dexLang),
MDConstants.tryUsingFirstVolumeCoverDefault,
)
private val SharedPreferences.blockedGroups
get() = getString(MDConstants.getBlockedGroupsPrefKey(dexLang), "")
?.split(",")
?.map(String::trim)
?.filter(String::isNotEmpty)
?.sorted()
.orEmpty()
.toSet()
private val SharedPreferences.blockedUploaders
get() = getString(MDConstants.getBlockedUploaderPrefKey(dexLang), "")
?.split(",")
?.map(String::trim)
?.filter(String::isNotEmpty)
?.sorted()
.orEmpty()
.toSet()
private val SharedPreferences.forceStandardHttps
get() = getBoolean(MDConstants.getStandardHttpsPreferenceKey(dexLang), false)
private val SharedPreferences.useDataSaver
get() = getBoolean(MDConstants.getDataSaverPreferenceKey(dexLang), false)
private val SharedPreferences.altTitlesInDesc
get() = getBoolean(MDConstants.getAltTitlesInDescPrefKey(dexLang), false)
private val SharedPreferences.customUserAgent
get() = getString(
MDConstants.getCustomUserAgentPrefKey(dexLang),
MDConstants.defaultUserAgent,
)
/**
* Previous versions of the extension allowed invalid UUID values to be stored in the
* preferences. This method clear invalid UUIDs in case the user have updated from
* a previous version with that behaviour.
*/
private fun SharedPreferences.sanitizeExistingUuidPrefs() {
if (getBoolean(MDConstants.getHasSanitizedUuidsPrefKey(dexLang), false)) {
return
}
val blockedGroups = getString(MDConstants.getBlockedGroupsPrefKey(dexLang), "")!!
.split(",")
.map(String::trim)
.filter(helper::isUuid)
.joinToString(", ")
val blockedUploaders = getString(MDConstants.getBlockedUploaderPrefKey(dexLang), "")!!
.split(",")
.map(String::trim)
.filter(helper::isUuid)
.joinToString(", ")
edit()
.putString(MDConstants.getBlockedGroupsPrefKey(dexLang), blockedGroups)
.putString(MDConstants.getBlockedUploaderPrefKey(dexLang), blockedUploaders)
.putBoolean(MDConstants.getHasSanitizedUuidsPrefKey(dexLang), true)
.apply()
}
}

View File

@ -0,0 +1,116 @@
package eu.kanade.tachiyomi.extension.all.mangadex
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceFactory
class MangaDexFactory : SourceFactory {
override fun createSources(): List<Source> = listOf(
MangaDexEnglish(),
MangaDexAlbanian(),
MangaDexArabic(),
MangaDexAzerbaijani(),
MangaDexBengali(),
MangaDexBulgarian(),
MangaDexBurmese(),
MangaDexCatalan(),
MangaDexChineseSimplified(),
MangaDexChineseTraditional(),
MangaDexCroatian(),
MangaDexCzech(),
MangaDexDanish(),
MangaDexDutch(),
MangaDexEsperanto(),
MangaDexEstonian(),
MangaDexFilipino(),
MangaDexFinnish(),
MangaDexFrench(),
MangaDexGeorgian(),
MangaDexGerman(),
MangaDexGreek(),
MangaDexHebrew(),
MangaDexHindi(),
MangaDexHungarian(),
MangaDexIndonesian(),
MangaDexItalian(),
MangaDexJapanese(),
MangaDexKazakh(),
MangaDexKorean(),
MangaDexLatin(),
MangaDexLithuanian(),
MangaDexMalay(),
MangaDexMongolian(),
MangaDexNepali(),
MangaDexNorwegian(),
MangaDexPersian(),
MangaDexPolish(),
MangaDexPortugueseBrazil(),
MangaDexPortuguesePortugal(),
MangaDexRomanian(),
MangaDexRussian(),
MangaDexSerbian(),
MangaDexSlovak(),
MangaDexSpanishLatinAmerica(),
MangaDexSpanishSpain(),
MangaDexSwedish(),
MangaDexTamil(),
MangaDexTelugu(),
MangaDexThai(),
MangaDexTurkish(),
MangaDexUkrainian(),
MangaDexVietnamese(),
)
}
class MangaDexAlbanian : MangaDex("sq")
class MangaDexArabic : MangaDex("ar")
class MangaDexAzerbaijani : MangaDex("az")
class MangaDexBengali : MangaDex("bn")
class MangaDexBulgarian : MangaDex("bg")
class MangaDexBurmese : MangaDex("my")
class MangaDexCatalan : MangaDex("ca")
class MangaDexChineseSimplified : MangaDex("zh-Hans", "zh")
class MangaDexChineseTraditional : MangaDex("zh-Hant", "zh-hk")
class MangaDexCroatian : MangaDex("hr")
class MangaDexCzech : MangaDex("cs")
class MangaDexDanish : MangaDex("da")
class MangaDexDutch : MangaDex("nl")
class MangaDexEnglish : MangaDex("en")
class MangaDexEsperanto : MangaDex("eo")
class MangaDexEstonian : MangaDex("et")
class MangaDexFilipino : MangaDex("fil", "tl")
class MangaDexFinnish : MangaDex("fi")
class MangaDexFrench : MangaDex("fr")
class MangaDexGeorgian : MangaDex("ka")
class MangaDexGerman : MangaDex("de")
class MangaDexGreek : MangaDex("el")
class MangaDexHebrew : MangaDex("he")
class MangaDexHindi : MangaDex("hi")
class MangaDexHungarian : MangaDex("hu")
class MangaDexIndonesian : MangaDex("id")
class MangaDexItalian : MangaDex("it")
class MangaDexJapanese : MangaDex("ja")
class MangaDexKazakh : MangaDex("kk")
class MangaDexKorean : MangaDex("ko")
class MangaDexLatin : MangaDex("la")
class MangaDexLithuanian : MangaDex("lt")
class MangaDexMalay : MangaDex("ms")
class MangaDexMongolian : MangaDex("mn")
class MangaDexNepali : MangaDex("ne")
class MangaDexNorwegian : MangaDex("no")
class MangaDexPersian : MangaDex("fa")
class MangaDexPolish : MangaDex("pl")
class MangaDexPortugueseBrazil : MangaDex("pt-BR", "pt-br")
class MangaDexPortuguesePortugal : MangaDex("pt")
class MangaDexRomanian : MangaDex("ro")
class MangaDexRussian : MangaDex("ru")
class MangaDexSerbian : MangaDex("sr")
class MangaDexSlovak : MangaDex("sk")
class MangaDexSpanishLatinAmerica : MangaDex("es-419", "es-la")
class MangaDexSpanishSpain : MangaDex("es")
class MangaDexSwedish : MangaDex("sv")
class MangaDexTamil : MangaDex("ta")
class MangaDexTelugu : MangaDex("te")
class MangaDexThai : MangaDex("th")
class MangaDexTurkish : MangaDex("tr")
class MangaDexUkrainian : MangaDex("uk")
class MangaDexVietnamese : MangaDex("vi")

View File

@ -0,0 +1,400 @@
package eu.kanade.tachiyomi.extension.all.mangadex
import android.content.SharedPreferences
import eu.kanade.tachiyomi.extension.all.mangadex.dto.ContentRatingDto
import eu.kanade.tachiyomi.extension.all.mangadex.dto.PublicationDemographicDto
import eu.kanade.tachiyomi.extension.all.mangadex.dto.StatusDto
import eu.kanade.tachiyomi.lib.i18n.Intl
import eu.kanade.tachiyomi.source.model.Filter
import eu.kanade.tachiyomi.source.model.FilterList
import okhttp3.HttpUrl
class MangaDexFilters {
internal fun getMDFilterList(
preferences: SharedPreferences,
dexLang: String,
intl: Intl,
): FilterList = FilterList(
HasAvailableChaptersFilter(intl),
OriginalLanguageList(intl, getOriginalLanguage(preferences, dexLang, intl)),
ContentRatingList(intl, getContentRating(preferences, dexLang, intl)),
DemographicList(intl, getDemographics(intl)),
StatusList(intl, getStatus(intl)),
SortFilter(intl, getSortables(intl)),
TagsFilter(intl, getTagFilters(intl)),
TagList(intl["content"], getContents(intl)),
TagList(intl["format"], getFormats(intl)),
TagList(intl["genre"], getGenres(intl)),
TagList(intl["theme"], getThemes(intl)),
)
private interface UrlQueryFilter {
fun addQueryParameter(url: HttpUrl.Builder, dexLang: String)
}
private class HasAvailableChaptersFilter(intl: Intl) :
Filter.CheckBox(intl["has_available_chapters"]),
UrlQueryFilter {
override fun addQueryParameter(url: HttpUrl.Builder, dexLang: String) {
if (state) {
url.addQueryParameter("hasAvailableChapters", "true")
url.addQueryParameter("availableTranslatedLanguage[]", dexLang)
}
}
}
private class OriginalLanguage(
name: String,
val isoCode: String,
state: Boolean = false,
) : Filter.CheckBox(name, state)
private class OriginalLanguageList(intl: Intl, originalLanguage: List<OriginalLanguage>) :
Filter.Group<OriginalLanguage>(intl["original_language"], originalLanguage),
UrlQueryFilter {
override fun addQueryParameter(url: HttpUrl.Builder, dexLang: String) {
state.filter(OriginalLanguage::state)
.forEach { lang ->
// dex has zh and zh-hk for chinese manhua
if (lang.isoCode == MDConstants.originalLanguagePrefValChinese) {
url.addQueryParameter(
"originalLanguage[]",
MDConstants.originalLanguagePrefValChineseHk,
)
}
url.addQueryParameter("originalLanguage[]", lang.isoCode)
}
}
}
private fun getOriginalLanguage(
preferences: SharedPreferences,
dexLang: String,
intl: Intl,
): List<OriginalLanguage> {
val originalLanguages = preferences.getStringSet(
MDConstants.getOriginalLanguagePrefKey(dexLang),
setOf(),
)!!
return listOf(
OriginalLanguage(
name = intl.format(
"original_language_filter_japanese",
intl.languageDisplayName(MangaDexIntl.JAPANESE),
),
isoCode = MDConstants.originalLanguagePrefValJapanese,
state = MDConstants.originalLanguagePrefValJapanese in originalLanguages,
),
OriginalLanguage(
name = intl.format(
"original_language_filter_chinese",
intl.languageDisplayName(MangaDexIntl.CHINESE),
),
isoCode = MDConstants.originalLanguagePrefValChinese,
state = MDConstants.originalLanguagePrefValChinese in originalLanguages,
),
OriginalLanguage(
name = intl.format(
"original_language_filter_korean",
intl.languageDisplayName(MangaDexIntl.KOREAN),
),
isoCode = MDConstants.originalLanguagePrefValKorean,
state = MDConstants.originalLanguagePrefValKorean in originalLanguages,
),
)
}
private class ContentRating(name: String, val value: String) : Filter.CheckBox(name)
private class ContentRatingList(intl: Intl, contentRating: List<ContentRating>) :
Filter.Group<ContentRating>(intl["content_rating"], contentRating),
UrlQueryFilter {
override fun addQueryParameter(url: HttpUrl.Builder, dexLang: String) {
state.filter(ContentRating::state)
.forEach { url.addQueryParameter("contentRating[]", it.value) }
}
}
private fun getContentRating(
preferences: SharedPreferences,
dexLang: String,
intl: Intl,
): List<ContentRating> {
val contentRatings = preferences.getStringSet(
MDConstants.getContentRatingPrefKey(dexLang),
MDConstants.contentRatingPrefDefaults,
)
return listOf(
ContentRating(intl["content_rating_safe"], ContentRatingDto.SAFE.value).apply {
state = contentRatings?.contains(MDConstants.contentRatingPrefValSafe) ?: true
},
ContentRating(intl["content_rating_suggestive"], ContentRatingDto.SUGGESTIVE.value).apply {
state = contentRatings?.contains(MDConstants.contentRatingPrefValSuggestive) ?: true
},
ContentRating(intl["content_rating_erotica"], ContentRatingDto.EROTICA.value).apply {
state = contentRatings?.contains(MDConstants.contentRatingPrefValErotica) ?: false
},
ContentRating(intl["content_rating_pornographic"], ContentRatingDto.PORNOGRAPHIC.value).apply {
state = contentRatings?.contains(MDConstants.contentRatingPrefValPornographic) ?: false
},
)
}
private class Demographic(name: String, val value: String) : Filter.CheckBox(name)
private class DemographicList(intl: Intl, demographics: List<Demographic>) :
Filter.Group<Demographic>(intl["publication_demographic"], demographics),
UrlQueryFilter {
override fun addQueryParameter(url: HttpUrl.Builder, dexLang: String) {
state.filter(Demographic::state)
.forEach { url.addQueryParameter("publicationDemographic[]", it.value) }
}
}
private fun getDemographics(intl: Intl) = listOf(
Demographic(intl["publication_demographic_none"], PublicationDemographicDto.NONE.value),
Demographic(intl["publication_demographic_shounen"], PublicationDemographicDto.SHOUNEN.value),
Demographic(intl["publication_demographic_shoujo"], PublicationDemographicDto.SHOUJO.value),
Demographic(intl["publication_demographic_seinen"], PublicationDemographicDto.SEINEN.value),
Demographic(intl["publication_demographic_josei"], PublicationDemographicDto.JOSEI.value),
)
private class Status(name: String, val value: String) : Filter.CheckBox(name)
private class StatusList(intl: Intl, status: List<Status>) :
Filter.Group<Status>(intl["status"], status),
UrlQueryFilter {
override fun addQueryParameter(url: HttpUrl.Builder, dexLang: String) {
state.filter(Status::state)
.forEach { url.addQueryParameter("status[]", it.value) }
}
}
private fun getStatus(intl: Intl) = listOf(
Status(intl["status_ongoing"], StatusDto.ONGOING.value),
Status(intl["status_completed"], StatusDto.COMPLETED.value),
Status(intl["status_hiatus"], StatusDto.HIATUS.value),
Status(intl["status_cancelled"], StatusDto.CANCELLED.value),
)
data class Sortable(val title: String, val value: String) {
override fun toString(): String = title
}
private fun getSortables(intl: Intl) = arrayOf(
Sortable(intl["sort_alphabetic"], "title"),
Sortable(intl["sort_chapter_uploaded_at"], "latestUploadedChapter"),
Sortable(intl["sort_number_of_follows"], "followedCount"),
Sortable(intl["sort_content_created_at"], "createdAt"),
Sortable(intl["sort_content_info_updated_at"], "updatedAt"),
Sortable(intl["sort_relevance"], "relevance"),
Sortable(intl["sort_year"], "year"),
Sortable(intl["sort_rating"], "rating"),
)
class SortFilter(intl: Intl, private val sortables: Array<Sortable>) :
Filter.Sort(
intl["sort"],
sortables.map(Sortable::title).toTypedArray(),
Selection(5, false),
),
UrlQueryFilter {
override fun addQueryParameter(url: HttpUrl.Builder, dexLang: String) {
if (state != null) {
val query = sortables[state!!.index].value
val value = if (state!!.ascending) "asc" else "desc"
url.addQueryParameter("order[$query]", value)
}
}
}
internal class Tag(val id: String, name: String) : Filter.TriState(name)
private class TagList(collection: String, tags: List<Tag>) :
Filter.Group<Tag>(collection, tags),
UrlQueryFilter {
override fun addQueryParameter(url: HttpUrl.Builder, dexLang: String) {
state.forEach { tag ->
if (tag.isIncluded()) {
url.addQueryParameter("includedTags[]", tag.id)
} else if (tag.isExcluded()) {
url.addQueryParameter("excludedTags[]", tag.id)
}
}
}
}
private fun getContents(intl: Intl): List<Tag> {
val tags = listOf(
Tag("b29d6a3d-1569-4e7a-8caf-7557bc92cd5d", intl["content_gore"]),
Tag("97893a4c-12af-4dac-b6be-0dffb353568e", intl["content_sexual_violence"]),
)
return tags.sortIfTranslated(intl)
}
private fun getFormats(intl: Intl): List<Tag> {
val tags = listOf(
Tag("b11fda93-8f1d-4bef-b2ed-8803d3733170", intl["format_yonkoma"]),
Tag("f4122d1c-3b44-44d0-9936-ff7502c39ad3", intl["format_adaptation"]),
Tag("51d83883-4103-437c-b4b1-731cb73d786c", intl["format_anthology"]),
Tag("0a39b5a1-b235-4886-a747-1d05d216532d", intl["format_award_winning"]),
Tag("b13b2a48-c720-44a9-9c77-39c9979373fb", intl["format_doujinshi"]),
Tag("7b2ce280-79ef-4c09-9b58-12b7c23a9b78", intl["format_fan_colored"]),
Tag("f5ba408b-0e7a-484d-8d49-4e9125ac96de", intl["format_full_color"]),
Tag("3e2b8dae-350e-4ab8-a8ce-016e844b9f0d", intl["format_long_strip"]),
Tag("320831a8-4026-470b-94f6-8353740e6f04", intl["format_official_colored"]),
Tag("0234a31e-a729-4e28-9d6a-3f87c4966b9e", intl["format_oneshot"]),
Tag("891cf039-b895-47f0-9229-bef4c96eccd4", intl["format_user_created"]),
Tag("e197df38-d0e7-43b5-9b09-2842d0c326dd", intl["format_web_comic"]),
)
return tags.sortIfTranslated(intl)
}
private fun getGenres(intl: Intl): List<Tag> {
val tags = listOf(
Tag("391b0423-d847-456f-aff0-8b0cfc03066b", intl["genre_action"]),
Tag("87cc87cd-a395-47af-b27a-93258283bbc6", intl["genre_adventure"]),
Tag("5920b825-4181-4a17-beeb-9918b0ff7a30", intl["genre_boys_love"]),
Tag("4d32cc48-9f00-4cca-9b5a-a839f0764984", intl["genre_comedy"]),
Tag("5ca48985-9a9d-4bd8-be29-80dc0303db72", intl["genre_crime"]),
Tag("b9af3a63-f058-46de-a9a0-e0c13906197a", intl["genre_drama"]),
Tag("cdc58593-87dd-415e-bbc0-2ec27bf404cc", intl["genre_fantasy"]),
Tag("a3c67850-4684-404e-9b7f-c69850ee5da6", intl["genre_girls_love"]),
Tag("33771934-028e-4cb3-8744-691e866a923e", intl["genre_historical"]),
Tag("cdad7e68-1419-41dd-bdce-27753074a640", intl["genre_horror"]),
Tag("ace04997-f6bd-436e-b261-779182193d3d", intl["genre_isekai"]),
Tag("81c836c9-914a-4eca-981a-560dad663e73", intl["genre_magical_girls"]),
Tag("50880a9d-5440-4732-9afb-8f457127e836", intl["genre_mecha"]),
Tag("c8cbe35b-1b2b-4a3f-9c37-db84c4514856", intl["genre_medical"]),
Tag("ee968100-4191-4968-93d3-f82d72be7e46", intl["genre_mystery"]),
Tag("b1e97889-25b4-4258-b28b-cd7f4d28ea9b", intl["genre_philosophical"]),
Tag("423e2eae-a7a2-4a8b-ac03-a8351462d71d", intl["genre_romance"]),
Tag("256c8bd9-4904-4360-bf4f-508a76d67183", intl["genre_sci_fi"]),
Tag("e5301a23-ebd9-49dd-a0cb-2add944c7fe9", intl["genre_slice_of_life"]),
Tag("69964a64-2f90-4d33-beeb-f3ed2875eb4c", intl["genre_sports"]),
Tag("7064a261-a137-4d3a-8848-2d385de3a99c", intl["genre_superhero"]),
Tag("07251805-a27e-4d59-b488-f0bfbec15168", intl["genre_thriller"]),
Tag("f8f62932-27da-4fe4-8ee1-6779a8c5edba", intl["genre_tragedy"]),
Tag("acc803a4-c95a-4c22-86fc-eb6b582d82a2", intl["genre_wuxia"]),
)
return tags.sortIfTranslated(intl)
}
private fun getThemes(intl: Intl): List<Tag> {
val tags = listOf(
Tag("e64f6742-c834-471d-8d72-dd51fc02b835", intl["theme_aliens"]),
Tag("3de8c75d-8ee3-48ff-98ee-e20a65c86451", intl["theme_animals"]),
Tag("ea2bc92d-1c26-4930-9b7c-d5c0dc1b6869", intl["theme_cooking"]),
Tag("9ab53f92-3eed-4e9b-903a-917c86035ee3", intl["theme_crossdressing"]),
Tag("da2d50ca-3018-4cc0-ac7a-6b7d472a29ea", intl["theme_delinquents"]),
Tag("39730448-9a5f-48a2-85b0-a70db87b1233", intl["theme_demons"]),
Tag("2bd2e8d0-f146-434a-9b51-fc9ff2c5fe6a", intl["theme_gender_swap"]),
Tag("3bb26d85-09d5-4d2e-880c-c34b974339e9", intl["theme_ghosts"]),
Tag("fad12b5e-68ba-460e-b933-9ae8318f5b65", intl["theme_gyaru"]),
Tag("aafb99c1-7f60-43fa-b75f-fc9502ce29c7", intl["theme_harem"]),
Tag("5bd0e105-4481-44ca-b6e7-7544da56b1a3", intl["theme_incest"]),
Tag("2d1f5d56-a1e5-4d0d-a961-2193588b08ec", intl["theme_loli"]),
Tag("85daba54-a71c-4554-8a28-9901a8b0afad", intl["theme_mafia"]),
Tag("a1f53773-c69a-4ce5-8cab-fffcd90b1565", intl["theme_magic"]),
Tag("799c202e-7daa-44eb-9cf7-8a3c0441531e", intl["theme_martial_arts"]),
Tag("ac72833b-c4e9-4878-b9db-6c8a4a99444a", intl["theme_military"]),
Tag("dd1f77c5-dea9-4e2b-97ae-224af09caf99", intl["theme_monster_girls"]),
Tag("36fd93ea-e8b8-445e-b836-358f02b3d33d", intl["theme_monsters"]),
Tag("f42fbf9e-188a-447b-9fdc-f19dc1e4d685", intl["theme_music"]),
Tag("489dd859-9b61-4c37-af75-5b18e88daafc", intl["theme_ninja"]),
Tag("92d6d951-ca5e-429c-ac78-451071cbf064", intl["theme_office_workers"]),
Tag("df33b754-73a3-4c54-80e6-1a74a8058539", intl["theme_police"]),
Tag("9467335a-1b83-4497-9231-765337a00b96", intl["theme_post_apocalyptic"]),
Tag("3b60b75c-a2d7-4860-ab56-05f391bb889c", intl["theme_psychological"]),
Tag("0bc90acb-ccc1-44ca-a34a-b9f3a73259d0", intl["theme_reincarnation"]),
Tag("65761a2a-415e-47f3-bef2-a9dababba7a6", intl["theme_reverse_harem"]),
Tag("81183756-1453-4c81-aa9e-f6e1b63be016", intl["theme_samurai"]),
Tag("caaa44eb-cd40-4177-b930-79d3ef2afe87", intl["theme_school_life"]),
Tag("ddefd648-5140-4e5f-ba18-4eca4071d19b", intl["theme_shota"]),
Tag("eabc5b4c-6aff-42f3-b657-3e90cbd00b75", intl["theme_supernatural"]),
Tag("5fff9cde-849c-4d78-aab0-0d52b2ee1d25", intl["theme_survival"]),
Tag("292e862b-2d17-4062-90a2-0356caa4ae27", intl["theme_time_travel"]),
Tag("31932a7e-5b8e-49a6-9f12-2afa39dc544c", intl["theme_traditional_games"]),
Tag("d7d1730f-6eb0-4ba6-9437-602cac38664c", intl["theme_vampires"]),
Tag("9438db5a-7e2a-4ac0-b39e-e0d95a34b8a8", intl["theme_video_games"]),
Tag("d14322ac-4d6f-4e9b-afd9-629d5f4d8a41", intl["theme_villainess"]),
Tag("8c86611e-fab7-4986-9dec-d1a2f44acdd5", intl["theme_virtual_reality"]),
Tag("631ef465-9aba-4afb-b0fc-ea10efe274a8", intl["theme_zombies"]),
)
return tags.sortIfTranslated(intl)
}
// to get all tags from dex https://api.mangadex.org/manga/tag
internal fun getTags(intl: Intl): List<Tag> {
return getContents(intl) + getFormats(intl) + getGenres(intl) + getThemes(intl)
}
private data class TagMode(val title: String, val value: String) {
override fun toString(): String = title
}
private fun getTagModes(intl: Intl) = arrayOf(
TagMode(intl["mode_and"], "AND"),
TagMode(intl["mode_or"], "OR"),
)
private class TagInclusionMode(intl: Intl, modes: Array<TagMode>) :
Filter.Select<TagMode>(intl["included_tags_mode"], modes, 0),
UrlQueryFilter {
override fun addQueryParameter(url: HttpUrl.Builder, dexLang: String) {
url.addQueryParameter("includedTagsMode", values[state].value)
}
}
private class TagExclusionMode(intl: Intl, modes: Array<TagMode>) :
Filter.Select<TagMode>(intl["excluded_tags_mode"], modes, 1),
UrlQueryFilter {
override fun addQueryParameter(url: HttpUrl.Builder, dexLang: String) {
url.addQueryParameter("excludedTagsMode", values[state].value)
}
}
private class TagsFilter(intl: Intl, innerFilters: FilterList) :
Filter.Group<Filter<*>>(intl["tags_mode"], innerFilters),
UrlQueryFilter {
override fun addQueryParameter(url: HttpUrl.Builder, dexLang: String) {
state.filterIsInstance<UrlQueryFilter>()
.forEach { filter -> filter.addQueryParameter(url, dexLang) }
}
}
private fun getTagFilters(intl: Intl): FilterList = FilterList(
TagInclusionMode(intl, getTagModes(intl)),
TagExclusionMode(intl, getTagModes(intl)),
)
internal fun addFiltersToUrl(url: HttpUrl.Builder, filters: FilterList, dexLang: String): HttpUrl {
filters.filterIsInstance<UrlQueryFilter>()
.forEach { filter -> filter.addQueryParameter(url, dexLang) }
return url.build()
}
private fun List<Tag>.sortIfTranslated(intl: Intl): List<Tag> = apply {
if (intl.chosenLanguage == MangaDexIntl.ENGLISH) {
return this
}
return sortedWith(compareBy(intl.collator, Tag::name))
}
}

View File

@ -0,0 +1,490 @@
package eu.kanade.tachiyomi.extension.all.mangadex
import android.text.Editable
import android.text.TextWatcher
import android.util.Log
import android.widget.Button
import android.widget.EditText
import eu.kanade.tachiyomi.extension.all.mangadex.dto.AggregateVolume
import eu.kanade.tachiyomi.extension.all.mangadex.dto.ArtistDto
import eu.kanade.tachiyomi.extension.all.mangadex.dto.AtHomeDto
import eu.kanade.tachiyomi.extension.all.mangadex.dto.AttributesDto
import eu.kanade.tachiyomi.extension.all.mangadex.dto.AuthorArtistAttributesDto
import eu.kanade.tachiyomi.extension.all.mangadex.dto.AuthorDto
import eu.kanade.tachiyomi.extension.all.mangadex.dto.ChapterAttributesDto
import eu.kanade.tachiyomi.extension.all.mangadex.dto.ChapterDataDto
import eu.kanade.tachiyomi.extension.all.mangadex.dto.ContentRatingDto
import eu.kanade.tachiyomi.extension.all.mangadex.dto.CoverArtAttributesDto
import eu.kanade.tachiyomi.extension.all.mangadex.dto.CoverArtDto
import eu.kanade.tachiyomi.extension.all.mangadex.dto.EntityDto
import eu.kanade.tachiyomi.extension.all.mangadex.dto.ListAttributesDto
import eu.kanade.tachiyomi.extension.all.mangadex.dto.ListDataDto
import eu.kanade.tachiyomi.extension.all.mangadex.dto.MangaAttributesDto
import eu.kanade.tachiyomi.extension.all.mangadex.dto.MangaDataDto
import eu.kanade.tachiyomi.extension.all.mangadex.dto.ScanlationGroupAttributes
import eu.kanade.tachiyomi.extension.all.mangadex.dto.ScanlationGroupDto
import eu.kanade.tachiyomi.extension.all.mangadex.dto.StatusDto
import eu.kanade.tachiyomi.extension.all.mangadex.dto.TagAttributesDto
import eu.kanade.tachiyomi.extension.all.mangadex.dto.TagDto
import eu.kanade.tachiyomi.extension.all.mangadex.dto.UnknownEntity
import eu.kanade.tachiyomi.extension.all.mangadex.dto.UserAttributes
import eu.kanade.tachiyomi.extension.all.mangadex.dto.UserDto
import eu.kanade.tachiyomi.lib.i18n.Intl
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.plus
import kotlinx.serialization.modules.polymorphic
import kotlinx.serialization.modules.subclass
import okhttp3.CacheControl
import okhttp3.Headers
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import okhttp3.Request
import org.jsoup.parser.Parser
import java.util.Date
import java.util.Locale
import java.util.concurrent.TimeUnit
class MangaDexHelper(lang: String) {
val mdFilters = MangaDexFilters()
val json = Json {
isLenient = true
ignoreUnknownKeys = true
allowSpecialFloatingPointValues = true
prettyPrint = true
serializersModule += SerializersModule {
polymorphic(EntityDto::class) {
subclass(AuthorDto::class)
subclass(ArtistDto::class)
subclass(ChapterDataDto::class)
subclass(CoverArtDto::class)
subclass(ListDataDto::class)
subclass(MangaDataDto::class)
subclass(ScanlationGroupDto::class)
subclass(TagDto::class)
subclass(UserDto::class)
defaultDeserializer { UnknownEntity.serializer() }
}
polymorphic(AttributesDto::class) {
subclass(AuthorArtistAttributesDto::class)
subclass(ChapterAttributesDto::class)
subclass(CoverArtAttributesDto::class)
subclass(ListAttributesDto::class)
subclass(MangaAttributesDto::class)
subclass(ScanlationGroupAttributes::class)
subclass(TagAttributesDto::class)
subclass(UserAttributes::class)
}
}
}
val intl = Intl(
language = lang,
baseLanguage = MangaDexIntl.ENGLISH,
availableLanguages = MangaDexIntl.AVAILABLE_LANGS,
classLoader = this::class.java.classLoader!!,
createMessageFileName = { lang ->
when (lang) {
MangaDexIntl.SPANISH_LATAM -> Intl.createDefaultMessageFileName(MangaDexIntl.SPANISH)
MangaDexIntl.PORTUGUESE -> Intl.createDefaultMessageFileName(MangaDexIntl.BRAZILIAN_PORTUGUESE)
else -> Intl.createDefaultMessageFileName(lang)
}
},
)
/**
* Gets the UUID from the url
*/
fun getUUIDFromUrl(url: String) = url.substringAfterLast("/")
/**
* Get chapters for manga (aka manga/$id/feed endpoint)
*/
fun getChapterEndpoint(mangaId: String, offset: Int, langCode: String) =
"${MDConstants.apiMangaUrl}/$mangaId/feed".toHttpUrl().newBuilder()
.addQueryParameter("includes[]", MDConstants.scanlationGroup)
.addQueryParameter("includes[]", MDConstants.user)
.addQueryParameter("limit", "500")
.addQueryParameter("offset", offset.toString())
.addQueryParameter("translatedLanguage[]", langCode)
.addQueryParameter("order[volume]", "desc")
.addQueryParameter("order[chapter]", "desc")
.addQueryParameter("includeFuturePublishAt", "0")
.addQueryParameter("includeEmptyPages", "0")
.toString()
/**
* Check if the manga url is a valid uuid
*/
fun containsUuid(url: String) = url.contains(MDConstants.uuidRegex)
/**
* Check if the string is a valid uuid
*/
fun isUuid(text: String) = MDConstants.uuidRegex matches text
/**
* Get the manga offset pages are 1 based, so subtract 1
*/
fun getMangaListOffset(page: Int): String = (MDConstants.mangaLimit * (page - 1)).toString()
/**
* Get the latest chapter offset pages are 1 based, so subtract 1
*/
fun getLatestChapterOffset(page: Int): String =
(MDConstants.latestChapterLimit * (page - 1)).toString()
/**
* Remove any HTML characters in manga or chapter name to actual
* characters. For example &hearts; will show .
*/
private fun String.removeEntities(): String {
return Parser.unescapeEntities(this, false)
}
/**
* Remove any HTML characters in description to actual characters.
* It also removes Markdown syntax for links, italic and bold.
*/
private fun String.removeEntitiesAndMarkdown(): String {
return removeEntities()
.substringBefore("---")
.replace(markdownLinksRegex, "$1")
.replace(markdownItalicBoldRegex, "$1")
.replace(markdownItalicRegex, "$1")
.trim()
}
/**
* Maps MangaDex status to Tachiyomi status.
* Adapted from the MangaDex handler from TachiyomiSY.
*/
fun getPublicationStatus(attr: MangaAttributesDto, volumes: Map<String, AggregateVolume>): Int {
val chaptersList = volumes.values
.flatMap { it.chapters.values }
.map { it.chapter }
val tempStatus = when (attr.status) {
StatusDto.ONGOING -> SManga.ONGOING
StatusDto.CANCELLED -> SManga.CANCELLED
StatusDto.COMPLETED -> SManga.PUBLISHING_FINISHED
StatusDto.HIATUS -> SManga.ON_HIATUS
else -> SManga.UNKNOWN
}
val publishedOrCancelled = tempStatus == SManga.PUBLISHING_FINISHED ||
tempStatus == SManga.CANCELLED
val isOneShot = attr.tags.any { it.id == MDConstants.tagOneShotUuid } &&
attr.tags.none { it.id == MDConstants.tagAnthologyUuid }
return when {
chaptersList.contains(attr.lastChapter) && publishedOrCancelled -> SManga.COMPLETED
isOneShot && volumes["none"]?.chapters?.get("none") != null -> SManga.COMPLETED
else -> tempStatus
}
}
private fun parseDate(dateAsString: String): Long =
MDConstants.dateFormatter.parse(dateAsString)?.time ?: 0
/**
* Chapter URL where we get the token, last request time.
*/
private val tokenTracker = hashMapOf<String, Long>()
companion object {
val USE_CACHE = CacheControl.Builder()
.maxStale(Integer.MAX_VALUE, TimeUnit.SECONDS)
.build()
val markdownLinksRegex = "\\[([^]]+)\\]\\(([^)]+)\\)".toRegex()
val markdownItalicBoldRegex = "\\*+\\s*([^\\*]*)\\s*\\*+".toRegex()
val markdownItalicRegex = "_+\\s*([^_]*)\\s*_+".toRegex()
val titleSpecialCharactersRegex = "[^a-z0-9]+".toRegex()
val trailingHyphenRegex = "-+$".toRegex()
}
/**
* Check the token map to see if the MD@Home host is still valid.
*/
fun getValidImageUrlForPage(page: Page, headers: Headers, client: OkHttpClient): Request {
val (host, tokenRequestUrl, time) = page.url.split(",")
val mdAtHomeServerUrl =
when (Date().time - time.toLong() > MDConstants.mdAtHomeTokenLifespan) {
false -> host
true -> {
val tokenLifespan = Date().time - (tokenTracker[tokenRequestUrl] ?: 0)
val cacheControl = if (tokenLifespan > MDConstants.mdAtHomeTokenLifespan) {
CacheControl.FORCE_NETWORK
} else {
USE_CACHE
}
getMdAtHomeUrl(tokenRequestUrl, client, headers, cacheControl)
}
}
return GET(mdAtHomeServerUrl + page.imageUrl, headers)
}
/**
* Get the MD@Home URL.
*/
private fun getMdAtHomeUrl(
tokenRequestUrl: String,
client: OkHttpClient,
headers: Headers,
cacheControl: CacheControl,
): String {
val request = mdAtHomeRequest(tokenRequestUrl, headers, cacheControl)
val response = client.newCall(request).execute()
// This check is for the error that causes pages to fail to load.
// It should never be entered, but in case it is, we retry the request.
if (response.code == 504) {
Log.wtf("MangaDex", "Failed to read cache for \"$tokenRequestUrl\"")
return getMdAtHomeUrl(tokenRequestUrl, client, headers, CacheControl.FORCE_NETWORK)
}
return response.use { json.decodeFromString<AtHomeDto>(it.body.string()).baseUrl }
}
/**
* create an md at home Request
*/
fun mdAtHomeRequest(
tokenRequestUrl: String,
headers: Headers,
cacheControl: CacheControl,
): Request {
if (cacheControl == CacheControl.FORCE_NETWORK) {
tokenTracker[tokenRequestUrl] = Date().time
}
return GET(tokenRequestUrl, headers, cacheControl)
}
/**
* Create a [SManga] from the JSON element with only basic attributes filled.
*/
fun createBasicManga(
mangaDataDto: MangaDataDto,
coverFileName: String?,
coverSuffix: String?,
lang: String,
): SManga = SManga.create().apply {
url = "/manga/${mangaDataDto.id}"
val titleMap = mangaDataDto.attributes!!.title
val dirtyTitle =
titleMap.values.firstOrNull() // use literally anything from title as first resort
?: mangaDataDto.attributes.altTitles
.find { (it[lang] ?: it["en"]) !== null }
?.values?.singleOrNull() // find something else from alt titles
title = dirtyTitle?.removeEntities().orEmpty()
coverFileName?.let {
thumbnail_url = when (!coverSuffix.isNullOrEmpty()) {
true -> "${MDConstants.cdnUrl}/covers/${mangaDataDto.id}/$coverFileName$coverSuffix"
else -> "${MDConstants.cdnUrl}/covers/${mangaDataDto.id}/$coverFileName"
}
}
}
/**
* Create an [SManga] from the JSON element with all attributes filled.
*/
fun createManga(
mangaDataDto: MangaDataDto,
chapters: Map<String, AggregateVolume>,
firstVolumeCover: String?,
lang: String,
coverSuffix: String?,
altTitlesInDesc: Boolean,
): SManga {
val attr = mangaDataDto.attributes!!
// Things that will go with the genre tags but aren't actually genre
val dexLocale = Locale.forLanguageTag(lang)
val nonGenres = listOfNotNull(
attr.publicationDemographic
?.let { intl["publication_demographic_${it.name.lowercase()}"] },
attr.contentRating
.takeIf { it != ContentRatingDto.SAFE }
?.let { intl.format("content_rating_genre", intl["content_rating_${it.name.lowercase()}"]) },
attr.originalLanguage
?.let { Locale.forLanguageTag(it) }
?.getDisplayName(dexLocale)
?.replaceFirstChar { it.uppercase(dexLocale) },
)
val authors = mangaDataDto.relationships
.filterIsInstance<AuthorDto>()
.mapNotNull { it.attributes?.name }
.distinct()
val artists = mangaDataDto.relationships
.filterIsInstance<ArtistDto>()
.mapNotNull { it.attributes?.name }
.distinct()
val coverFileName = firstVolumeCover ?: mangaDataDto.relationships
.filterIsInstance<CoverArtDto>()
.firstOrNull()
?.attributes?.fileName
val tags = mdFilters.getTags(intl).associate { it.id to it.name }
val genresMap = attr.tags
.groupBy({ it.attributes!!.group }) { tagDto -> tags[tagDto.id] }
.mapValues { it.value.filterNotNull().sortedWith(intl.collator) }
val genreList = MDConstants.tagGroupsOrder.flatMap { genresMap[it].orEmpty() } + nonGenres
var desc = (attr.description[lang] ?: attr.description["en"])
?.removeEntitiesAndMarkdown()
.orEmpty()
if (altTitlesInDesc) {
val romanizedOriginalLang = MDConstants.romanizedLangCodes[attr.originalLanguage].orEmpty()
val altTitles = attr.altTitles
.filter { it.containsKey(lang) || it.containsKey(romanizedOriginalLang) }
.mapNotNull { it.values.singleOrNull() }
.filter(String::isNotEmpty)
if (altTitles.isNotEmpty()) {
val altTitlesDesc = altTitles
.joinToString("\n", "${intl["alternative_titles"]}\n") { "$it" }
desc += (if (desc.isBlank()) "" else "\n\n") + altTitlesDesc.removeEntities()
}
}
return createBasicManga(mangaDataDto, coverFileName, coverSuffix, lang).apply {
description = desc
author = authors.joinToString()
artist = artists.joinToString()
status = getPublicationStatus(attr, chapters)
genre = genreList
.filter(String::isNotEmpty)
.joinToString()
}
}
/**
* Create the [SChapter] from the JSON element.
*/
fun createChapter(chapterDataDto: ChapterDataDto): SChapter {
val attr = chapterDataDto.attributes!!
val groups = chapterDataDto.relationships
.filterIsInstance<ScanlationGroupDto>()
.filterNot { it.id == MDConstants.legacyNoGroupId } // 'no group' left over from MDv3
.mapNotNull { it.attributes?.name }
.joinToString(" & ")
.ifEmpty {
// Fallback to uploader name if no group is set.
val users = chapterDataDto.relationships
.filterIsInstance<UserDto>()
.mapNotNull { it.attributes?.username }
if (users.isNotEmpty()) intl.format("uploaded_by", users.joinToString(" & ")) else ""
}
.ifEmpty { intl["no_group"] } // "No Group" as final resort
val chapterName = mutableListOf<String>()
// Build chapter name
attr.volume?.let {
if (it.isNotEmpty()) {
chapterName.add("Vol.$it")
}
}
attr.chapter?.let {
if (it.isNotEmpty()) {
chapterName.add("Ch.$it")
}
}
attr.title?.let {
if (it.isNotEmpty()) {
if (chapterName.isNotEmpty()) {
chapterName.add("-")
}
chapterName.add(it)
}
}
// if volume, chapter and title is empty its a oneshot
if (chapterName.isEmpty()) {
chapterName.add("Oneshot")
}
// In future calculate [END] if non mvp api doesn't provide it
return SChapter.create().apply {
url = "/chapter/${chapterDataDto.id}"
name = chapterName.joinToString(" ").removeEntities()
date_upload = parseDate(attr.publishAt)
scanlator = groups
}
}
fun titleToSlug(title: String) = title.trim()
.lowercase(Locale.US)
.replace(titleSpecialCharactersRegex, "-")
.replace(trailingHyphenRegex, "")
.split("-")
.reduce { accumulator, element ->
val currentSlug = "$accumulator-$element"
if (currentSlug.length > 100) {
accumulator
} else {
currentSlug
}
}
/**
* Adds a custom [TextWatcher] to the preference's [EditText] that show an
* error if the input value contains invalid UUIDs. If the validation fails,
* the Ok button is disabled to prevent the user from saving the value.
*
* This will likely need to be removed or revisited when the app migrates the
* extension preferences screen to Compose.
*/
fun setupEditTextUuidValidator(editText: EditText) {
editText.addTextChangedListener(
object : TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit
override fun afterTextChanged(editable: Editable?) {
requireNotNull(editable)
val text = editable.toString()
val isValid = text.isBlank() || text
.split(",")
.map(String::trim)
.all(::isUuid)
editText.error = if (!isValid) intl["invalid_uuids"] else null
editText.rootView.findViewById<Button>(android.R.id.button1)
?.isEnabled = editText.error == null
}
},
)
}
}

View File

@ -0,0 +1,24 @@
package eu.kanade.tachiyomi.extension.all.mangadex
object MangaDexIntl {
const val BRAZILIAN_PORTUGUESE = "pt-BR"
const val CHINESE = "zh"
const val ENGLISH = "en"
const val JAPANESE = "ja"
const val KOREAN = "ko"
const val PORTUGUESE = "pt"
const val SPANISH_LATAM = "es-419"
const val SPANISH = "es"
const val RUSSIAN = "ru"
val AVAILABLE_LANGS = setOf(
ENGLISH,
BRAZILIAN_PORTUGUESE,
PORTUGUESE,
SPANISH,
SPANISH_LATAM,
RUSSIAN,
)
const val MANGADEX_NAME = "MangaDex"
}

View File

@ -0,0 +1,53 @@
package eu.kanade.tachiyomi.extension.all.mangadex
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Intent
import android.os.Bundle
import android.util.Log
import kotlin.system.exitProcess
/**
* Springboard that accepts https://mangadex.com/title/xxx intents and redirects them to
* the main tachiyomi process. The idea is to not install the intent filter unless
* you have this extension installed, but still let the main tachiyomi app control
* things.
*
* Main goal was to make it easier to open manga in Tachiyomi in spite of the DDoS blocking
* the usual search screen from working.
*/
class MangadexUrlActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val pathSegments = intent?.data?.pathSegments
if (pathSegments != null && pathSegments.size > 1) {
val titleId = pathSegments[1]
val mainIntent = Intent().apply {
action = "eu.kanade.tachiyomi.SEARCH"
with(pathSegments[0]) {
when {
equals("chapter") -> putExtra("query", MDConstants.prefixChSearch + titleId)
equals("group") -> putExtra("query", MDConstants.prefixGrpSearch + titleId)
equals("user") -> putExtra("query", MDConstants.prefixUsrSearch + titleId)
equals("author") -> putExtra("query", MDConstants.prefixAuthSearch + titleId)
equals("list") -> putExtra("query", MDConstants.prefixListSearch + titleId)
else -> putExtra("query", MDConstants.prefixIdSearch + titleId)
}
}
putExtra("filter", packageName)
}
try {
startActivity(mainIntent)
} catch (e: ActivityNotFoundException) {
Log.e("MangadexUrlActivity", e.toString())
}
} else {
Log.e("MangadexUrlActivity", "Could not parse URI from intent $intent")
}
finish()
exitProcess(0)
}
}

View File

@ -0,0 +1,109 @@
package eu.kanade.tachiyomi.extension.all.mangadex
import android.util.Log
import eu.kanade.tachiyomi.extension.all.mangadex.dto.ImageReportDto
import eu.kanade.tachiyomi.network.POST
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import okhttp3.Call
import okhttp3.Callback
import okhttp3.Headers
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import uy.kohesive.injekt.injectLazy
/**
* Interceptor to post to MD@Home for MangaDex Stats
*/
class MdAtHomeReportInterceptor(
private val client: OkHttpClient,
private val headers: Headers,
) : Interceptor {
private val json: Json by injectLazy()
override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request()
val response = chain.proceed(chain.request())
val url = originalRequest.url.toString()
if (!url.contains(MD_AT_HOME_URL_REGEX)) {
return response
}
Log.e("MangaDex", "Connecting to MD@Home node at $url")
val reportRequest = mdAtHomeReportRequest(response)
// Execute the report endpoint network call asynchronously to avoid blocking
// the reader from showing the image once it's fully loaded if the report call
// gets stuck, as it tend to happens sometimes.
client.newCall(reportRequest).enqueue(REPORT_CALLBACK)
if (response.isSuccessful) {
return response
}
response.close()
Log.e("MangaDex", "Error connecting to MD@Home node, fallback to uploads server")
val imagePath = originalRequest.url.pathSegments
.dropWhile { it != "data" && it != "data-saver" }
.joinToString("/")
val fallbackUrl = MDConstants.cdnUrl.toHttpUrl().newBuilder()
.addPathSegments(imagePath)
.build()
val fallbackRequest = originalRequest.newBuilder()
.url(fallbackUrl)
.headers(headers)
.build()
return chain.proceed(fallbackRequest)
}
private fun mdAtHomeReportRequest(response: Response): Request {
val result = ImageReportDto(
url = response.request.url.toString(),
success = response.isSuccessful,
bytes = response.peekBody(Long.MAX_VALUE).bytes().size,
cached = response.headers["X-Cache"] == "HIT",
duration = response.receivedResponseAtMillis - response.sentRequestAtMillis,
)
val payload = json.encodeToString(result)
return POST(
url = MDConstants.atHomePostUrl,
headers = headers,
body = payload.toRequestBody(JSON_MEDIA_TYPE),
)
}
companion object {
private val JSON_MEDIA_TYPE = "application/json".toMediaType()
private val MD_AT_HOME_URL_REGEX =
"""^https://[\w\d]+\.[\w\d]+\.mangadex(\b-test\b)?\.network.*${'$'}""".toRegex()
private val REPORT_CALLBACK = object : Callback {
override fun onFailure(call: Call, e: okio.IOException) {
Log.e("MangaDex", "Error trying to POST report to MD@Home: ${e.message}")
}
override fun onResponse(call: Call, response: Response) {
if (!response.isSuccessful) {
Log.e("MangaDex", "Error trying to POST report to MD@Home: HTTP error ${response.code}")
}
response.close()
}
}
}
}

View File

@ -0,0 +1,40 @@
package eu.kanade.tachiyomi.extension.all.mangadex
import android.content.SharedPreferences
import okhttp3.Interceptor
import okhttp3.Response
import java.io.IOException
/**
* Interceptor to set custom useragent for MangaDex
*/
class MdUserAgentInterceptor(
private val preferences: SharedPreferences,
private val dexLang: String,
) : Interceptor {
private val SharedPreferences.customUserAgent
get() = getString(
MDConstants.getCustomUserAgentPrefKey(dexLang),
MDConstants.defaultUserAgent,
)
override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request()
val newUserAgent = preferences.customUserAgent
?: return chain.proceed(originalRequest)
val originalHeaders = originalRequest.headers
val modifiedHeaders = originalHeaders.newBuilder()
.set("User-Agent", newUserAgent)
.build()
val modifiedRequest = originalRequest.newBuilder()
.headers(modifiedHeaders)
.build()
return chain.proceed(modifiedRequest)
}
}

View File

@ -0,0 +1,22 @@
package eu.kanade.tachiyomi.extension.all.mangadex.dto
import kotlinx.serialization.Serializable
@Serializable
data class AggregateDto(
val result: String,
val volumes: Map<String, AggregateVolume>?,
)
@Serializable
data class AggregateVolume(
val volume: String,
val count: String,
val chapters: Map<String, AggregateChapter>,
)
@Serializable
data class AggregateChapter(
val chapter: String,
val count: String,
)

View File

@ -0,0 +1,25 @@
package eu.kanade.tachiyomi.extension.all.mangadex.dto
import kotlinx.serialization.Serializable
@Serializable
data class AtHomeDto(
val baseUrl: String,
val chapter: AtHomeChapterDto,
)
@Serializable
data class AtHomeChapterDto(
val hash: String,
val data: List<String>,
val dataSaver: List<String>,
)
@Serializable
data class ImageReportDto(
val url: String,
val success: Boolean,
val bytes: Int?,
val cached: Boolean,
val duration: Long,
)

View File

@ -0,0 +1,16 @@
package eu.kanade.tachiyomi.extension.all.mangadex.dto
import eu.kanade.tachiyomi.extension.all.mangadex.MDConstants
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
@SerialName(MDConstants.author)
data class AuthorDto(override val attributes: AuthorArtistAttributesDto? = null) : EntityDto()
@Serializable
@SerialName(MDConstants.artist)
data class ArtistDto(override val attributes: AuthorArtistAttributesDto? = null) : EntityDto()
@Serializable
data class AuthorArtistAttributesDto(val name: String) : AttributesDto()

View File

@ -0,0 +1,30 @@
package eu.kanade.tachiyomi.extension.all.mangadex.dto
import eu.kanade.tachiyomi.extension.all.mangadex.MDConstants
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
typealias ChapterListDto = PaginatedResponseDto<ChapterDataDto>
typealias ChapterDto = ResponseDto<ChapterDataDto>
@Serializable
@SerialName(MDConstants.chapter)
data class ChapterDataDto(override val attributes: ChapterAttributesDto? = null) : EntityDto()
@Serializable
data class ChapterAttributesDto(
val title: String?,
val volume: String?,
val chapter: String?,
val pages: Int,
val publishAt: String,
val externalUrl: String?,
) : AttributesDto() {
/**
* Returns true if the chapter is from an external website and have no pages.
*/
val isInvalid: Boolean
get() = externalUrl != null && pages == 0
}

View File

@ -0,0 +1,17 @@
package eu.kanade.tachiyomi.extension.all.mangadex.dto
import eu.kanade.tachiyomi.extension.all.mangadex.MDConstants
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
typealias CoverArtListDto = PaginatedResponseDto<CoverArtDto>
@Serializable
@SerialName(MDConstants.coverArt)
data class CoverArtDto(override val attributes: CoverArtAttributesDto? = null) : EntityDto()
@Serializable
data class CoverArtAttributesDto(
val fileName: String? = null,
val locale: String? = null,
) : AttributesDto()

View File

@ -0,0 +1,16 @@
package eu.kanade.tachiyomi.extension.all.mangadex.dto
import kotlinx.serialization.Serializable
@Serializable
abstract class EntityDto {
val id: String = ""
val relationships: List<EntityDto> = emptyList()
abstract val attributes: AttributesDto?
}
@Serializable
abstract class AttributesDto
@Serializable
data class UnknownEntity(override val attributes: AttributesDto? = null) : EntityDto()

View File

@ -0,0 +1,18 @@
package eu.kanade.tachiyomi.extension.all.mangadex.dto
import eu.kanade.tachiyomi.extension.all.mangadex.MDConstants
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
typealias ListDto = ResponseDto<ListDataDto>
@Serializable
@SerialName(MDConstants.list)
data class ListDataDto(override val attributes: ListAttributesDto? = null) : EntityDto()
@Serializable
data class ListAttributesDto(
val name: String,
val visibility: String,
val version: Int,
) : AttributesDto()

View File

@ -0,0 +1,114 @@
package eu.kanade.tachiyomi.extension.all.mangadex.dto
import eu.kanade.tachiyomi.extension.all.mangadex.MDConstants
import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.descriptors.buildClassSerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.JsonDecoder
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.serializer
typealias MangaListDto = PaginatedResponseDto<MangaDataDto>
typealias MangaDto = ResponseDto<MangaDataDto>
@Serializable
@SerialName(MDConstants.manga)
data class MangaDataDto(override val attributes: MangaAttributesDto? = null) : EntityDto()
@Serializable
data class MangaAttributesDto(
val title: LocalizedString,
val altTitles: List<LocalizedString>,
val description: LocalizedString,
val originalLanguage: String?,
val lastVolume: String?,
val lastChapter: String?,
val contentRating: ContentRatingDto? = null,
val publicationDemographic: PublicationDemographicDto? = null,
val status: StatusDto? = null,
val tags: List<TagDto>,
) : AttributesDto()
@Serializable
enum class ContentRatingDto(val value: String) {
@SerialName("safe")
SAFE("safe"),
@SerialName("suggestive")
SUGGESTIVE("suggestive"),
@SerialName("erotica")
EROTICA("erotica"),
@SerialName("pornographic")
PORNOGRAPHIC("pornographic"),
}
@Serializable
enum class PublicationDemographicDto(val value: String) {
@SerialName("none")
NONE("none"),
@SerialName("shounen")
SHOUNEN("shounen"),
@SerialName("shoujo")
SHOUJO("shoujo"),
@SerialName("josei")
JOSEI("josei"),
@SerialName("seinen")
SEINEN("seinen"),
}
@Serializable
enum class StatusDto(val value: String) {
@SerialName("ongoing")
ONGOING("ongoing"),
@SerialName("completed")
COMPLETED("completed"),
@SerialName("hiatus")
HIATUS("hiatus"),
@SerialName("cancelled")
CANCELLED("cancelled"),
}
@Serializable
@SerialName(MDConstants.tag)
data class TagDto(override val attributes: TagAttributesDto? = null) : EntityDto()
@Serializable
data class TagAttributesDto(val group: String) : AttributesDto()
typealias LocalizedString = @Serializable(LocalizedStringSerializer::class)
Map<String, String>
/**
* Temporary workaround while Dex API still returns arrays instead of objects
* in the places that uses [LocalizedString].
*/
object LocalizedStringSerializer : KSerializer<Map<String, String>> {
override val descriptor = buildClassSerialDescriptor("LocalizedString")
override fun deserialize(decoder: Decoder): Map<String, String> {
require(decoder is JsonDecoder)
return (decoder.decodeJsonElement() as? JsonObject)
?.mapValues { it.value.jsonPrimitive.contentOrNull ?: "" }
.orEmpty()
}
override fun serialize(encoder: Encoder, value: Map<String, String>) {
encoder.encodeSerializableValue(serializer(), value)
}
}

View File

@ -0,0 +1,24 @@
package eu.kanade.tachiyomi.extension.all.mangadex.dto
import kotlinx.serialization.Serializable
@Serializable
data class PaginatedResponseDto<T : EntityDto>(
val result: String,
val response: String = "",
val data: List<T> = emptyList(),
val limit: Int = 0,
val offset: Int = 0,
val total: Int = 0,
) {
val hasNextPage: Boolean
get() = limit + offset < total
}
@Serializable
data class ResponseDto<T : EntityDto>(
val result: String,
val response: String = "",
val data: T? = null,
)

View File

@ -0,0 +1,12 @@
package eu.kanade.tachiyomi.extension.all.mangadex.dto
import eu.kanade.tachiyomi.extension.all.mangadex.MDConstants
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
@SerialName(MDConstants.scanlationGroup)
data class ScanlationGroupDto(override val attributes: ScanlationGroupAttributes? = null) : EntityDto()
@Serializable
data class ScanlationGroupAttributes(val name: String) : AttributesDto()

View File

@ -0,0 +1,12 @@
package eu.kanade.tachiyomi.extension.all.mangadex.dto
import eu.kanade.tachiyomi.extension.all.mangadex.MDConstants
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
@SerialName(MDConstants.user)
data class UserDto(override val attributes: UserAttributes? = null) : EntityDto()
@Serializable
data class UserAttributes(val username: String) : AttributesDto()

View File

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest />

View File

@ -0,0 +1,48 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
ext {
extName = 'NewToki / ManaToki'
pkgNameSuffix = 'ko.newtoki'
extClass = '.TokiFactory'
extVersionCode = 29
isNsfw = true
}
apply from: "$rootDir/common.gradle"
def domainNumberFileName = "src/ko/newtoki/src/eu/kanade/tachiyomi/extension/ko/newtoki/FallbackDomainNumber.kt"
def domainNumberFile = new File(domainNumberFileName)
def backupFile = new File(domainNumberFileName + "_bak")
task updateDomainNumber {
doLast {
def domainNumberUrl = "https://stevenyomi.github.io/source-domains/newtoki.txt"
def number = new URL(domainNumberUrl).withInputStream { it.readLines()[0] }
println("[NewToki] Updating domain number to $number")
domainNumberFile.renameTo(backupFile)
domainNumberFile.withPrintWriter {
it.println("// THIS FILE IS AUTO-GENERATED, DO NOT COMMIT")
it.println("package eu.kanade.tachiyomi.extension.ko.newtoki")
it.println("const val fallbackDomainNumber = \"$number\"")
}
}
}
preBuild.dependsOn updateDomainNumber
task restoreBackup {
doLast {
if (backupFile.exists()) {
println("[NewToki] Restoring placeholder file")
domainNumberFile.delete()
backupFile.renameTo(domainNumberFile)
}
}
}
tasks.whenTaskAdded { task ->
if (task.name == "assembleDebug" || task.name == "assembleRelease") {
task.finalizedBy(restoreBackup)
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 348 KiB

View File

@ -0,0 +1,73 @@
package eu.kanade.tachiyomi.extension.ko.newtoki
import android.util.Log
import eu.kanade.tachiyomi.network.GET
import okhttp3.Interceptor
import okhttp3.Response
import java.io.IOException
var domainNumber = ""
get() {
val currentValue = field
if (currentValue.isNotEmpty()) return currentValue
val prefValue = newTokiPreferences.domainNumber
if (prefValue.isNotEmpty()) {
field = prefValue
return prefValue
}
val fallback = fallbackDomainNumber
domainNumber = fallback
return fallback
}
set(value) {
for (preference in arrayOf(manaTokiPreferences, newTokiPreferences)) {
preference.domainNumber = value
}
field = value
}
object DomainInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
val response = try {
chain.proceed(request)
} catch (e: IOException) {
if (chain.call().isCanceled()) throw e
Log.e("NewToki", "failed to fetch ${request.url}", e)
val newDomainNumber = try {
val domainNumberUrl = "https://stevenyomi.github.io/source-domains/newtoki.txt"
chain.proceed(GET(domainNumberUrl)).body.string().also { it.toInt() }
} catch (_: Throwable) {
throw IOException(editDomainNumber(), e)
}
domainNumber = newDomainNumber
val url = request.url
val newHost = numberRegex.replaceFirst(url.host, newDomainNumber)
val newUrl = url.newBuilder().host(newHost).build()
try {
chain.proceed(request.newBuilder().url(newUrl).build())
} catch (e: IOException) {
Log.e("NewToki", "failed to fetch $newUrl", e)
throw IOException(editDomainNumber(), e)
}
}
if (response.priorResponse == null) return response
val newUrl = response.request.url
if ("captcha" in newUrl.toString()) throw IOException(solveCaptcha())
val newHost = newUrl.host
if (newHost.startsWith(MANATOKI_PREFIX) || newHost.startsWith(NEWTOKI_PREFIX)) {
numberRegex.find(newHost)?.run { domainNumber = value }
}
return response
}
private val numberRegex by lazy { Regex("""\d+|$fallbackDomainNumber""") }
}

View File

@ -0,0 +1,10 @@
package eu.kanade.tachiyomi.extension.ko.newtoki
/**
* This value will be automatically overwritten when building the extension.
* After building, this file will be restored.
*
* Even if this value is built into the extension, the network call will fail
* because of underscore character and the extension will update it on its own.
*/
const val fallbackDomainNumber = "_failed_to_fetch_domain_number"

View File

@ -0,0 +1,174 @@
package eu.kanade.tachiyomi.extension.ko.newtoki
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.source.model.Filter
import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.SManga
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Request
import org.jsoup.nodes.Element
/*
* ManaToki is too big to support in a Factory File., So split into separate file.
*/
object ManaToki : NewToki("ManaToki", "comic", manaTokiPreferences) {
// / ! DO NOT CHANGE THIS ! Only the site name changed from newtoki.
override val id = MANATOKI_ID
override val baseUrl get() = "https://$MANATOKI_PREFIX$domainNumber.net"
private val chapterRegex by lazy { Regex(""" [ \d,~.-]+화$""") }
fun latestUpdatesElementParse(element: Element): SManga {
val linkElement = element.select("a.btn-primary")
val rawTitle = element.select(".post-subject > a").first()!!.ownText().trim()
val title = rawTitle.trim().replace(chapterRegex, "")
val manga = SManga.create()
manga.url = getUrlPath(linkElement.attr("href"))
manga.title = title
manga.thumbnail_url = element.select(".img-item > img").attr("src")
manga.initialized = false
return manga
}
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
val url = ("$baseUrl/comic" + (if (page > 1) "/p$page" else "")).toHttpUrl().newBuilder()
filters.forEach { filter ->
when (filter) {
is SearchPublishTypeList -> {
if (filter.state > 0) {
url.addQueryParameter("publish", filter.values[filter.state])
}
}
is SearchJaumTypeList -> {
if (filter.state > 0) {
url.addQueryParameter("jaum", filter.values[filter.state])
}
}
is SearchGenreTypeList -> {
val genres = filter.state.filter { it.state }.joinToString(",") { it.id }
url.addQueryParameter("tag", genres)
}
is SearchSortTypeList -> {
val state = filter.state ?: return@forEach
url.addQueryParameter("sst", arrayOf("wr_datetime", "wr_hit", "wr_good", "as_update")[state.index])
url.addQueryParameter("sod", if (state.ascending) "asc" else "desc")
}
else -> {}
}
}
if (query.isNotBlank()) {
url.addQueryParameter("stx", query)
// Remove some filter QueryParams that not working with query
url.removeAllQueryParameters("publish")
url.removeAllQueryParameters("jaum")
url.removeAllQueryParameters("tag")
}
return GET(url.toString(), headers)
}
private class SearchCheckBox(name: String, val id: String = name) : Filter.CheckBox(name)
// [...document.querySelectorAll("form.form td")[3].querySelectorAll("span.btn")].map((el, i) => `"${el.innerText.trim()}"`).join(',\n')
private class SearchPublishTypeList : Filter.Select<String>(
"Publish",
arrayOf(
"전체",
"주간",
"격주",
"월간",
"단편",
"단행본",
"완결",
),
)
// [...document.querySelectorAll("form.form td")[4].querySelectorAll("span.btn")].map((el, i) => `"${el.innerText.trim()}"`).join(',\n')
private class SearchJaumTypeList : Filter.Select<String>(
"Jaum",
arrayOf(
"전체",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"0-9",
"a-z",
),
)
// [...document.querySelectorAll("form.form td")[6].querySelectorAll("span.btn")].map((el, i) => `"${el.innerText.trim()}"`).join(',\n')
private class SearchGenreTypeList : Filter.Group<SearchCheckBox>(
"Genres",
arrayOf(
"전체",
"17",
"BL",
"SF",
"TS",
"개그",
"게임",
"도박",
"드라마",
"라노벨",
"러브코미디",
"먹방",
"백합",
"붕탁",
"순정",
"스릴러",
"스포츠",
"시대",
"애니화",
"액션",
"음악",
"이세계",
"일상",
"전생",
"추리",
"판타지",
"학원",
"호러",
).map { SearchCheckBox(it) },
)
private class SearchSortTypeList : Filter.Sort(
"Sort",
arrayOf(
"기본(날짜순)",
"인기순",
"추천순",
"업데이트순",
),
)
override fun getFilterList() = FilterList(
SearchSortTypeList(),
Filter.Separator(),
Filter.Header(ignoredForTextSearch()),
SearchPublishTypeList(),
SearchJaumTypeList(),
SearchGenreTypeList(),
)
}

View File

@ -0,0 +1,284 @@
package eu.kanade.tachiyomi.extension.ko.newtoki
import android.content.SharedPreferences
import android.util.Log
import eu.kanade.tachiyomi.network.GET
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.ParsedHttpSource
import eu.kanade.tachiyomi.util.asJsoup
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Response
import org.jsoup.Jsoup
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.concurrent.TimeUnit
/**
* NewToki Source
*
* Based on https://github.com/gnuboard/gnuboard5
**/
abstract class NewToki(
override val name: String,
private val boardName: String,
private val preferences: SharedPreferences,
) : ParsedHttpSource(), ConfigurableSource {
override val lang: String = "ko"
override val supportsLatest = true
override val client by lazy { buildClient(withRateLimit = false) }
private val rateLimitedClient by lazy { buildClient(withRateLimit = true) }
private fun buildClient(withRateLimit: Boolean) =
network.cloudflareClient.newBuilder()
.apply { if (withRateLimit) rateLimit(1, preferences.rateLimitPeriod.toLong()) }
.addInterceptor(DomainInterceptor) // not rate-limited
.connectTimeout(10, TimeUnit.SECONDS) // fail fast
.build()
override fun popularMangaSelector() = "div#webtoon-list > ul > li"
override fun popularMangaFromElement(element: Element): SManga {
val linkElement = element.getElementsByTag("a").first()!!
val manga = SManga.create()
manga.url = getUrlPath(linkElement.attr("href"))
manga.title = element.select("span.title").first()!!.ownText()
manga.thumbnail_url = linkElement.getElementsByTag("img").attr("src")
return manga
}
override fun popularMangaNextPageSelector() = "ul.pagination > li:last-child:not(.disabled)"
// Do not add page parameter if page is 1 to prevent tracking.
override fun popularMangaRequest(page: Int) = GET("$baseUrl/$boardName" + if (page > 1) "/p$page" else "", headers)
override fun searchMangaSelector() = popularMangaSelector()
override fun searchMangaFromElement(element: Element) = popularMangaFromElement(element)
override fun searchMangaNextPageSelector() = popularMangaNextPageSelector()
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
return if (query.startsWith(PREFIX_ID_SEARCH)) {
val realQuery = query.removePrefix(PREFIX_ID_SEARCH)
val urlPath = "/$boardName/$realQuery"
rateLimitedClient.newCall(GET("$baseUrl$urlPath", headers))
.asObservableSuccess()
.map { response ->
// the id is matches any of 'post' from their CMS board.
// Includes Manga Details Page, Chapters, Comments, and etcs...
actualMangaParseById(urlPath, response)
}
} else {
super.fetchSearchManga(page, query, filters)
}
}
private fun actualMangaParseById(urlPath: String, response: Response): MangasPage {
val document = response.asJsoup()
// Only exists on detail page.
val firstChapterButton = document.select("tr > th > button.btn-blue").first()
// only exists on chapter with proper manga detail page.
val fullListButton = document.select(".comic-navbar .toon-nav a").last()
val list: List<SManga> = when {
firstChapterButton?.text()?.contains("첫회보기") == true -> { // Check this page is detail page
val details = mangaDetailsParse(document)
details.url = urlPath
listOf(details)
}
fullListButton?.text()?.contains("전체목록") == true -> { // Check this page is chapter page
val url = fullListButton.attr("abs:href")
val details = mangaDetailsParse(rateLimitedClient.newCall(GET(url, headers)).execute())
details.url = getUrlPath(url)
listOf(details)
}
else -> emptyList()
}
return MangasPage(list, false)
}
override fun mangaDetailsParse(document: Document): SManga {
val info = document.select("div.view-title > .view-content").first()!!
val title = document.select("div.view-content > span > b").text()
val thumbnail = document.select("div.row div.view-img > img").attr("src")
val descriptionElement = info.select("div.row div.view-content:not([style])")
val description = descriptionElement.map {
it.text().trim()
}
val prefix = if (isCleanPath(document.location())) "" else needMigration()
val manga = SManga.create()
manga.title = title
manga.description = description.joinToString("\n", prefix = prefix)
manga.thumbnail_url = thumbnail
descriptionElement.forEach {
val text = it.text()
when {
"작가" in text -> manga.author = it.getElementsByTag("a").text()
"분류" in text -> {
val genres = mutableListOf<String>()
it.getElementsByTag("a").forEach { item ->
genres.add(item.text())
}
manga.genre = genres.joinToString(", ")
}
"발행구분" in text -> manga.status = parseStatus(it.getElementsByTag("a").text())
}
}
return manga
}
private fun parseStatus(status: String) = when (status.trim()) {
"주간", "격주", "월간", "격월/비정기", "단행본" -> SManga.ONGOING
"단편", "완결" -> SManga.COMPLETED
else -> SManga.UNKNOWN
}
override fun chapterListSelector() = "div.serial-list > ul.list-body > li.list-item"
override fun chapterFromElement(element: Element): SChapter {
val linkElement = element.select(".wr-subject > a.item-subject").last()!!
val rawName = linkElement.ownText().trim()
val chapter = SChapter.create()
chapter.setUrlWithoutDomain(linkElement.attr("href"))
chapter.chapter_number = parseChapterNumber(rawName)
chapter.name = rawName
chapter.date_upload = parseChapterDate(element.select(".wr-date").last()!!.text().trim())
return chapter
}
private fun parseChapterNumber(name: String): Float {
try {
if (name.contains("[단편]")) return 1f
// `특별` means `Special`, so It can be buggy. so pad `편`(Chapter) to prevent false return
if (name.contains("번외") || name.contains("특별편")) return -2f
val regex = chapterNumberRegex
val (ch_primal, ch_second) = regex.find(name)!!.destructured
return (ch_primal + if (ch_second.isBlank()) "" else ".$ch_second").toFloatOrNull()
?: -1f
} catch (e: Exception) {
Log.e("NewToki", "failed to parse chapter number '$name'", e)
return -1f
}
}
private fun mangaDetailsParseWithTitleCheck(manga: SManga, document: Document) =
mangaDetailsParse(document).apply {
// TODO: don't throw when there is download folder rename feature
if (manga.description.isNullOrEmpty() && manga.title != title) {
throw Exception(titleNotMatch(title))
}
}
override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
return rateLimitedClient.newCall(mangaDetailsRequest(manga))
.asObservableSuccess()
.map { response ->
val document = response.asJsoup()
mangaDetailsParseWithTitleCheck(manga, document).apply { initialized = true }
}
}
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
return rateLimitedClient.newCall(chapterListRequest(manga))
.asObservableSuccess()
.map { response ->
val document = response.asJsoup()
val title = mangaDetailsParseWithTitleCheck(manga, document).title
document.select(chapterListSelector()).map {
chapterFromElement(it).apply {
name = name.removePrefix(title).trimStart()
}
}
}
}
// not thread-safe
private val dateFormat by lazy { SimpleDateFormat("yyyy.MM.dd", Locale.ENGLISH) }
private fun parseChapterDate(date: String): Long {
return try {
if (date.contains(":")) {
val calendar = Calendar.getInstance()
val splitDate = date.split(":")
val hours = splitDate.first().toInt()
val minutes = splitDate.last().toInt()
val calendarHours = calendar.get(Calendar.HOUR)
val calendarMinutes = calendar.get(Calendar.MINUTE)
if (calendarHours >= hours && calendarMinutes > minutes) {
calendar.add(Calendar.DATE, -1)
}
calendar.timeInMillis
} else {
dateFormat.parse(date)?.time ?: 0
}
} catch (e: Exception) {
Log.e("NewToki", "failed to parse chapter date '$date'", e)
0
}
}
override fun pageListParse(document: Document): List<Page> {
val script = document.select("script:containsData(html_data)").firstOrNull()?.data()
?: throw Exception("data script not found")
val loadScript = document.select("script:containsData(data_attribute)").firstOrNull()?.data()
?: throw Exception("load script not found")
val dataAttr = "abs:data-" + loadScript.substringAfter("data_attribute: '").substringBefore("',")
return htmlDataRegex.findAll(script).map { it.groupValues[1] }
.asIterable()
.flatMap { it.split(".") }
.joinToString("") { it.toIntOrNull(16)?.toChar()?.toString() ?: "" }
.let { Jsoup.parse(it) }
.select("img[src=/img/loading-image.gif], .view-img > img[itemprop]")
.mapIndexed { i, img -> Page(i, "", if (img.hasAttr(dataAttr)) img.attr(dataAttr) else img.attr("abs:content")) }
}
override fun latestUpdatesSelector() = ".media.post-list"
override fun latestUpdatesFromElement(element: Element) = ManaToki.latestUpdatesElementParse(element)
override fun latestUpdatesRequest(page: Int) = GET("$baseUrl/page/update?hid=update&page=$page", headers)
override fun latestUpdatesNextPageSelector() = ".pg_end"
// We are able to get the image URL directly from the page list
override fun imageUrlParse(document: Document) = throw UnsupportedOperationException("This method should not be called!")
override fun setupPreferenceScreen(screen: androidx.preference.PreferenceScreen) {
getPreferencesInternal(screen.context).map(screen::addPreference)
}
protected fun getUrlPath(orig: String): String {
val url = baseUrl.toHttpUrl().resolve(orig) ?: return orig
val pathSegments = url.pathSegments
return "/${pathSegments[0]}/${pathSegments[1]}"
}
private fun isCleanPath(absUrl: String): Boolean {
val url = absUrl.toHttpUrl()
return url.pathSegments.size == 2 && url.querySize == 0 && url.fragment == null
}
companion object {
const val PREFIX_ID_SEARCH = "id:"
private val chapterNumberRegex by lazy { Regex("([0-9]+)(?:[-.]([0-9]+))?화") }
private val htmlDataRegex by lazy { Regex("""html_data\+='([^']+)'""") }
}
}

View File

@ -0,0 +1,146 @@
package eu.kanade.tachiyomi.extension.ko.newtoki
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.source.model.Filter
import eu.kanade.tachiyomi.source.model.FilterList
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Request
object NewTokiWebtoon : NewToki("NewToki", "webtoon", newTokiPreferences) {
// / ! DO NOT CHANGE THIS ! Prevent to treating as a new site
override val id = NEWTOKI_ID
override val baseUrl get() = "https://$NEWTOKI_PREFIX$domainNumber.com"
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
val url = ("$baseUrl/webtoon" + (if (page > 1) "/p$page" else "")).toHttpUrl().newBuilder()
filters.forEach { filter ->
when (filter) {
is SearchTargetTypeList -> {
if (filter.state > 0) {
url.addQueryParameter("toon", filter.values[filter.state])
}
}
is SearchSortTypeList -> {
val state = filter.state ?: return@forEach
url.addQueryParameter("sst", arrayOf("as_update", "wr_hit", "wr_good")[state.index])
url.addQueryParameter("sod", if (state.ascending) "asc" else "desc")
}
else -> {}
}
}
// Incompatible with Other Search Parameter
if (!query.isBlank()) {
url.addQueryParameter("stx", query)
} else {
filters.forEach { filter ->
when (filter) {
is SearchYoilTypeList -> {
if (filter.state > 0) {
url.addQueryParameter("yoil", filter.values[filter.state])
}
}
is SearchJaumTypeList -> {
if (filter.state > 0) {
url.addQueryParameter("jaum", filter.values[filter.state])
}
}
is SearchGenreTypeList -> {
if (filter.state > 0) {
url.addQueryParameter("tag", filter.values[filter.state])
}
}
else -> {}
}
}
}
return GET(url.toString(), headers)
}
private class SearchTargetTypeList : Filter.Select<String>("Type", arrayOf("전체", "일반웹툰", "성인웹툰", "BL/GL", "완결웹툰"))
// [...document.querySelectorAll("form.form td")[1].querySelectorAll("a")].map((el, i) => `"${el.innerText.trim()}"`).join(',\n')
private class SearchYoilTypeList : Filter.Select<String>(
"Day of the Week",
arrayOf(
"전체",
"",
"",
"",
"",
"",
"",
"",
"열흘",
),
)
// [...document.querySelectorAll("form.form td")[2].querySelectorAll("a")].map((el, i) => `"${el.innerText.trim()}"`).join(',\n')
private class SearchJaumTypeList : Filter.Select<String>(
"Jaum",
arrayOf(
"전체",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"a-z",
"0-9",
),
)
// [...document.querySelectorAll("form.form td")[3].querySelectorAll("a")].map((el, i) => `"${el.innerText.trim()}"`).join(',\n')
private class SearchGenreTypeList : Filter.Select<String>(
"Genre",
arrayOf(
"전체",
"판타지",
"액션",
"개그",
"미스터리",
"로맨스",
"드라마",
"무협",
"스포츠",
"일상",
"학원",
"성인",
),
)
private class SearchSortTypeList : Filter.Sort(
"Sort",
arrayOf(
"기본(업데이트순)",
"인기순",
"추천순",
),
)
override fun getFilterList() = FilterList(
SearchTargetTypeList(),
SearchSortTypeList(),
Filter.Separator(),
Filter.Header(ignoredForTextSearch()),
SearchYoilTypeList(),
SearchJaumTypeList(),
SearchGenreTypeList(),
)
}

View File

@ -0,0 +1,90 @@
package eu.kanade.tachiyomi.extension.ko.newtoki
import android.app.Application
import android.content.Context
import android.content.SharedPreferences
import androidx.preference.EditTextPreference
import androidx.preference.ListPreference
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
const val MANATOKI_ID = 2526381983439079467L // "NewToki/ko/1"
const val NEWTOKI_ID = 1977818283770282459L // "NewToki (Webtoon)/ko/1"
const val MANATOKI_PREFIX = "manatoki"
const val NEWTOKI_PREFIX = "newtoki"
val manaTokiPreferences = getSharedPreferences(MANATOKI_ID).migrate()
val newTokiPreferences = getSharedPreferences(NEWTOKI_ID).migrate()
fun getPreferencesInternal(context: Context) = arrayOf(
EditTextPreference(context).apply {
key = DOMAIN_NUMBER_PREF
title = domainNumberTitle()
summary = domainNumberSummary()
setOnPreferenceChangeListener { _, newValue ->
val value = newValue as String
if (value.isEmpty() || value != value.trim()) {
false
} else {
domainNumber = value
true
}
}
},
ListPreference(context).apply {
key = RATE_LIMIT_PERIOD_PREF
title = rateLimitTitle()
summary = "%s\n" + requiresAppRestart()
val values = Array(RATE_LIMIT_PERIOD_MAX) { (it + 1).toString() }
entries = Array(RATE_LIMIT_PERIOD_MAX) { rateLimitEntry(values[it]) }
entryValues = values
setDefaultValue(RATE_LIMIT_PERIOD_DEFAULT)
},
)
var SharedPreferences.domainNumber: String
get() = getString(DOMAIN_NUMBER_PREF, "")!!
set(value) = edit().putString(DOMAIN_NUMBER_PREF, value).apply()
val SharedPreferences.rateLimitPeriod: Int
get() = getString(RATE_LIMIT_PERIOD_PREF, RATE_LIMIT_PERIOD_DEFAULT)!!.toInt().coerceIn(1, RATE_LIMIT_PERIOD_MAX)
private fun SharedPreferences.migrate(): SharedPreferences {
if ("Override BaseUrl" !in this) return this // already migrated
val editor = edit().clear() // clear all legacy preferences listed below
val oldValue = try { // this was a long
getLong(RATE_LIMIT_PERIOD_PREF, -1).toInt()
} catch (_: ClassCastException) {
-1
}
if (oldValue != -1) { // convert to string
val newValue = oldValue.coerceIn(1, RATE_LIMIT_PERIOD_MAX)
editor.putString(RATE_LIMIT_PERIOD_PREF, newValue.toString())
}
editor.apply()
return this
}
/**
* Don't use the following legacy keys:
* - "Override BaseUrl"
* - "overrideBaseUrl_v${AppInfo.getVersionName()}"
* - "Enable Latest (Experimental)"
* - "fetchLatestExperiment"
* - "Fetch Latest with detail (Optional)"
* - "fetchLatestWithDetail"
* - "Rate Limit Request Period Seconds"
*/
private const val DOMAIN_NUMBER_PREF = "domainNumber"
private const val RATE_LIMIT_PERIOD_PREF = "rateLimitPeriod"
private const val RATE_LIMIT_PERIOD_DEFAULT = 2.toString()
private const val RATE_LIMIT_PERIOD_MAX = 9
private fun getSharedPreferences(id: Long): SharedPreferences =
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)

View File

@ -0,0 +1,76 @@
package eu.kanade.tachiyomi.extension.ko.newtoki
import android.os.Build
import android.os.LocaleList
import java.util.Locale
private val useKorean by lazy {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
LocaleList.getDefault().getFirstMatch(arrayOf("ko", "en"))?.language == "ko"
} else {
Locale.getDefault().language == "ko"
}
}
// region Prompts
fun solveCaptcha() = when {
useKorean -> "WebView에서 캡챠 풀기"
else -> "Solve Captcha with WebView"
}
fun titleNotMatch(realTitle: String) = when {
useKorean -> "이 만화를 찾으시려면 '$realTitle'으로 검색하세요"
else -> "Find this manga by searching '$realTitle'"
}
fun needMigration() = when {
useKorean -> "이 항목은 URL 포맷이 틀립니다. 중복된 항목을 피하려면 동일한 소스로 이전하세요.\n\n"
else -> "This entry has wrong URL format. Please migrate to the same source to avoid duplicates.\n\n"
}
// endregion
// region Filters
fun ignoredForTextSearch() = when {
useKorean -> "검색에서 다음 필터 항목은 무시됩니다"
else -> "The following filters are ignored for text search"
}
// endregion
// region Preferences
fun domainNumberTitle() = when {
useKorean -> "도메인 번호"
else -> "Domain number"
}
fun domainNumberSummary() = when {
useKorean -> "도메인 번호는 자동으로 갱신됩니다"
else -> "This number is updated automatically"
}
fun editDomainNumber() = when {
useKorean -> "확장기능 설정에서 도메인 번호를 수정해 주세요"
else -> "Please edit domain number in extension settings"
}
fun rateLimitTitle() = when {
useKorean -> "요청 제한"
else -> "Rate limit"
}
fun rateLimitEntry(period: String) = when {
useKorean -> "${period}초마다 요청"
else -> "1 request every $period seconds"
}
// taken from app strings
fun requiresAppRestart() = when {
useKorean -> "설정을 적용하려면 앱을 재시작하세요"
else -> "Requires app restart to take effect"
}
// endregion

View File

@ -0,0 +1,7 @@
package eu.kanade.tachiyomi.extension.ko.newtoki
import eu.kanade.tachiyomi.source.SourceFactory
class TokiFactory : SourceFactory {
override fun createSources() = listOf(ManaToki, NewTokiWebtoon)
}