added dummy extension
@ -1,2 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest package="eu.kanade.tachiyomi.extension" />
|
@ -1,13 +0,0 @@
|
||||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'kotlin-android'
|
||||
|
||||
ext {
|
||||
extName = 'Bato.to'
|
||||
pkgNameSuffix = 'all.batoto'
|
||||
extClass = '.BatoToFactory'
|
||||
extVersionCode = 7
|
||||
libVersion = '1.2'
|
||||
containsNsfw = true
|
||||
}
|
||||
|
||||
apply from: "$rootDir/common.gradle"
|
Before Width: | Height: | Size: 3.8 KiB |
Before Width: | Height: | Size: 2.2 KiB |
Before Width: | Height: | Size: 5.1 KiB |
Before Width: | Height: | Size: 9.3 KiB |
Before Width: | Height: | Size: 14 KiB |
Before Width: | Height: | Size: 67 KiB |
@ -1,518 +0,0 @@
|
||||
package eu.kanade.tachiyomi.extension.all.batoto
|
||||
|
||||
import com.squareup.duktape.Duktape
|
||||
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.Page
|
||||
import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import eu.kanade.tachiyomi.source.online.ParsedHttpSource
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
import java.util.Calendar
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
open class BatoTo(
|
||||
override val lang: String,
|
||||
private val siteLang: String
|
||||
) : ParsedHttpSource() {
|
||||
|
||||
override val name: String = "Bato.to"
|
||||
override val baseUrl: String = "https://bato.to"
|
||||
|
||||
override val supportsLatest = true
|
||||
|
||||
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()
|
||||
manga.thumbnail_url = imgurl
|
||||
return manga
|
||||
}
|
||||
|
||||
override fun latestUpdatesNextPageSelector() = "div#mainer .pagination .page-item:not(.disabled) a.page-link:contains(»)"
|
||||
|
||||
override fun popularMangaRequest(page: Int): Request {
|
||||
return GET("$baseUrl/browse?langs=$siteLang&sort=views_w&page=$page")
|
||||
}
|
||||
|
||||
override fun popularMangaSelector() = latestUpdatesSelector()
|
||||
|
||||
override fun popularMangaFromElement(element: Element) = latestUpdatesFromElement(element)
|
||||
|
||||
override fun popularMangaNextPageSelector() = latestUpdatesNextPageSelector()
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
return if (query.isNotBlank()) {
|
||||
GET("$baseUrl/search?word=$query&page=$page")
|
||||
} else {
|
||||
val url = HttpUrl.parse("$baseUrl/browse")!!.newBuilder()
|
||||
url.addQueryParameter("page", page.toString())
|
||||
url.addQueryParameter("langs", siteLang)
|
||||
filters.forEach { filter ->
|
||||
when (filter) {
|
||||
is OriginFilter -> {
|
||||
val originToInclude = mutableListOf<String>()
|
||||
filter.state.forEach { content ->
|
||||
if (content.state) {
|
||||
originToInclude.add(content.name)
|
||||
}
|
||||
}
|
||||
if (originToInclude.isNotEmpty()) {
|
||||
url.addQueryParameter(
|
||||
"origs",
|
||||
originToInclude
|
||||
.joinToString(",")
|
||||
)
|
||||
}
|
||||
}
|
||||
is StatusFilter -> {
|
||||
if (filter.state != 0) {
|
||||
url.addQueryParameter("release", filter.toUriPart())
|
||||
}
|
||||
}
|
||||
is GenreFilter -> {
|
||||
val genreToInclude = filter.state
|
||||
.filter { it.isIncluded() }
|
||||
.map { it.name }
|
||||
|
||||
val genreToExclude = filter.state
|
||||
.filter { it.isExcluded() }
|
||||
.map { it.name }
|
||||
|
||||
if (genreToInclude.isNotEmpty() || genreToExclude.isNotEmpty()) {
|
||||
url.addQueryParameter(
|
||||
"genres",
|
||||
genreToInclude
|
||||
.joinToString(",") +
|
||||
"|" +
|
||||
genreToExclude
|
||||
.joinToString(",")
|
||||
)
|
||||
}
|
||||
}
|
||||
is ChapterFilter -> {
|
||||
if (filter.state != 0) {
|
||||
url.addQueryParameter("chapters", filter.toUriPart())
|
||||
}
|
||||
}
|
||||
is SortBy -> {
|
||||
if (filter.state != 0) {
|
||||
url.addQueryParameter("sort", filter.toUriPart())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
GET(url.build().toString(), headers)
|
||||
}
|
||||
}
|
||||
|
||||
override fun searchMangaSelector() = latestUpdatesSelector()
|
||||
|
||||
override fun searchMangaFromElement(element: Element) = latestUpdatesFromElement(element)
|
||||
|
||||
override fun searchMangaNextPageSelector() = latestUpdatesNextPageSelector()
|
||||
|
||||
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 genres = mutableListOf<String>()
|
||||
val status = infoElement.select("div.attr-item:contains(status) span").text()
|
||||
infoElement.select("div.attr-item:contains(genres) span").text().split(
|
||||
" / "
|
||||
.toRegex()
|
||||
).forEach { element ->
|
||||
genres.add(element)
|
||||
}
|
||||
manga.title = infoElement.select("h3").text()
|
||||
manga.author = infoElement.select("div.attr-item:contains(author) a:first-child").text()
|
||||
manga.artist = infoElement.select("div.attr-item:contains(author) a:last-child").text()
|
||||
manga.status = parseStatus(status)
|
||||
manga.genre = infoElement.select(".attr-item b:contains(genres) + span ").joinToString { it.text() }
|
||||
manga.description = infoElement.select("h5:contains(summary) + pre").text()
|
||||
manga.thumbnail_url = document.select("div.attr-cover img")
|
||||
.attr("abs:src")
|
||||
return manga
|
||||
}
|
||||
|
||||
private fun parseStatus(status: String?) = when {
|
||||
status == null -> SManga.UNKNOWN
|
||||
status.contains("Ongoing") -> SManga.ONGOING
|
||||
status.contains("Completed") -> SManga.COMPLETED
|
||||
else -> SManga.UNKNOWN
|
||||
}
|
||||
|
||||
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("i").text()
|
||||
.replace("a ", "1 ")
|
||||
.replace("an ", "1 ")
|
||||
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 pages = mutableListOf<Page>()
|
||||
|
||||
val script = document.select("script").html()
|
||||
|
||||
if (script.contains("var images =")) {
|
||||
val imgJson = JSONObject(script.substringAfter("var images = ").substringBefore(";"))
|
||||
val imgNames = imgJson.names()
|
||||
|
||||
if (imgNames != null) {
|
||||
for (i in 0 until imgNames.length()) {
|
||||
val imgKey = imgNames.getString(i)
|
||||
val imgUrl = imgJson.getString(imgKey)
|
||||
pages.add(Page(i, "", imgUrl))
|
||||
}
|
||||
}
|
||||
} else if (script.contains("const server =")) { // bato.to
|
||||
val duktape = Duktape.create()
|
||||
val encryptedServer = script.substringAfter("const server = ").substringBefore(";")
|
||||
val batojs = duktape.evaluate(script.substringAfter("const batojs = ").substringBefore(";")).toString()
|
||||
val decryptScript = cryptoJS + "CryptoJS.AES.decrypt($encryptedServer, \"$batojs\").toString(CryptoJS.enc.Utf8);"
|
||||
val server = duktape.evaluate(decryptScript).toString().replace("\"", "")
|
||||
duktape.close()
|
||||
|
||||
val imgArray = JSONArray(script.substringAfter("const images = ").substringBefore(";"))
|
||||
if (imgArray != null) {
|
||||
if (script.contains("bato.to/images")) {
|
||||
for (i in 0 until imgArray.length()) {
|
||||
val imgUrl = imgArray.get(i)
|
||||
pages.add(Page(i, "", "$imgUrl"))
|
||||
}
|
||||
} else {
|
||||
for (i in 0 until imgArray.length()) {
|
||||
val imgUrl = imgArray.get(i)
|
||||
if (server.startsWith("http"))
|
||||
pages.add(Page(i, "", "${server}$imgUrl"))
|
||||
else
|
||||
pages.add(Page(i, "", "https:${server}$imgUrl"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return pages
|
||||
}
|
||||
|
||||
private val cryptoJS by lazy {
|
||||
client.newCall(
|
||||
GET(
|
||||
"https://cdnjs.cloudflare.com/ajax/libs/crypto-js/4.0.0/crypto-js.min.js",
|
||||
headers
|
||||
)
|
||||
).execute().body()!!.string()
|
||||
}
|
||||
|
||||
override fun imageUrlParse(document: Document): String = throw UnsupportedOperationException("Not used")
|
||||
|
||||
private class OriginFilter(genres: List<Tag>) : Filter.Group<Tag>("Origin", genres)
|
||||
private class GenreFilter(genres: List<Genre>) : Filter.Group<Genre>("Genres", genres)
|
||||
|
||||
private class ChapterFilter : UriPartFilter(
|
||||
"Chapters",
|
||||
arrayOf(
|
||||
Pair("<select>", ""),
|
||||
Pair("1 ~ 9", "1-9"),
|
||||
Pair("10 ~ 29", "10-29"),
|
||||
Pair("30 ~ 99", "30-99"),
|
||||
Pair("100 ~ 199", "100-199"),
|
||||
Pair("200+", "200"),
|
||||
Pair("100+", "100"),
|
||||
Pair("50+", "50"),
|
||||
Pair("10+", "10"),
|
||||
Pair("1+", "1")
|
||||
)
|
||||
)
|
||||
|
||||
private class SortBy : UriPartFilter(
|
||||
"Sorts By",
|
||||
arrayOf(
|
||||
Pair("<select>", ""),
|
||||
Pair("A-Z", "title.az"),
|
||||
Pair("Z-A", "title"),
|
||||
Pair("Last Updated", "update"),
|
||||
Pair("Oldest Updated", "updated.az"),
|
||||
Pair("Newest Added", "create"),
|
||||
Pair("Oldest Added", "create.az"),
|
||||
Pair("Most Views Totally", "views_a"),
|
||||
Pair("Most Views 365 days", "views_y"),
|
||||
Pair("Most Views 30 days", "views_m"),
|
||||
Pair("Most Views 7 days", "views_w"),
|
||||
Pair("Most Views 24 hours", "views_d"),
|
||||
Pair("Most Views 60 minutes", "views_h"),
|
||||
Pair("Least Views Totally", "views_a.az"),
|
||||
Pair("Least Views 365 days", "views_y.az"),
|
||||
Pair("Least Views 30 days", "views_m.az"),
|
||||
Pair("Least Views 7 days", "views_w.az"),
|
||||
Pair("Least Views 24 hours", "views_d.az"),
|
||||
Pair("Least Views 60 minutes", "views_h.az")
|
||||
)
|
||||
)
|
||||
|
||||
private class StatusFilter : UriPartFilter(
|
||||
"Status",
|
||||
arrayOf(
|
||||
Pair("<select>", ""),
|
||||
Pair("Pending", "pending"),
|
||||
Pair("Ongoing", "ongoing"),
|
||||
Pair("Completed", "completed"),
|
||||
Pair("Hiatus", "hiatus"),
|
||||
Pair("Cancelled", "cancelled")
|
||||
)
|
||||
)
|
||||
|
||||
override fun getFilterList() = FilterList(
|
||||
Filter.Header("NOTE: Ignored if using text search!"),
|
||||
Filter.Separator(),
|
||||
ChapterFilter(),
|
||||
SortBy(),
|
||||
StatusFilter(),
|
||||
OriginFilter(getOriginList()),
|
||||
GenreFilter(getGenreList())
|
||||
)
|
||||
|
||||
private fun getOriginList() = listOf(
|
||||
Tag("my"),
|
||||
Tag("ceb"),
|
||||
Tag("zh"),
|
||||
Tag("zh_hk"),
|
||||
Tag("en"),
|
||||
Tag("en_us"),
|
||||
Tag("fil"),
|
||||
Tag("id"),
|
||||
Tag("it"),
|
||||
Tag("ja"),
|
||||
Tag("ko"),
|
||||
Tag("ms"),
|
||||
Tag("pt_br"),
|
||||
Tag("th"),
|
||||
Tag("vi")
|
||||
)
|
||||
|
||||
private fun getGenreList() = listOf(
|
||||
Genre("Artbook"),
|
||||
Genre("Cartoon"),
|
||||
Genre("Comic"),
|
||||
Genre("Doujinshi"),
|
||||
Genre("Imageset"),
|
||||
Genre("Manga"),
|
||||
Genre("Manhua"),
|
||||
Genre("Manhwa"),
|
||||
Genre("Webtoon"),
|
||||
Genre("Western"),
|
||||
Genre("Josei"),
|
||||
Genre("Seinen"),
|
||||
Genre("Shoujo"),
|
||||
Genre("Shoujo_Ai"),
|
||||
Genre("Shounen"),
|
||||
Genre("Shounen_Ai"),
|
||||
Genre("Yaoi"),
|
||||
Genre("Yuri"),
|
||||
Genre("Ecchi"),
|
||||
Genre("Mature"),
|
||||
Genre("Adult"),
|
||||
Genre("Gore"),
|
||||
Genre("Violence"),
|
||||
Genre("Smut"),
|
||||
Genre("Hentai"),
|
||||
Genre("4_Koma"),
|
||||
Genre("Action"),
|
||||
Genre("Adaptation"),
|
||||
Genre("Adventure"),
|
||||
Genre("Aliens"),
|
||||
Genre("Animals"),
|
||||
Genre("Anthology"),
|
||||
Genre("Comedy"),
|
||||
Genre("Cooking"),
|
||||
Genre("Crime"),
|
||||
Genre("Crossdressing"),
|
||||
Genre("Delinquents"),
|
||||
Genre("Dementia"),
|
||||
Genre("Demons"),
|
||||
Genre("Drama"),
|
||||
Genre("Fantasy"),
|
||||
Genre("Fan_Colored"),
|
||||
Genre("Full_Color"),
|
||||
Genre("Game"),
|
||||
Genre("Gender_Bender"),
|
||||
Genre("Genderswap"),
|
||||
Genre("Ghosts"),
|
||||
Genre("Gyaru"),
|
||||
Genre("Harem"),
|
||||
Genre("Harlequin"),
|
||||
Genre("Historical"),
|
||||
Genre("Horror"),
|
||||
Genre("Incest"),
|
||||
Genre("Isekai"),
|
||||
Genre("Kids"),
|
||||
Genre("Loli"),
|
||||
Genre("Lolicon"),
|
||||
Genre("Magic"),
|
||||
Genre("Magical_Girls"),
|
||||
Genre("Martial_Arts"),
|
||||
Genre("Mecha"),
|
||||
Genre("Medical"),
|
||||
Genre("Military"),
|
||||
Genre("Monster_Girls"),
|
||||
Genre("Monsters"),
|
||||
Genre("Music"),
|
||||
Genre("Mystery"),
|
||||
Genre("Netorare"),
|
||||
Genre("Ninja"),
|
||||
Genre("Office_Workers"),
|
||||
Genre("Oneshot"),
|
||||
Genre("Parody"),
|
||||
Genre("Philosophical"),
|
||||
Genre("Police"),
|
||||
Genre("Post_Apocalyptic"),
|
||||
Genre("Psychological"),
|
||||
Genre("Reincarnation"),
|
||||
Genre("Reverse_Harem"),
|
||||
Genre("Romance"),
|
||||
Genre("Samurai"),
|
||||
Genre("School_Life"),
|
||||
Genre("Sci_Fi"),
|
||||
Genre("Shota"),
|
||||
Genre("Shotacon"),
|
||||
Genre("Slice_Of_Life"),
|
||||
Genre("SM_BDSM"),
|
||||
Genre("Space"),
|
||||
Genre("Sports"),
|
||||
Genre("Super_Power"),
|
||||
Genre("Superhero"),
|
||||
Genre("Supernatural"),
|
||||
Genre("Survival"),
|
||||
Genre("Thriller"),
|
||||
Genre("Time_Travel"),
|
||||
Genre("Tragedy"),
|
||||
Genre("Vampires"),
|
||||
Genre("Video_Games"),
|
||||
Genre("Virtual_Reality"),
|
||||
Genre("Wuxia"),
|
||||
Genre("Xianxia"),
|
||||
Genre("Xuanhuan"),
|
||||
Genre("Zombies"),
|
||||
Genre("award_winning"),
|
||||
Genre("youkai"),
|
||||
Genre("uncategorized")
|
||||
)
|
||||
|
||||
private open class UriPartFilter(displayName: String, val vals: Array<Pair<String, String>>) :
|
||||
Filter.Select<String>(displayName, vals.map { it.first }.toTypedArray()) {
|
||||
fun toUriPart() = vals[state].second
|
||||
}
|
||||
|
||||
private class Tag(name: String) : Filter.CheckBox(name)
|
||||
private class Genre(name: String) : Filter.TriState(name)
|
||||
}
|
@ -1,60 +0,0 @@
|
||||
package eu.kanade.tachiyomi.extension.all.batoto
|
||||
|
||||
import eu.kanade.tachiyomi.annotations.Nsfw
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.source.SourceFactory
|
||||
|
||||
@Nsfw
|
||||
class BatoToFactory : SourceFactory {
|
||||
override fun createSources(): List<Source> = languages.map { BatoTo(it.first, it.second) }
|
||||
}
|
||||
|
||||
private val languages = listOf(
|
||||
//commented langueges do currently not exist on Bato.to but haven in the past
|
||||
Pair("all",""),
|
||||
Pair("ar", "ar"),
|
||||
Pair("bg", "bg"),
|
||||
Pair("cs", "cs"),
|
||||
Pair("da", "da"),
|
||||
Pair("de", "de"),
|
||||
Pair("el", "el"),
|
||||
Pair("en", "en"),
|
||||
Pair("en-US", "en_us"),
|
||||
Pair("es", "es"),
|
||||
Pair("es-419", "es_419"),
|
||||
Pair("eu", "eu"),
|
||||
Pair("fa", "fa"),
|
||||
Pair("fi", "fi"),
|
||||
Pair("fil", "fil"),
|
||||
Pair("fr", "fr"),
|
||||
Pair("he", "he"),
|
||||
//Pair("hi", "hi"),
|
||||
Pair("hr", "hr"),
|
||||
Pair("hu", "hu"),
|
||||
Pair("id", "id"),
|
||||
Pair("it", "it"),
|
||||
Pair("ja", "ja"),
|
||||
Pair("ko", "ko"),
|
||||
//Pair("ku", "ku"),
|
||||
Pair("ml", "ml"),
|
||||
Pair("mn", "mn"),
|
||||
Pair("ms", "ms"),
|
||||
Pair("my", "my"),
|
||||
Pair("nl", "nl"),
|
||||
Pair("no", "no"),
|
||||
Pair("pl", "pl"),
|
||||
Pair("pt", "pt"),
|
||||
Pair("pt-BR", "pt_br"),
|
||||
Pair("pt-PT", "pt_pt"),
|
||||
Pair("ro", "ro"),
|
||||
Pair("ru", "ru"),
|
||||
Pair("th", "th"),
|
||||
Pair("tr", "tr"),
|
||||
Pair("uk", "uk"),
|
||||
Pair("vi", "vi"),
|
||||
//Pair("xh", "xh"),
|
||||
Pair("zh", "zh"),
|
||||
Pair("zh-rHK", "zh_hk"),
|
||||
Pair("zh-rTW", "zh_tw"),
|
||||
Pair("zu", "zu"),
|
||||
)
|
@ -1,57 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="eu.kanade.tachiyomi.extension">
|
||||
|
||||
<application>
|
||||
<activity
|
||||
android:name=".all.cubari.CubariUrlActivity"
|
||||
android:excludeFromRecents="true"
|
||||
android:theme="@android:style/Theme.NoDisplay">
|
||||
<!-- We need another intent filter so the /a/..* shortcut -->
|
||||
<!-- doesn't pollute the cubari one, since they work in any combination -->
|
||||
<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="*cubari.moe"
|
||||
android:pathPattern="/read/..*"
|
||||
android:scheme="https" />
|
||||
|
||||
<data
|
||||
android:host="*cubari.moe"
|
||||
android:pathPattern="/proxy/..*"
|
||||
android:scheme="https" />
|
||||
</intent-filter>
|
||||
<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="*guya.moe"
|
||||
android:pathPattern="/proxy/..*"
|
||||
android:scheme="https" />
|
||||
</intent-filter>
|
||||
<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="*imgur.com"
|
||||
android:pathPattern="/a/..*"
|
||||
android:scheme="https" />
|
||||
|
||||
<data
|
||||
android:host="*imgur.com"
|
||||
android:pathPattern="/gallery/..*"
|
||||
android:scheme="https" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
@ -1,12 +0,0 @@
|
||||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'kotlin-android'
|
||||
|
||||
ext {
|
||||
extName = 'Cubari'
|
||||
pkgNameSuffix = "all.cubari"
|
||||
extClass = '.CubariFactory'
|
||||
extVersionCode = 2
|
||||
libVersion = '1.2'
|
||||
}
|
||||
|
||||
apply from: "$rootDir/common.gradle"
|
Before Width: | Height: | Size: 4.1 KiB |
Before Width: | Height: | Size: 2.3 KiB |
Before Width: | Height: | Size: 5.5 KiB |
Before Width: | Height: | Size: 10 KiB |
Before Width: | Height: | Size: 15 KiB |
Before Width: | Height: | Size: 29 KiB |
@ -1,354 +0,0 @@
|
||||
package eu.kanade.tachiyomi.extension.all.cubari
|
||||
|
||||
import android.os.Build
|
||||
import eu.kanade.tachiyomi.extension.BuildConfig
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.asObservableSuccess
|
||||
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 okhttp3.Headers
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import rx.Observable
|
||||
|
||||
open class Cubari(override val lang: String) : HttpSource() {
|
||||
|
||||
final override val name = "Cubari"
|
||||
final override val baseUrl = "https://cubari.moe"
|
||||
final override val supportsLatest = true
|
||||
|
||||
override fun headersBuilder() = Headers.Builder().apply {
|
||||
add(
|
||||
"User-Agent",
|
||||
"(Android ${Build.VERSION.RELEASE}; " +
|
||||
"${Build.MANUFACTURER} ${Build.MODEL}) " +
|
||||
"Tachiyomi/${BuildConfig.VERSION_NAME} " +
|
||||
Build.ID
|
||||
)
|
||||
}
|
||||
|
||||
override fun latestUpdatesRequest(page: Int): Request {
|
||||
return GET("$baseUrl/", headers)
|
||||
}
|
||||
|
||||
override fun fetchLatestUpdates(page: Int): Observable<MangasPage> {
|
||||
return client.newBuilder()
|
||||
.addInterceptor(RemoteStorageUtils.HomeInterceptor())
|
||||
.build()!!
|
||||
.newCall(latestUpdatesRequest(page))
|
||||
.asObservableSuccess()
|
||||
.map { response -> latestUpdatesParse(response) }
|
||||
}
|
||||
|
||||
override fun latestUpdatesParse(response: Response): MangasPage {
|
||||
return parseMangaList(JSONArray(response.body()!!.string()), SortType.UNPINNED)
|
||||
}
|
||||
|
||||
override fun popularMangaRequest(page: Int): Request {
|
||||
return GET("$baseUrl/", headers)
|
||||
}
|
||||
|
||||
override fun fetchPopularManga(page: Int): Observable<MangasPage> {
|
||||
return client.newBuilder()
|
||||
.addInterceptor(RemoteStorageUtils.HomeInterceptor())
|
||||
.build()!!
|
||||
.newCall(popularMangaRequest(page))
|
||||
.asObservableSuccess()
|
||||
.map { response -> popularMangaParse(response) }
|
||||
}
|
||||
|
||||
override fun popularMangaParse(response: Response): MangasPage {
|
||||
return parseMangaList(JSONArray(response.body()!!.string()), SortType.PINNED)
|
||||
}
|
||||
|
||||
override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
|
||||
return client.newCall(chapterListRequest(manga))
|
||||
.asObservableSuccess()
|
||||
.map { response -> mangaDetailsParse(response, manga) }
|
||||
}
|
||||
|
||||
// Called when the series is loaded, or when opening in browser
|
||||
override fun mangaDetailsRequest(manga: SManga): Request {
|
||||
return GET("$baseUrl${manga.url}", headers)
|
||||
}
|
||||
|
||||
override fun mangaDetailsParse(response: Response): SManga {
|
||||
throw Exception("Unused")
|
||||
}
|
||||
|
||||
private fun mangaDetailsParse(response: Response, manga: SManga): SManga {
|
||||
return parseMangaFromApi(JSONObject(response.body()!!.string()), manga)
|
||||
}
|
||||
|
||||
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
|
||||
return client.newCall(chapterListRequest(manga))
|
||||
.asObservableSuccess()
|
||||
.map { response -> chapterListParse(response, manga) }
|
||||
}
|
||||
|
||||
// Gets the chapter list based on the series being viewed
|
||||
override fun chapterListRequest(manga: SManga): Request {
|
||||
val urlComponents = manga.url.split("/")
|
||||
val source = urlComponents[2]
|
||||
val slug = urlComponents[3]
|
||||
|
||||
return GET("$baseUrl/read/api/$source/series/$slug/", headers)
|
||||
}
|
||||
|
||||
override fun chapterListParse(response: Response): List<SChapter> {
|
||||
throw Exception("Unused")
|
||||
}
|
||||
|
||||
// Called after the request
|
||||
private fun chapterListParse(response: Response, manga: SManga): List<SChapter> {
|
||||
val res = response.body()!!.string()
|
||||
return parseChapterList(res, manga)
|
||||
}
|
||||
|
||||
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
|
||||
return when {
|
||||
chapter.url.contains("/chapter/") -> {
|
||||
client.newCall(pageListRequest(chapter))
|
||||
.asObservableSuccess()
|
||||
.map { response ->
|
||||
directPageListParse(response)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
client.newCall(pageListRequest(chapter))
|
||||
.asObservableSuccess()
|
||||
.map { response ->
|
||||
seriesJsonPageListParse(response, chapter)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun pageListRequest(chapter: SChapter): Request {
|
||||
return when {
|
||||
chapter.url.contains("/chapter/") -> {
|
||||
GET("$baseUrl${chapter.url}", headers)
|
||||
}
|
||||
else -> {
|
||||
var url = chapter.url.split("/")
|
||||
val source = url[2]
|
||||
val slug = url[3]
|
||||
|
||||
GET("$baseUrl/read/api/$source/series/$slug/", headers)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun directPageListParse(response: Response): List<Page> {
|
||||
val res = response.body()!!.string()
|
||||
val pages = JSONArray(res)
|
||||
val pageArray = ArrayList<Page>()
|
||||
|
||||
for (i in 0 until pages.length()) {
|
||||
val page = if (pages.optJSONObject(i) != null) {
|
||||
pages.getJSONObject(i).getString("src")
|
||||
} else {
|
||||
pages[i]
|
||||
}
|
||||
pageArray.add(Page(i + 1, "", page.toString()))
|
||||
}
|
||||
return pageArray
|
||||
}
|
||||
|
||||
private fun seriesJsonPageListParse(response: Response, chapter: SChapter): List<Page> {
|
||||
val res = response.body()!!.string()
|
||||
val json = JSONObject(res)
|
||||
val groups = json.getJSONObject("groups")
|
||||
val groupIter = groups.keys()
|
||||
val groupMap = HashMap<String, String>()
|
||||
|
||||
while (groupIter.hasNext()) {
|
||||
val groupKey = groupIter.next()
|
||||
groupMap[groups.getString(groupKey)] = groupKey
|
||||
}
|
||||
|
||||
val chapters = json.getJSONObject("chapters")
|
||||
|
||||
val pages = if (chapters.has(chapter.chapter_number.toString())) {
|
||||
chapters
|
||||
.getJSONObject(chapter.chapter_number.toString())
|
||||
.getJSONObject("groups")
|
||||
.getJSONArray(groupMap[chapter.scanlator])
|
||||
} else {
|
||||
chapters
|
||||
.getJSONObject(chapter.chapter_number.toInt().toString())
|
||||
.getJSONObject("groups")
|
||||
.getJSONArray(groupMap[chapter.scanlator])
|
||||
}
|
||||
val pageArray = ArrayList<Page>()
|
||||
for (i in 0 until pages.length()) {
|
||||
val page = if (pages.optJSONObject(i) != null) {
|
||||
pages.getJSONObject(i).getString("src")
|
||||
} else {
|
||||
pages[i]
|
||||
}
|
||||
pageArray.add(Page(i + 1, "", page.toString()))
|
||||
}
|
||||
return pageArray
|
||||
}
|
||||
|
||||
// Stub
|
||||
override fun pageListParse(response: Response): List<Page> {
|
||||
throw Exception("Unused")
|
||||
}
|
||||
|
||||
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
|
||||
return when {
|
||||
query.startsWith(PROXY_PREFIX) -> {
|
||||
val trimmedQuery = query.removePrefix(PROXY_PREFIX)
|
||||
// Only tag for recently read on search
|
||||
client.newBuilder()
|
||||
.addInterceptor(RemoteStorageUtils.TagInterceptor())
|
||||
.build()!!
|
||||
.newCall(searchMangaRequest(page, trimmedQuery, filters))
|
||||
.asObservableSuccess()
|
||||
.map { response ->
|
||||
searchMangaParse(response, trimmedQuery)
|
||||
}
|
||||
}
|
||||
else -> throw Exception(SEARCH_FALLBACK_MSG)
|
||||
}
|
||||
}
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
try {
|
||||
val queryFragments = query.split("/")
|
||||
val source = queryFragments[0]
|
||||
val slug = queryFragments[1]
|
||||
|
||||
return GET("$baseUrl/read/api/$source/series/$slug/", headers)
|
||||
} catch (e: Exception) {
|
||||
throw Exception(SEARCH_FALLBACK_MSG)
|
||||
}
|
||||
}
|
||||
|
||||
override fun searchMangaParse(response: Response): MangasPage {
|
||||
throw Exception("Unused")
|
||||
}
|
||||
|
||||
private fun searchMangaParse(response: Response, query: String): MangasPage {
|
||||
return parseSearchList(JSONObject(response.body()!!.string()), query)
|
||||
}
|
||||
|
||||
// ------------- Helpers and whatnot ---------------
|
||||
|
||||
private fun parseChapterList(payload: String, manga: SManga): List<SChapter> {
|
||||
val json = JSONObject(payload)
|
||||
val groups = json.getJSONObject("groups")
|
||||
val chapters = json.getJSONObject("chapters")
|
||||
|
||||
val chapterList = ArrayList<SChapter>()
|
||||
|
||||
val iter = chapters.keys()
|
||||
|
||||
while (iter.hasNext()) {
|
||||
val chapterNum = iter.next()
|
||||
val chapterObj = chapters.getJSONObject(chapterNum)
|
||||
val chapterGroups = chapterObj.getJSONObject("groups")
|
||||
val groupsIter = chapterGroups.keys()
|
||||
|
||||
while (groupsIter.hasNext()) {
|
||||
val groupNum = groupsIter.next()
|
||||
val chapter = SChapter.create()
|
||||
|
||||
chapter.scanlator = groups.getString(groupNum)
|
||||
if (chapterObj.has("release_date")) {
|
||||
chapter.date_upload =
|
||||
chapterObj.getJSONObject("release_date").getLong(groupNum) * 1000
|
||||
}
|
||||
chapter.name = chapterNum + " - " + chapterObj.getString("title")
|
||||
chapter.chapter_number = chapterNum.toFloat()
|
||||
chapter.url =
|
||||
if (chapterGroups.optJSONArray(groupNum) != null) {
|
||||
"${manga.url}/$chapterNum/$groupNum"
|
||||
} else {
|
||||
chapterGroups.getString(groupNum)
|
||||
}
|
||||
chapterList.add(chapter)
|
||||
}
|
||||
}
|
||||
|
||||
return chapterList.reversed()
|
||||
}
|
||||
|
||||
private fun parseMangaList(payload: JSONArray, sortType: SortType): MangasPage {
|
||||
val mangas = ArrayList<SManga>()
|
||||
|
||||
for (i in 0 until payload.length()) {
|
||||
val json = payload.getJSONObject(i)
|
||||
val pinned = json.getBoolean("pinned")
|
||||
|
||||
if (sortType == SortType.PINNED && pinned) {
|
||||
mangas.add(parseMangaFromRemoteStorage(json))
|
||||
} else if (sortType == SortType.UNPINNED && !pinned) {
|
||||
mangas.add(parseMangaFromRemoteStorage(json))
|
||||
}
|
||||
}
|
||||
|
||||
return MangasPage(mangas, false)
|
||||
}
|
||||
|
||||
private fun parseSearchList(payload: JSONObject, query: String): MangasPage {
|
||||
val mangas = ArrayList<SManga>()
|
||||
val tempManga = SManga.create()
|
||||
tempManga.url = "/read/$query"
|
||||
mangas.add(parseMangaFromApi(payload, tempManga))
|
||||
return MangasPage(mangas, false)
|
||||
}
|
||||
|
||||
private fun parseMangaFromRemoteStorage(json: JSONObject): SManga {
|
||||
val manga = SManga.create()
|
||||
manga.title = json.getString("title")
|
||||
manga.artist = json.optString("artist", ARTIST_FALLBACK)
|
||||
manga.author = json.optString("author", AUTHOR_FALLBACK)
|
||||
manga.description = json.optString("description", DESCRIPTION_FALLBACK)
|
||||
manga.url = json.getString("url")
|
||||
manga.thumbnail_url = json.getString("coverUrl")
|
||||
|
||||
return manga
|
||||
}
|
||||
|
||||
private fun parseMangaFromApi(json: JSONObject, mangaReference: SManga): SManga {
|
||||
val manga = SManga.create()
|
||||
manga.title = json.getString("title")
|
||||
manga.artist = json.optString("artist", ARTIST_FALLBACK)
|
||||
manga.author = json.optString("author", AUTHOR_FALLBACK)
|
||||
manga.description = json.optString("description", DESCRIPTION_FALLBACK)
|
||||
manga.url = mangaReference.url
|
||||
manga.thumbnail_url = json.optString("cover", "")
|
||||
|
||||
return manga
|
||||
}
|
||||
|
||||
// ----------------- Things we aren't supporting -----------------
|
||||
|
||||
override fun imageUrlParse(response: Response): String {
|
||||
throw Exception("imageUrlParse not supported.")
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val PROXY_PREFIX = "cubari:"
|
||||
|
||||
const val AUTHOR_FALLBACK = "Unknown"
|
||||
const val ARTIST_FALLBACK = "Unknown"
|
||||
const val DESCRIPTION_FALLBACK = "No description."
|
||||
|
||||
const val SEARCH_FALLBACK_MSG = "Unable to parse. Is your query in the format of $PROXY_PREFIX<source>/<slug>?"
|
||||
|
||||
enum class SortType {
|
||||
PINNED,
|
||||
UNPINNED
|
||||
}
|
||||
}
|
||||
}
|
@ -1,12 +0,0 @@
|
||||
package eu.kanade.tachiyomi.extension.all.cubari
|
||||
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.source.SourceFactory
|
||||
|
||||
class CubariFactory : SourceFactory {
|
||||
override fun createSources(): List<Source> = listOf(
|
||||
Cubari("en"),
|
||||
Cubari("all"),
|
||||
Cubari("other")
|
||||
)
|
||||
}
|
@ -1,64 +0,0 @@
|
||||
package eu.kanade.tachiyomi.extension.all.cubari
|
||||
|
||||
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 CubariUrlActivity : 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 = when (host) {
|
||||
"m.imgur.com", "imgur.com" -> fromImgur(pathSegments)
|
||||
else -> fromCubari(pathSegments)
|
||||
}
|
||||
|
||||
if (query == null) {
|
||||
Log.e("CubariUrlActivity", "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("CubariUrlActivity", e.toString())
|
||||
}
|
||||
}
|
||||
|
||||
finish()
|
||||
exitProcess(0)
|
||||
}
|
||||
|
||||
private fun fromImgur(pathSegments: List<String>): String? {
|
||||
if (pathSegments.size >= 2) {
|
||||
val id = pathSegments[1]
|
||||
|
||||
return "${Cubari.PROXY_PREFIX}imgur/$id"
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun fromCubari(pathSegments: MutableList<String>): String? {
|
||||
return if (pathSegments.size >= 3) {
|
||||
val source = pathSegments[1]
|
||||
val slug = pathSegments[2]
|
||||
"${Cubari.PROXY_PREFIX}$source/$slug"
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
@ -1,147 +0,0 @@
|
||||
package eu.kanade.tachiyomi.extension.all.cubari
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Application
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.webkit.JavascriptInterface
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.ResponseBody
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class RemoteStorageUtils {
|
||||
abstract class GenericInterceptor(private val transparent: Boolean) : Interceptor {
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
|
||||
abstract val jsScript: String
|
||||
|
||||
abstract fun urlModifier(originalUrl: String): String
|
||||
|
||||
internal class JsInterface(private val latch: CountDownLatch, var payload: String = "") {
|
||||
@JavascriptInterface
|
||||
fun passPayload(passedPayload: String) {
|
||||
payload = passedPayload
|
||||
latch.countDown()
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
try {
|
||||
val originalRequest = chain.request()
|
||||
val originalResponse = chain.proceed(originalRequest)
|
||||
return proceedWithWebView(originalRequest, originalResponse)
|
||||
} catch (e: Exception) {
|
||||
throw IOException(e)
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled", "AddJavascriptInterface")
|
||||
private fun proceedWithWebView(request: Request, response: Response): Response {
|
||||
val latch = CountDownLatch(1)
|
||||
|
||||
var webView: WebView? = null
|
||||
|
||||
val origRequestUrl = request.url().toString()
|
||||
val headers = request.headers().toMultimap().mapValues {
|
||||
it.value.getOrNull(0) ?: ""
|
||||
}.toMutableMap()
|
||||
val jsInterface = JsInterface(latch)
|
||||
|
||||
handler.post {
|
||||
val webview = WebView(Injekt.get<Application>())
|
||||
webView = webview
|
||||
with(webview.settings) {
|
||||
javaScriptEnabled = true
|
||||
domStorageEnabled = true
|
||||
databaseEnabled = true
|
||||
useWideViewPort = false
|
||||
loadWithOverviewMode = false
|
||||
userAgentString = request.header("User-Agent")
|
||||
}
|
||||
|
||||
webview.addJavascriptInterface(jsInterface, "android")
|
||||
|
||||
webview.webViewClient = object : WebViewClient() {
|
||||
override fun onPageFinished(view: WebView, url: String) {
|
||||
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.KITKAT) {
|
||||
view.evaluateJavascript(jsScript) {}
|
||||
}
|
||||
if (transparent) {
|
||||
latch.countDown()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
webview.loadUrl(urlModifier(origRequestUrl), headers)
|
||||
}
|
||||
|
||||
latch.await(TIMEOUT_SEC, TimeUnit.SECONDS)
|
||||
|
||||
handler.postDelayed(
|
||||
{ webView?.destroy() },
|
||||
DELAY_MILLIS * (if (transparent) 2 else 1)
|
||||
)
|
||||
|
||||
return if (transparent) {
|
||||
response
|
||||
} else {
|
||||
response.newBuilder().body(ResponseBody.create(response.body()?.contentType(), jsInterface.payload)).build()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class TagInterceptor : GenericInterceptor(true) {
|
||||
override val jsScript: String = """
|
||||
let dispatched = false;
|
||||
window.addEventListener('history-ready', function () {
|
||||
if (!dispatched) {
|
||||
dispatched = true;
|
||||
Promise.all(
|
||||
[globalHistoryHandler.getAllPinnedSeries(), globalHistoryHandler.getAllUnpinnedSeries()]
|
||||
).then(e => {
|
||||
window.android.passPayload(JSON.stringify(e.flatMap(e => e)))
|
||||
});
|
||||
}
|
||||
});
|
||||
tag();
|
||||
"""
|
||||
|
||||
override fun urlModifier(originalUrl: String): String {
|
||||
return originalUrl.replace("/api/", "/").replace("/series/", "/")
|
||||
}
|
||||
}
|
||||
|
||||
class HomeInterceptor : GenericInterceptor(false) {
|
||||
override val jsScript: String = """
|
||||
let dispatched = false;
|
||||
(function () {
|
||||
if (!dispatched) {
|
||||
dispatched = true;
|
||||
Promise.all(
|
||||
[globalHistoryHandler.getAllPinnedSeries(), globalHistoryHandler.getAllUnpinnedSeries()]
|
||||
).then(e => {
|
||||
window.android.passPayload(JSON.stringify(e.flatMap(e => e) ) )
|
||||
});
|
||||
}
|
||||
})();
|
||||
"""
|
||||
|
||||
override fun urlModifier(originalUrl: String): String {
|
||||
return originalUrl
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TIMEOUT_SEC: Long = 10
|
||||
const val DELAY_MILLIS: Long = 10000
|
||||
}
|
||||
}
|
@ -1,2 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest package="eu.kanade.tachiyomi.extension" />
|
@ -1,12 +0,0 @@
|
||||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'kotlin-android'
|
||||
|
||||
ext {
|
||||
extName = 'Dragon Ball Multiverse'
|
||||
pkgNameSuffix = 'all.dragonball_multiverse'
|
||||
extClass = '.DbMFactory'
|
||||
extVersionCode = 2
|
||||
libVersion = '1.2'
|
||||
}
|
||||
|
||||
apply from: "$rootDir/common.gradle"
|
Before Width: | Height: | Size: 11 KiB |
Before Width: | Height: | Size: 5.2 KiB |
Before Width: | Height: | Size: 18 KiB |
Before Width: | Height: | Size: 39 KiB |
Before Width: | Height: | Size: 67 KiB |
Before Width: | Height: | Size: 408 KiB |
@ -1,83 +0,0 @@
|
||||
@file:Suppress("ClassName")
|
||||
|
||||
package eu.kanade.tachiyomi.extension.all.dragonball_multiverse
|
||||
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.source.SourceFactory
|
||||
|
||||
class DbMFactory : SourceFactory {
|
||||
override fun createSources(): List<Source> = listOf(
|
||||
DbMultiverseEN(),
|
||||
DbMultiverseFR(),
|
||||
DbMultiverseJP(),
|
||||
DbMultiverseCN(),
|
||||
DbMultiverseES(),
|
||||
DbMultiverseIT(),
|
||||
DbMultiversePT(),
|
||||
DbMultiverseDE(),
|
||||
DbMultiversePL(),
|
||||
DbMultiverseNL(),
|
||||
DbMultiverseFR_PA(),
|
||||
DbMultiverseTR_TR(),
|
||||
DbMultiversePT_BR(),
|
||||
DbMultiverseHU_HU(),
|
||||
DbMultiverseGA_ES(),
|
||||
DbMultiverseCT_CT(),
|
||||
DbMultiverseNO_NO(),
|
||||
DbMultiverseRU_RU(),
|
||||
DbMultiverseRO_RO(),
|
||||
DbMultiverseEU_EH(),
|
||||
DbMultiverseLT_LT(),
|
||||
DbMultiverseHR_HR(),
|
||||
DbMultiverseKR_KR(),
|
||||
DbMultiverseFI_FI(),
|
||||
DbMultiverseHE_HE(),
|
||||
DbMultiverseBG_BG(),
|
||||
DbMultiverseSV_SE(),
|
||||
DbMultiverseGR_GR(),
|
||||
DbMultiverseES_CO(),
|
||||
DbMultiverseAR_JO(),
|
||||
DbMultiverseTL_PI(),
|
||||
DbMultiverseLA_LA(),
|
||||
DbMultiverseDA_DK(),
|
||||
DbMultiverseCO_FR(),
|
||||
DbMultiverseBR_FR(),
|
||||
DbMultiverseXX_VE()
|
||||
)
|
||||
}
|
||||
|
||||
class DbMultiverseFR : DbMultiverse("fr", "fr")
|
||||
class DbMultiverseJP : DbMultiverse("ja", "jp")
|
||||
class DbMultiverseCN : DbMultiverse("zh", "cn")
|
||||
class DbMultiverseES : DbMultiverse("es", "es")
|
||||
class DbMultiverseIT : DbMultiverse("it", "it")
|
||||
class DbMultiversePT : DbMultiverse("pt", "pt")
|
||||
class DbMultiverseDE : DbMultiverse("de", "de")
|
||||
class DbMultiversePL : DbMultiverse("pl", "pl")
|
||||
class DbMultiverseNL : DbMultiverse("nl", "nl")
|
||||
class DbMultiverseFR_PA : DbMultiverse("fr", "fr_PA")
|
||||
class DbMultiverseTR_TR : DbMultiverse("tr", "tr_TR")
|
||||
class DbMultiversePT_BR : DbMultiverse("pt-BR", "pt_BR")
|
||||
class DbMultiverseHU_HU : DbMultiverse("hu", "hu_HU")
|
||||
class DbMultiverseGA_ES : DbMultiverse("ga", "ga_ES")
|
||||
class DbMultiverseCT_CT : DbMultiverse("ca", "ct_CT")
|
||||
class DbMultiverseNO_NO : DbMultiverse("no", "no_NO")
|
||||
class DbMultiverseRU_RU : DbMultiverse("ru", "ru_RU")
|
||||
class DbMultiverseRO_RO : DbMultiverse("ro", "ro_RO")
|
||||
class DbMultiverseEU_EH : DbMultiverse("eu", "eu_EH")
|
||||
class DbMultiverseLT_LT : DbMultiverse("lt", "lt_LT")
|
||||
class DbMultiverseHR_HR : DbMultiverse("hr", "hr_HR")
|
||||
class DbMultiverseKR_KR : DbMultiverse("ko", "kr_KR")
|
||||
class DbMultiverseFI_FI : DbMultiverse("fi", "fi_FI")
|
||||
class DbMultiverseHE_HE : DbMultiverse("he", "he_HE")
|
||||
class DbMultiverseBG_BG : DbMultiverse("bg", "bg_BG")
|
||||
class DbMultiverseSV_SE : DbMultiverse("sv", "sv_SE")
|
||||
class DbMultiverseGR_GR : DbMultiverse("el", "gr_GR")
|
||||
class DbMultiverseES_CO : DbMultiverse("es-419", "es_CO")
|
||||
class DbMultiverseAR_JO : DbMultiverse("ar", "ar_JO")
|
||||
class DbMultiverseTL_PI : DbMultiverse("fil", "tl_PI")
|
||||
class DbMultiverseLA_LA : DbMultiverse("la", "la_LA")
|
||||
class DbMultiverseDA_DK : DbMultiverse("da", "da_DK")
|
||||
class DbMultiverseCO_FR : DbMultiverse("co", "co_FR")
|
||||
class DbMultiverseBR_FR : DbMultiverse("br", "br_FR")
|
||||
class DbMultiverseXX_VE : DbMultiverse("vec", "xx_VE")
|
@ -1,103 +0,0 @@
|
||||
package eu.kanade.tachiyomi.extension.all.dragonball_multiverse
|
||||
|
||||
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.Request
|
||||
import okhttp3.Response
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
import rx.Observable
|
||||
|
||||
abstract class DbMultiverse(override val lang: String, private val internalLang: String) : ParsedHttpSource() {
|
||||
|
||||
override val name =
|
||||
if (internalLang.endsWith("_PA")) "Dragon Ball Multiverse Parody"
|
||||
else "Dragon Ball Multiverse"
|
||||
override val baseUrl = "https://www.dragonball-multiverse.com"
|
||||
override val supportsLatest = false
|
||||
|
||||
private fun chapterFromElement(element: Element, name: String): SChapter {
|
||||
val chapter = SChapter.create()
|
||||
chapter.setUrlWithoutDomain(element.attr("abs:href"))
|
||||
chapter.name = name + element.text().let { num ->
|
||||
if (num.contains("-")) {
|
||||
"Pages $num"
|
||||
} else {
|
||||
"Page $num"
|
||||
}
|
||||
}
|
||||
return chapter
|
||||
}
|
||||
|
||||
override fun chapterListSelector(): String = "div.cadrelect.chapters a[href*=page-]"
|
||||
|
||||
override fun chapterListParse(response: Response): List<SChapter> {
|
||||
val chapters = mutableListOf<SChapter>()
|
||||
val document = response.asJsoup()
|
||||
|
||||
document.select("div[ch]").forEach { container ->
|
||||
container.select(chapterListSelector()).mapIndexed { i, chapter ->
|
||||
// Each page is its own chapter, add chapter name when a first page is mapped
|
||||
val name = if (i == 0) container.select("h4").text() + " - " else ""
|
||||
chapters.add(chapterFromElement(chapter, name))
|
||||
}
|
||||
}
|
||||
|
||||
return chapters.reversed()
|
||||
}
|
||||
|
||||
override fun pageListParse(document: Document): List<Page> {
|
||||
return document.select("div#h_read img").mapIndexed { index, element ->
|
||||
Page(index, "", element.attr("abs:src"))
|
||||
}
|
||||
}
|
||||
|
||||
override fun mangaDetailsParse(document: Document): SManga = createManga(document)
|
||||
|
||||
override fun fetchPopularManga(page: Int): Observable<MangasPage> {
|
||||
return Observable.just(MangasPage(listOf(createManga(null)), hasNextPage = false))
|
||||
}
|
||||
|
||||
private fun createManga(document: Document?) = SManga.create().apply {
|
||||
title = name
|
||||
status = SManga.ONGOING
|
||||
url = "/$internalLang/chapters.html"
|
||||
description = "Dragon Ball Multiverse (DBM) is a free online comic, made by a whole team of fans. It's our personal sequel to DBZ."
|
||||
thumbnail_url = document?.select("div[ch=\"1\"] img")?.attr("abs:src")
|
||||
}
|
||||
|
||||
override fun chapterFromElement(element: Element): SChapter = throw UnsupportedOperationException()
|
||||
|
||||
override fun imageUrlParse(document: Document): String = throw UnsupportedOperationException()
|
||||
|
||||
override fun popularMangaFromElement(element: Element): SManga = throw UnsupportedOperationException()
|
||||
|
||||
override fun popularMangaRequest(page: Int): Request = throw UnsupportedOperationException()
|
||||
|
||||
override fun popularMangaSelector(): String = throw UnsupportedOperationException()
|
||||
|
||||
override fun popularMangaNextPageSelector(): String? = throw UnsupportedOperationException()
|
||||
|
||||
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> = Observable.just(MangasPage(emptyList(), false))
|
||||
|
||||
override fun searchMangaFromElement(element: Element): SManga = throw UnsupportedOperationException()
|
||||
|
||||
override fun searchMangaNextPageSelector(): String? = throw UnsupportedOperationException()
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request = throw UnsupportedOperationException()
|
||||
|
||||
override fun searchMangaSelector(): String = throw UnsupportedOperationException()
|
||||
|
||||
override fun latestUpdatesFromElement(element: Element): SManga = throw UnsupportedOperationException()
|
||||
|
||||
override fun latestUpdatesNextPageSelector(): String? = throw UnsupportedOperationException()
|
||||
|
||||
override fun latestUpdatesRequest(page: Int): Request = throw UnsupportedOperationException()
|
||||
|
||||
override fun latestUpdatesSelector(): String = throw UnsupportedOperationException()
|
||||
}
|
@ -1,3 +0,0 @@
|
||||
package eu.kanade.tachiyomi.extension.all.dragonball_multiverse
|
||||
|
||||
class DbMultiverseEN : DbMultiverse("en", "en")
|
@ -1,23 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="eu.kanade.tachiyomi.extension">
|
||||
|
||||
<application>
|
||||
<activity
|
||||
android:name=".all.ehentai.EHUrlActivity"
|
||||
android:excludeFromRecents="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="e-hentai.org"
|
||||
android:pathPattern="/g/..*/..*"
|
||||
android:scheme="https" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
@ -1,13 +0,0 @@
|
||||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'kotlin-android'
|
||||
|
||||
ext {
|
||||
extName = 'E-Hentai'
|
||||
pkgNameSuffix = 'all.ehentai'
|
||||
extClass = '.EHFactory'
|
||||
extVersionCode = 13
|
||||
libVersion = '1.2'
|
||||
containsNsfw = true
|
||||
}
|
||||
|
||||
apply from: "$rootDir/common.gradle"
|
Before Width: | Height: | Size: 2.3 KiB |
Before Width: | Height: | Size: 1.8 KiB |
Before Width: | Height: | Size: 2.9 KiB |
Before Width: | Height: | Size: 4.1 KiB |
Before Width: | Height: | Size: 6.0 KiB |
Before Width: | Height: | Size: 9.8 KiB |
@ -1,28 +0,0 @@
|
||||
package eu.kanade.tachiyomi.extension.all.ehentai
|
||||
|
||||
import eu.kanade.tachiyomi.annotations.Nsfw
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.source.SourceFactory
|
||||
|
||||
@Nsfw
|
||||
class EHFactory : SourceFactory {
|
||||
override fun createSources(): List<Source> = listOf(
|
||||
EHentai("ja", "japanese"),
|
||||
EHentai("en", "english"),
|
||||
EHentai("zh", "chinese"),
|
||||
EHentai("nl", "dutch"),
|
||||
EHentai("fr", "french"),
|
||||
EHentai("de", "german"),
|
||||
EHentai("hu", "hungarian"),
|
||||
EHentai("it", "italian"),
|
||||
EHentai("ko", "korean"),
|
||||
EHentai("pl", "polish"),
|
||||
EHentai("pt", "portuguese"),
|
||||
EHentai("ru", "russian"),
|
||||
EHentai("es", "spanish"),
|
||||
EHentai("th", "thai"),
|
||||
EHentai("vi", "vietnamese"),
|
||||
EHentai("none", "n/a"),
|
||||
EHentai("other", "other")
|
||||
)
|
||||
}
|
@ -1,39 +0,0 @@
|
||||
package eu.kanade.tachiyomi.extension.all.ehentai
|
||||
|
||||
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://e-hentai.net/g/xxxxx/yyyyy/ intents and redirects them to
|
||||
* the main Tachiyomi process.
|
||||
*/
|
||||
class EHUrlActivity : Activity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val pathSegments = intent?.data?.pathSegments
|
||||
if (pathSegments != null && pathSegments.size > 2) {
|
||||
val id = pathSegments[1]
|
||||
val key = pathSegments[2]
|
||||
val mainIntent = Intent().apply {
|
||||
action = "eu.kanade.tachiyomi.SEARCH"
|
||||
putExtra("query", "${EHentai.PREFIX_ID_SEARCH}$id/$key")
|
||||
putExtra("filter", packageName)
|
||||
}
|
||||
|
||||
try {
|
||||
startActivity(mainIntent)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
Log.e("EHUrlActivity", e.toString())
|
||||
}
|
||||
} else {
|
||||
Log.e("EHUrlActivity", "could not parse uri from intent $intent")
|
||||
}
|
||||
|
||||
finish()
|
||||
exitProcess(0)
|
||||
}
|
||||
}
|
@ -1,70 +0,0 @@
|
||||
package eu.kanade.tachiyomi.extension.all.ehentai
|
||||
|
||||
import kotlin.math.ln
|
||||
import kotlin.math.pow
|
||||
|
||||
/**
|
||||
* Various utility methods used in the E-Hentai source
|
||||
*/
|
||||
|
||||
/**
|
||||
* Return null if String is blank, otherwise returns the original String
|
||||
* @returns null if the String is blank, otherwise returns the original String
|
||||
*/
|
||||
fun String?.nullIfBlank(): String? = if (isNullOrBlank())
|
||||
null
|
||||
else
|
||||
this
|
||||
|
||||
/**
|
||||
* Ignores any exceptions thrown inside a block
|
||||
*/
|
||||
fun <T> ignore(expr: () -> T): T? {
|
||||
return try {
|
||||
expr()
|
||||
} catch (t: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Use '+' to append Strings onto a StringBuilder
|
||||
*/
|
||||
operator fun StringBuilder.plusAssign(other: String) {
|
||||
append(other)
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts bytes into a human readable String
|
||||
*/
|
||||
fun humanReadableByteCount(bytes: Long, si: Boolean): String {
|
||||
val unit = if (si) 1000 else 1024
|
||||
if (bytes < unit) return "$bytes B"
|
||||
val exp = (ln(bytes.toDouble()) / ln(unit.toDouble())).toInt()
|
||||
val pre = (if (si) "kMGTPE" else "KMGTPE")[exp - 1] + if (si) "" else "i"
|
||||
return String.format("%.1f %sB", bytes / unit.toDouble().pow(exp.toDouble()), pre)
|
||||
}
|
||||
|
||||
private const val KB_FACTOR = 1000
|
||||
private const val KIB_FACTOR = 1024
|
||||
private const val MB_FACTOR = 1000 * KB_FACTOR
|
||||
private const val MIB_FACTOR = 1024 * KIB_FACTOR
|
||||
private const val GB_FACTOR = 1000 * MB_FACTOR
|
||||
private const val GIB_FACTOR = 1024 * MIB_FACTOR
|
||||
|
||||
/**
|
||||
* Parse human readable size Strings
|
||||
*/
|
||||
fun parseHumanReadableByteCount(arg0: String): Double? {
|
||||
val spaceNdx = arg0.indexOf(" ")
|
||||
val ret = arg0.substring(0 until spaceNdx).toDouble()
|
||||
when (arg0.substring(spaceNdx + 1)) {
|
||||
"GB" -> return ret * GB_FACTOR
|
||||
"GiB" -> return ret * GIB_FACTOR
|
||||
"MB" -> return ret * MB_FACTOR
|
||||
"MiB" -> return ret * MIB_FACTOR
|
||||
"KB" -> return ret * KB_FACTOR
|
||||
"KiB" -> return ret * KIB_FACTOR
|
||||
}
|
||||
return null
|
||||
}
|
@ -1,533 +0,0 @@
|
||||
package eu.kanade.tachiyomi.extension.all.ehentai
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Application
|
||||
import android.content.SharedPreferences
|
||||
import android.net.Uri
|
||||
import androidx.preference.CheckBoxPreference
|
||||
import androidx.preference.PreferenceScreen
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.asObservableSuccess
|
||||
import eu.kanade.tachiyomi.source.ConfigurableSource
|
||||
import eu.kanade.tachiyomi.source.model.Filter
|
||||
import eu.kanade.tachiyomi.source.model.Filter.CheckBox
|
||||
import eu.kanade.tachiyomi.source.model.Filter.Group
|
||||
import eu.kanade.tachiyomi.source.model.Filter.Select
|
||||
import eu.kanade.tachiyomi.source.model.Filter.Text
|
||||
import eu.kanade.tachiyomi.source.model.Filter.TriState
|
||||
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 eu.kanade.tachiyomi.util.asJsoup
|
||||
import okhttp3.CacheControl
|
||||
import okhttp3.CookieJar
|
||||
import okhttp3.Headers
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.jsoup.nodes.Element
|
||||
import rx.Observable
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import java.net.URLEncoder
|
||||
import android.support.v7.preference.CheckBoxPreference as LegacyCheckBoxPreference
|
||||
import android.support.v7.preference.PreferenceScreen as LegacyPreferenceScreen
|
||||
|
||||
open class EHentai(override val lang: String, private val ehLang: String) : ConfigurableSource, HttpSource() {
|
||||
|
||||
private val preferences: SharedPreferences by lazy {
|
||||
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
|
||||
}
|
||||
|
||||
override val name = "E-Hentai"
|
||||
|
||||
override val baseUrl = "https://e-hentai.org"
|
||||
|
||||
override val supportsLatest = true
|
||||
|
||||
// true if lang is a "natural human language"
|
||||
private fun isLangNatural(): Boolean = lang !in listOf("none", "other")
|
||||
|
||||
private fun genericMangaParse(response: Response): MangasPage {
|
||||
val doc = response.asJsoup()
|
||||
val parsedMangas = doc.select("table.itg td.glname")
|
||||
.let { elements ->
|
||||
if (isLangNatural() && getEnforceLanguagePref()) {
|
||||
elements.filter { element ->
|
||||
// only accept elements with a language tag matching ehLang or without a language tag
|
||||
// could make this stricter and not accept elements without a language tag, possibly add a sharedpreference for it
|
||||
element.select("div[title^=language]").firstOrNull()?.let { it.text() == ehLang } ?: true
|
||||
}
|
||||
} else {
|
||||
elements
|
||||
}
|
||||
}
|
||||
.map {
|
||||
SManga.create().apply {
|
||||
// Get title
|
||||
it.select("a")?.first()?.apply {
|
||||
title = this.select(".glink").text()
|
||||
url = ExGalleryMetadata.normalizeUrl(attr("href"))
|
||||
}
|
||||
// Get image
|
||||
it.parent().select(".glthumb img")?.first().apply {
|
||||
thumbnail_url = this?.attr("data-src")?.nullIfBlank()
|
||||
?: this?.attr("src")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add to page if required
|
||||
val hasNextPage = doc.select("a[onclick=return false]").last()?.text() == ">"
|
||||
|
||||
return MangasPage(parsedMangas, hasNextPage)
|
||||
}
|
||||
|
||||
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> = Observable.just(
|
||||
listOf(
|
||||
SChapter.create().apply {
|
||||
url = manga.url
|
||||
name = "Chapter"
|
||||
chapter_number = 1f
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
override fun fetchPageList(chapter: SChapter) = fetchChapterPage(chapter, "$baseUrl/${chapter.url}").map {
|
||||
it.mapIndexed { i, s ->
|
||||
Page(i, s)
|
||||
}
|
||||
}!!
|
||||
|
||||
/**
|
||||
* Recursively fetch chapter pages
|
||||
*/
|
||||
private fun fetchChapterPage(
|
||||
chapter: SChapter,
|
||||
np: String,
|
||||
pastUrls: List<String> = emptyList()
|
||||
): Observable<List<String>> {
|
||||
val urls = ArrayList(pastUrls)
|
||||
return chapterPageCall(np).flatMap {
|
||||
val jsoup = it.asJsoup()
|
||||
urls += parseChapterPage(jsoup)
|
||||
nextPageUrl(jsoup)?.let { string ->
|
||||
fetchChapterPage(chapter, string, urls)
|
||||
} ?: Observable.just(urls)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseChapterPage(response: Element) = with(response) {
|
||||
select(".gdtm a").map {
|
||||
Pair(it.child(0).attr("alt").toInt(), it.attr("href"))
|
||||
}.sortedBy(Pair<Int, String>::first).map { it.second }
|
||||
}
|
||||
|
||||
private fun chapterPageCall(np: String) = client.newCall(chapterPageRequest(np)).asObservableSuccess()
|
||||
private fun chapterPageRequest(np: String) = exGet(np, null, headers)
|
||||
|
||||
private fun nextPageUrl(element: Element) = element.select("a[onclick=return false]").last()?.let {
|
||||
if (it.text() == ">") it.attr("href") else null
|
||||
}
|
||||
|
||||
private fun languageTag(enforceLanguageFilter: Boolean = false): String {
|
||||
return if (enforceLanguageFilter || getEnforceLanguagePref()) "language:$ehLang" else ""
|
||||
}
|
||||
|
||||
override fun popularMangaRequest(page: Int) = if (isLangNatural()) {
|
||||
exGet("$baseUrl/?f_search=${languageTag()}&f_srdd=5&f_sr=on", page)
|
||||
} else {
|
||||
latestUpdatesRequest(page)
|
||||
}
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
val enforceLanguageFilter = filters.find { it is EnforceLanguageFilter }?.state == true
|
||||
val uri = Uri.parse("$baseUrl$QUERY_PREFIX").buildUpon()
|
||||
var modifiedQuery = when {
|
||||
!isLangNatural() -> query
|
||||
query.isBlank() -> languageTag(enforceLanguageFilter)
|
||||
else -> languageTag(enforceLanguageFilter).let { if (it.isNotEmpty()) "$query,$it" else query }
|
||||
}
|
||||
modifiedQuery += filters.filterIsInstance<TagFilter>()
|
||||
.flatMap { it.markedTags() }
|
||||
.joinToString(",")
|
||||
.let { if (it.isNotEmpty()) ",$it" else it }
|
||||
uri.appendQueryParameter("f_search", modifiedQuery)
|
||||
filters.forEach {
|
||||
if (it is UriFilter) it.addToUri(uri)
|
||||
}
|
||||
return exGet(uri.toString(), page)
|
||||
}
|
||||
|
||||
override fun latestUpdatesRequest(page: Int) = exGet(baseUrl, page)
|
||||
|
||||
override fun popularMangaParse(response: Response) = genericMangaParse(response)
|
||||
override fun searchMangaParse(response: Response) = genericMangaParse(response)
|
||||
override fun latestUpdatesParse(response: Response) = genericMangaParse(response)
|
||||
|
||||
private fun exGet(url: String, page: Int? = null, additionalHeaders: Headers? = null, cache: Boolean = true): Request {
|
||||
return GET(
|
||||
page?.let {
|
||||
addParam(url, "page", (page - 1).toString())
|
||||
} ?: url,
|
||||
additionalHeaders?.let { header ->
|
||||
val headers = headers.newBuilder()
|
||||
header.toMultimap().forEach { (t, u) ->
|
||||
u.forEach {
|
||||
headers.add(t, it)
|
||||
}
|
||||
}
|
||||
headers.build()
|
||||
} ?: headers
|
||||
).let {
|
||||
if (!cache) {
|
||||
it.newBuilder().cacheControl(CacheControl.FORCE_NETWORK).build()
|
||||
} else {
|
||||
it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse gallery page to metadata model
|
||||
*/
|
||||
@SuppressLint("DefaultLocale")
|
||||
override fun mangaDetailsParse(response: Response) = with(response.asJsoup()) {
|
||||
with(ExGalleryMetadata()) {
|
||||
url = response.request().url().encodedPath()
|
||||
title = select("#gn").text().nullIfBlank()?.trim()
|
||||
|
||||
altTitle = select("#gj").text().nullIfBlank()?.trim()
|
||||
|
||||
// Thumbnail is set as background of element in style attribute
|
||||
thumbnailUrl = select("#gd1 div").attr("style").nullIfBlank()?.let {
|
||||
it.substring(it.indexOf('(') + 1 until it.lastIndexOf(')'))
|
||||
}
|
||||
genre = select("#gdc div").text().nullIfBlank()?.trim()?.toLowerCase()
|
||||
|
||||
uploader = select("#gdn").text().nullIfBlank()?.trim()
|
||||
|
||||
// Parse the table
|
||||
select("#gdd tr").forEach {
|
||||
it.select(".gdt1")
|
||||
.text()
|
||||
.nullIfBlank()
|
||||
?.trim()
|
||||
?.let { left ->
|
||||
it.select(".gdt2")
|
||||
.text()
|
||||
.nullIfBlank()
|
||||
?.trim()
|
||||
?.let { right ->
|
||||
ignore {
|
||||
when (
|
||||
left.removeSuffix(":")
|
||||
.toLowerCase()
|
||||
) {
|
||||
"posted" -> datePosted = EX_DATE_FORMAT.parse(right)?.time ?: 0
|
||||
"visible" -> visible = right.nullIfBlank()
|
||||
"language" -> {
|
||||
language = right.removeSuffix(TR_SUFFIX).trim().nullIfBlank()
|
||||
translated = right.endsWith(TR_SUFFIX, true)
|
||||
}
|
||||
"file size" -> size = parseHumanReadableByteCount(right)?.toLong()
|
||||
"length" -> length = right.removeSuffix("pages").trim().nullIfBlank()?.toInt()
|
||||
"favorited" -> favorites = right.removeSuffix("times").trim().nullIfBlank()?.toInt()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse ratings
|
||||
ignore {
|
||||
averageRating = select("#rating_label")
|
||||
.text()
|
||||
.removePrefix("Average:")
|
||||
.trim()
|
||||
.nullIfBlank()
|
||||
?.toDouble()
|
||||
ratingCount = select("#rating_count")
|
||||
.text()
|
||||
.trim()
|
||||
.nullIfBlank()
|
||||
?.toInt()
|
||||
}
|
||||
|
||||
// Parse tags
|
||||
tags.clear()
|
||||
select("#taglist tr").forEach {
|
||||
val namespace = it.select(".tc").text().removeSuffix(":")
|
||||
val currentTags = it.select("div").map { element ->
|
||||
Tag(
|
||||
element.text().trim(),
|
||||
element.hasClass("gtl")
|
||||
)
|
||||
}
|
||||
tags[namespace] = currentTags
|
||||
}
|
||||
|
||||
// Copy metadata to manga
|
||||
SManga.create().apply {
|
||||
copyTo(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun searchMangaByIdRequest(id: String) = GET("$baseUrl/g/$id", headers)
|
||||
|
||||
private fun searchMangaByIdParse(response: Response, id: String): MangasPage {
|
||||
val details = mangaDetailsParse(response)
|
||||
details.url = "/g/$id/"
|
||||
return MangasPage(listOf(details), false)
|
||||
}
|
||||
|
||||
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
|
||||
return if (query.startsWith(PREFIX_ID_SEARCH)) {
|
||||
val id = query.removePrefix(PREFIX_ID_SEARCH)
|
||||
client.newCall(searchMangaByIdRequest(id))
|
||||
.asObservableSuccess()
|
||||
.map { response -> searchMangaByIdParse(response, id) }
|
||||
} else {
|
||||
super.fetchSearchManga(page, query, filters)
|
||||
}
|
||||
}
|
||||
|
||||
override fun chapterListParse(response: Response) = throw UnsupportedOperationException("Unused method was called somehow!")
|
||||
|
||||
override fun pageListParse(response: Response) = throw UnsupportedOperationException("Unused method was called somehow!")
|
||||
|
||||
override fun imageUrlParse(response: Response): String = response.asJsoup().select("#img").attr("abs:src")
|
||||
|
||||
private val cookiesHeader by lazy {
|
||||
val cookies = mutableMapOf<String, String>()
|
||||
|
||||
// Setup settings
|
||||
val settings = mutableListOf<String>()
|
||||
|
||||
// Do not show popular right now pane as we can't parse it
|
||||
settings += "prn_n"
|
||||
|
||||
// Exclude every other language except the one we have selected
|
||||
settings += "xl_" + languageMappings.filter { it.first != ehLang }
|
||||
.flatMap { it.second }
|
||||
.joinToString("x")
|
||||
|
||||
cookies["uconfig"] = buildSettings(settings)
|
||||
|
||||
// Bypass "Offensive For Everyone" content warning
|
||||
cookies["nw"] = "1"
|
||||
|
||||
buildCookies(cookies)
|
||||
}
|
||||
|
||||
// Headers
|
||||
override fun headersBuilder() = super.headersBuilder().add("Cookie", cookiesHeader)!!
|
||||
|
||||
private fun buildSettings(settings: List<String?>) = settings.filterNotNull().joinToString(separator = "-")
|
||||
|
||||
private fun buildCookies(cookies: Map<String, String>) = cookies.entries.joinToString(separator = "; ", postfix = ";") {
|
||||
"${URLEncoder.encode(it.key, "UTF-8")}=${URLEncoder.encode(it.value, "UTF-8")}"
|
||||
}
|
||||
|
||||
@Suppress("SameParameterValue")
|
||||
private fun addParam(url: String, param: String, value: String) = Uri.parse(url)
|
||||
.buildUpon()
|
||||
.appendQueryParameter(param, value)
|
||||
.toString()
|
||||
|
||||
override val client = network.client.newBuilder()
|
||||
.cookieJar(CookieJar.NO_COOKIES)
|
||||
.addInterceptor { chain ->
|
||||
val newReq = chain
|
||||
.request()
|
||||
.newBuilder()
|
||||
.removeHeader("Cookie")
|
||||
.addHeader("Cookie", cookiesHeader)
|
||||
.build()
|
||||
|
||||
chain.proceed(newReq)
|
||||
}.build()!!
|
||||
|
||||
// Filters
|
||||
override fun getFilterList() = FilterList(
|
||||
EnforceLanguageFilter(getEnforceLanguagePref()),
|
||||
Watched(),
|
||||
GenreGroup(),
|
||||
TagFilter("Misc Tags", triStateBoxesFrom(miscTags), ""),
|
||||
TagFilter("Female Tags", triStateBoxesFrom(femaleTags), "female"),
|
||||
TagFilter("Male Tags", triStateBoxesFrom(maleTags), "male"),
|
||||
AdvancedGroup()
|
||||
)
|
||||
|
||||
class Watched : CheckBox("Watched List"), UriFilter {
|
||||
override fun addToUri(builder: Uri.Builder) {
|
||||
if (state)
|
||||
builder.appendPath("watched")
|
||||
}
|
||||
}
|
||||
|
||||
class GenreOption(name: String, private val genreId: String) : CheckBox(name, false), UriFilter {
|
||||
override fun addToUri(builder: Uri.Builder) {
|
||||
builder.appendQueryParameter("f_$genreId", if (state) "1" else "0")
|
||||
}
|
||||
}
|
||||
|
||||
class GenreGroup : UriGroup<GenreOption>(
|
||||
"Genres",
|
||||
listOf(
|
||||
GenreOption("Dōjinshi", "doujinshi"),
|
||||
GenreOption("Manga", "manga"),
|
||||
GenreOption("Artist CG", "artistcg"),
|
||||
GenreOption("Game CG", "gamecg"),
|
||||
GenreOption("Western", "western"),
|
||||
GenreOption("Non-H", "non-h"),
|
||||
GenreOption("Image Set", "imageset"),
|
||||
GenreOption("Cosplay", "cosplay"),
|
||||
GenreOption("Asian Porn", "asianporn"),
|
||||
GenreOption("Misc", "misc")
|
||||
)
|
||||
)
|
||||
|
||||
class AdvancedOption(name: String, private val param: String, defValue: Boolean = false) : CheckBox(name, defValue), UriFilter {
|
||||
override fun addToUri(builder: Uri.Builder) {
|
||||
if (state)
|
||||
builder.appendQueryParameter(param, "on")
|
||||
}
|
||||
}
|
||||
|
||||
open class PageOption(name: String, private val queryKey: String) : Text(name), UriFilter {
|
||||
override fun addToUri(builder: Uri.Builder) {
|
||||
if (state.isNotBlank()) {
|
||||
if (builder.build().getQueryParameters("f_sp").isEmpty()) {
|
||||
builder.appendQueryParameter("f_sp", "on")
|
||||
}
|
||||
|
||||
builder.appendQueryParameter(queryKey, state.trim())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class MinPagesOption : PageOption("Minimum Pages", "f_spf")
|
||||
class MaxPagesOption : PageOption("Maximum Pages", "f_spt")
|
||||
|
||||
class RatingOption :
|
||||
Select<String>(
|
||||
"Minimum Rating",
|
||||
arrayOf(
|
||||
"Any",
|
||||
"2 stars",
|
||||
"3 stars",
|
||||
"4 stars",
|
||||
"5 stars"
|
||||
)
|
||||
),
|
||||
UriFilter {
|
||||
override fun addToUri(builder: Uri.Builder) {
|
||||
if (state > 0) {
|
||||
builder.appendQueryParameter("f_srdd", (state + 1).toString())
|
||||
builder.appendQueryParameter("f_sr", "on")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Explicit type arg for listOf() to workaround this: KT-16570
|
||||
class AdvancedGroup : UriGroup<Filter<*>>(
|
||||
"Advanced Options",
|
||||
listOf(
|
||||
AdvancedOption("Search Gallery Name", "f_sname", true),
|
||||
AdvancedOption("Search Gallery Tags", "f_stags", true),
|
||||
AdvancedOption("Search Gallery Description", "f_sdesc"),
|
||||
AdvancedOption("Search Torrent Filenames", "f_storr"),
|
||||
AdvancedOption("Only Show Galleries With Torrents", "f_sto"),
|
||||
AdvancedOption("Search Low-Power Tags", "f_sdt1"),
|
||||
AdvancedOption("Search Downvoted Tags", "f_sdt2"),
|
||||
AdvancedOption("Show Expunged Galleries", "f_sh"),
|
||||
RatingOption(),
|
||||
MinPagesOption(),
|
||||
MaxPagesOption()
|
||||
)
|
||||
)
|
||||
|
||||
private class EnforceLanguageFilter(default: Boolean) : CheckBox("Enforce language", default)
|
||||
|
||||
private val miscTags = "3d, already uploaded, anaglyph, animal on animal, animated, anthology, arisa mizuhara, artbook, ashiya noriko, bailey jay, body swap, caption, chouzuki maryou, christian godard, comic, compilation, dakimakura, fe galvao, ffm threesome, figure, forbidden content, full censorship, full color, game sprite, goudoushi, group, gunyou mikan, harada shigemitsu, hardcore, helly von valentine, higurashi rin, hololive, honey select, how to, incest, incomplete, ishiba yoshikazu, jessica nigri, kalinka fox, kanda midori, kira kira, kitami eri, kuroi hiroki, lenfried, lincy leaw, marie claude bourbonnais, matsunaga ayaka, me me me, missing cover, mmf threesome, mmt threesome, mosaic censorship, mtf threesome, multi-work series, no penetration, non-nude, novel, nudity only, oakazaki joe, out of order, paperchild, pm02 colon 20, poor grammar, radio comix, realporn, redraw, replaced, sakaki kasa, sample, saotome love, scanmark, screenshots, sinful goddesses, sketch lines, stereoscopic, story arc, takeuti ken, tankoubon, themeless, tikuma jukou, time stop, tsubaki zakuro, ttm threesome, twins, uncensored, vandych alex, variant set, watermarked, webtoon, western cg, western imageset, western non-h, yamato nadeshiko club, yui okada, yukkuri, zappa go"
|
||||
private val femaleTags = "ahegao, anal, angel, apron, bandages, bbw, bdsm, beauty mark, big areolae, big ass, big breasts, big clit, big lips, big nipples, bikini, blackmail, bloomers, blowjob, bodysuit, bondage, breast expansion, bukkake, bunny girl, business suit, catgirl, centaur, cheating, chinese dress, christmas, collar, corset, cosplaying, cowgirl, crossdressing, cunnilingus, dark skin, daughter, deepthroat, defloration, demon girl, double penetration, dougi, dragon, drunk, elf, exhibitionism, farting, females only, femdom, filming, fingering, fishnets, footjob, fox girl, furry, futanari, garter belt, ghost, giantess, glasses, gloves, goblin, gothic lolita, growth, guro, gyaru, hair buns, hairy, hairy armpits, handjob, harem, hidden sex, horns, huge breasts, humiliation, impregnation, incest, inverted nipples, kemonomimi, kimono, kissing, lactation, latex, leg lock, leotard, lingerie, lizard girl, maid, masked face, masturbation, midget, miko, milf, mind break, mind control, monster girl, mother, muscle, nakadashi, netorare, nose hook, nun, nurse, oil, paizuri, panda girl, pantyhose, piercing, pixie cut, policewoman, ponytail, pregnant, rape, rimjob, robot, scat, schoolgirl uniform, sex toys, shemale, sister, small breasts, smell, sole dickgirl, sole female, squirting, stockings, sundress, sweating, swimsuit, swinging, tail, tall girl, teacher, tentacles, thigh high boots, tomboy, transformation, twins, twintails, unusual pupils, urination, vore, vtuber, widow, wings, witch, wolf girl, x-ray, yuri, zombie"
|
||||
private val maleTags = "anal, bbm, big ass, big penis, bikini, blood, blowjob, bondage, catboy, cheating, chikan, condom, crab, crossdressing, dark skin, deepthroat, demon, dickgirl on male, dilf, dog boy, double anal, double penetration, dragon, drunk, exhibitionism, facial hair, feminization, footjob, fox boy, furry, glasses, group, guro, hairy, handjob, hidden sex, horns, huge penis, human on furry, kimono, lingerie, lizard guy, machine, maid, males only, masturbation, mmm threesome, monster, muscle, nakadashi, ninja, octopus, oni, pillory, policeman, possession, prostate massage, public use, schoolboy uniform, schoolgirl uniform, sex toys, shotacon, sleeping, snuff, sole male, stockings, sunglasses, swimsuit, tall man, tentacles, tomgirl, unusual pupils, virginity, waiter, x-ray, yaoi, zombie"
|
||||
|
||||
private fun triStateBoxesFrom(tagString: String): List<TagTriState> = tagString.split(", ").map { TagTriState(it) }
|
||||
|
||||
class TagTriState(tag: String) : TriState(tag)
|
||||
class TagFilter(name: String, private val triStateBoxes: List<TagTriState>, private val nameSpace: String) : Group<TagTriState>(name, triStateBoxes) {
|
||||
fun markedTags() = triStateBoxes.filter { it.isIncluded() }.map { "$nameSpace:${it.name}" } + triStateBoxes.filter { it.isExcluded() }.map { "-$nameSpace:${it.name}" }
|
||||
}
|
||||
|
||||
// map languages to their internal ids
|
||||
private val languageMappings = listOf(
|
||||
Pair("japanese", listOf("0", "1024", "2048")),
|
||||
Pair("english", listOf("1", "1025", "2049")),
|
||||
Pair("chinese", listOf("10", "1034", "2058")),
|
||||
Pair("dutch", listOf("20", "1044", "2068")),
|
||||
Pair("french", listOf("30", "1054", "2078")),
|
||||
Pair("german", listOf("40", "1064", "2088")),
|
||||
Pair("hungarian", listOf("50", "1074", "2098")),
|
||||
Pair("italian", listOf("60", "1084", "2108")),
|
||||
Pair("korean", listOf("70", "1094", "2118")),
|
||||
Pair("polish", listOf("80", "1104", "2128")),
|
||||
Pair("portuguese", listOf("90", "1114", "2138")),
|
||||
Pair("russian", listOf("100", "1124", "2148")),
|
||||
Pair("spanish", listOf("110", "1134", "2158")),
|
||||
Pair("thai", listOf("120", "1144", "2168")),
|
||||
Pair("vietnamese", listOf("130", "1154", "2178")),
|
||||
Pair("n/a", listOf("254", "1278", "2302")),
|
||||
Pair("other", listOf("255", "1279", "2303"))
|
||||
)
|
||||
|
||||
companion object {
|
||||
const val QUERY_PREFIX = "?f_apply=Apply+Filter"
|
||||
const val PREFIX_ID_SEARCH = "id:"
|
||||
const val TR_SUFFIX = "TR"
|
||||
|
||||
// Preferences vals
|
||||
private const val ENFORCE_LANGUAGE_PREF_KEY = "ENFORCE_LANGUAGE"
|
||||
private const val ENFORCE_LANGUAGE_PREF_TITLE = "Enforce Language"
|
||||
private const val ENFORCE_LANGUAGE_PREF_SUMMARY = "If checked, forces browsing of manga matching a language tag"
|
||||
private const val ENFORCE_LANGUAGE_PREF_DEFAULT_VALUE = false
|
||||
}
|
||||
|
||||
// Preferences
|
||||
|
||||
override fun setupPreferenceScreen(screen: PreferenceScreen) {
|
||||
val enforceLanguagePref = CheckBoxPreference(screen.context).apply {
|
||||
key = "${ENFORCE_LANGUAGE_PREF_KEY}_$lang"
|
||||
title = ENFORCE_LANGUAGE_PREF_TITLE
|
||||
summary = ENFORCE_LANGUAGE_PREF_SUMMARY
|
||||
setDefaultValue(ENFORCE_LANGUAGE_PREF_DEFAULT_VALUE)
|
||||
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
val checkValue = newValue as Boolean
|
||||
preferences.edit().putBoolean("${ENFORCE_LANGUAGE_PREF_KEY}_$lang", checkValue).commit()
|
||||
}
|
||||
}
|
||||
screen.addPreference(enforceLanguagePref)
|
||||
}
|
||||
|
||||
override fun setupPreferenceScreen(screen: LegacyPreferenceScreen) {
|
||||
val enforceLanguagePref = LegacyCheckBoxPreference(screen.context).apply {
|
||||
key = "${ENFORCE_LANGUAGE_PREF_KEY}_$lang"
|
||||
title = ENFORCE_LANGUAGE_PREF_TITLE
|
||||
summary = ENFORCE_LANGUAGE_PREF_SUMMARY
|
||||
setDefaultValue(ENFORCE_LANGUAGE_PREF_DEFAULT_VALUE)
|
||||
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
val checkValue = newValue as Boolean
|
||||
preferences.edit().putBoolean("${ENFORCE_LANGUAGE_PREF_KEY}_$lang", checkValue).commit()
|
||||
}
|
||||
}
|
||||
screen.addPreference(enforceLanguagePref)
|
||||
}
|
||||
|
||||
private fun getEnforceLanguagePref(): Boolean = preferences.getBoolean("${ENFORCE_LANGUAGE_PREF_KEY}_$lang", ENFORCE_LANGUAGE_PREF_DEFAULT_VALUE)
|
||||
}
|
@ -1,52 +0,0 @@
|
||||
package eu.kanade.tachiyomi.extension.all.ehentai
|
||||
|
||||
import android.net.Uri
|
||||
|
||||
/**
|
||||
* Gallery metadata storage model
|
||||
*/
|
||||
|
||||
class ExGalleryMetadata {
|
||||
var url: String? = null
|
||||
|
||||
var thumbnailUrl: String? = null
|
||||
|
||||
var title: String? = null
|
||||
var altTitle: String? = null
|
||||
|
||||
var genre: String? = null
|
||||
|
||||
var datePosted: Long? = null
|
||||
var parent: String? = null
|
||||
var visible: String? = null // Not a boolean
|
||||
var language: String? = null
|
||||
var translated: Boolean? = null
|
||||
var size: Long? = null
|
||||
var length: Int? = null
|
||||
var favorites: Int? = null
|
||||
var ratingCount: Int? = null
|
||||
var averageRating: Double? = null
|
||||
|
||||
var uploader: String? = null
|
||||
|
||||
val tags: MutableMap<String, List<Tag>> = mutableMapOf()
|
||||
|
||||
companion object {
|
||||
private fun splitGalleryUrl(url: String) = url.let {
|
||||
// Only parse URL if is full URL
|
||||
val pathSegments = if (it.startsWith("http"))
|
||||
Uri.parse(it).pathSegments
|
||||
else
|
||||
it.split('/')
|
||||
pathSegments.filterNot(String::isNullOrBlank)
|
||||
}
|
||||
|
||||
private fun galleryId(url: String) = splitGalleryUrl(url)[1]
|
||||
|
||||
private fun galleryToken(url: String) = splitGalleryUrl(url)[2]
|
||||
|
||||
private fun normalizeUrl(id: String, token: String) = "/g/$id/$token/?nw=always"
|
||||
|
||||
fun normalizeUrl(url: String) = normalizeUrl(galleryId(url), galleryToken(url))
|
||||
}
|
||||
}
|
@ -1,84 +0,0 @@
|
||||
package eu.kanade.tachiyomi.extension.all.ehentai
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
private const val EH_ARTIST_NAMESPACE = "artist"
|
||||
private const val EH_AUTHOR_NAMESPACE = "author"
|
||||
|
||||
private val ONGOING_SUFFIX = arrayOf(
|
||||
"[ongoing]",
|
||||
"(ongoing)",
|
||||
"{ongoing}"
|
||||
)
|
||||
|
||||
val EX_DATE_FORMAT = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.US)
|
||||
|
||||
fun ExGalleryMetadata.copyTo(manga: SManga) {
|
||||
url?.let { manga.url = it }
|
||||
thumbnailUrl?.let { manga.thumbnail_url = it }
|
||||
|
||||
(title ?: altTitle)?.let { manga.title = it }
|
||||
|
||||
// Set artist (if we can find one)
|
||||
tags[EH_ARTIST_NAMESPACE]?.let {
|
||||
if (it.isNotEmpty()) manga.artist = it.joinToString(transform = Tag::name)
|
||||
}
|
||||
// Set author (if we can find one)
|
||||
tags[EH_AUTHOR_NAMESPACE]?.let {
|
||||
if (it.isNotEmpty()) manga.author = it.joinToString(transform = Tag::name)
|
||||
}
|
||||
// Set genre
|
||||
genre?.let { manga.genre = it }
|
||||
|
||||
// Try to automatically identify if it is ongoing, we try not to be too lenient here to avoid making mistakes
|
||||
// We default to completed
|
||||
manga.status = SManga.COMPLETED
|
||||
title?.let { t ->
|
||||
if (ONGOING_SUFFIX.any {
|
||||
t.endsWith(it, ignoreCase = true)
|
||||
}
|
||||
) manga.status = SManga.ONGOING
|
||||
}
|
||||
|
||||
// Build a nice looking description out of what we know
|
||||
val titleDesc = StringBuilder()
|
||||
title?.let { titleDesc += "Title: $it\n" }
|
||||
altTitle?.let { titleDesc += "Alternate Title: $it\n" }
|
||||
|
||||
val detailsDesc = StringBuilder()
|
||||
uploader?.let { detailsDesc += "Uploader: $it\n" }
|
||||
datePosted?.let { detailsDesc += "Posted: ${EX_DATE_FORMAT.format(Date(it))}\n" }
|
||||
visible?.let { detailsDesc += "Visible: $it\n" }
|
||||
language?.let {
|
||||
detailsDesc += "Language: $it"
|
||||
if (translated == true) detailsDesc += " TR"
|
||||
detailsDesc += "\n"
|
||||
}
|
||||
size?.let { detailsDesc += "File Size: ${humanReadableByteCount(it, true)}\n" }
|
||||
length?.let { detailsDesc += "Length: $it pages\n" }
|
||||
favorites?.let { detailsDesc += "Favorited: $it times\n" }
|
||||
averageRating?.let {
|
||||
detailsDesc += "Rating: $it"
|
||||
ratingCount?.let { count -> detailsDesc += " ($count)" }
|
||||
detailsDesc += "\n"
|
||||
}
|
||||
|
||||
val tagsDesc = buildTagsDescription(this)
|
||||
|
||||
manga.description = listOf(titleDesc.toString(), detailsDesc.toString(), tagsDesc.toString())
|
||||
.filter(String::isNotBlank)
|
||||
.joinToString(separator = "\n")
|
||||
}
|
||||
|
||||
private fun buildTagsDescription(metadata: ExGalleryMetadata) = StringBuilder("Tags:\n").apply {
|
||||
// BiConsumer only available in Java 8, we have to use destructuring here
|
||||
metadata.tags.forEach { (namespace, tags) ->
|
||||
if (tags.isNotEmpty()) {
|
||||
val joinedTags = tags.joinToString(separator = " ", transform = { "<${it.name}>" })
|
||||
this += "▪ $namespace: $joinedTags\n"
|
||||
}
|
||||
}
|
||||
}
|
@ -1,6 +0,0 @@
|
||||
package eu.kanade.tachiyomi.extension.all.ehentai
|
||||
|
||||
/**
|
||||
* Simple tag model
|
||||
*/
|
||||
data class Tag(val name: String, val light: Boolean)
|
@ -1,10 +0,0 @@
|
||||
package eu.kanade.tachiyomi.extension.all.ehentai
|
||||
|
||||
import android.net.Uri
|
||||
|
||||
/**
|
||||
* Uri filter
|
||||
*/
|
||||
interface UriFilter {
|
||||
fun addToUri(builder: Uri.Builder)
|
||||
}
|
@ -1,15 +0,0 @@
|
||||
package eu.kanade.tachiyomi.extension.all.ehentai
|
||||
|
||||
import android.net.Uri
|
||||
import eu.kanade.tachiyomi.source.model.Filter
|
||||
|
||||
/**
|
||||
* UriGroup
|
||||
*/
|
||||
open class UriGroup<V>(name: String, state: List<V>) : Filter.Group<V>(name, state), UriFilter {
|
||||
override fun addToUri(builder: Uri.Builder) {
|
||||
state.forEach {
|
||||
if (it is UriFilter) it.addToUri(builder)
|
||||
}
|
||||
}
|
||||
}
|
@ -1,2 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest package="eu.kanade.tachiyomi.extension" />
|
@ -1,13 +0,0 @@
|
||||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'kotlin-android'
|
||||
|
||||
ext {
|
||||
extName = 'HentaiHand'
|
||||
pkgNameSuffix = 'all.hentaihand'
|
||||
extClass = '.HentaiHandFactory'
|
||||
extVersionCode = 2
|
||||
libVersion = '1.2'
|
||||
containsNsfw = true
|
||||
}
|
||||
|
||||
apply from: "$rootDir/common.gradle"
|
Before Width: | Height: | Size: 2.8 KiB |
Before Width: | Height: | Size: 1.6 KiB |
Before Width: | Height: | Size: 3.4 KiB |
Before Width: | Height: | Size: 5.7 KiB |
Before Width: | Height: | Size: 7.4 KiB |
Before Width: | Height: | Size: 20 KiB |
@ -1,396 +0,0 @@
|
||||
package eu.kanade.tachiyomi.extension.all.hentaihand
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Application
|
||||
import android.content.SharedPreferences
|
||||
import android.support.v7.preference.EditTextPreference
|
||||
import android.support.v7.preference.PreferenceScreen
|
||||
import android.text.InputType
|
||||
import android.widget.Toast
|
||||
import com.github.salomonbrys.kotson.fromJson
|
||||
import com.github.salomonbrys.kotson.get
|
||||
import com.github.salomonbrys.kotson.nullObj
|
||||
import com.github.salomonbrys.kotson.nullString
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.JsonArray
|
||||
import com.google.gson.JsonObject
|
||||
import eu.kanade.tachiyomi.annotations.Nsfw
|
||||
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.HttpSource
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.MediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody
|
||||
import okhttp3.Response
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
import rx.Observable
|
||||
import rx.schedulers.Schedulers
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import java.text.SimpleDateFormat
|
||||
|
||||
@Nsfw
|
||||
class HentaiHand(
|
||||
override val lang: String,
|
||||
private val hhLangId: Int? = null,
|
||||
extraName: String = ""
|
||||
) : ConfigurableSource, HttpSource() {
|
||||
|
||||
override val baseUrl: String = "https://hentaihand.com"
|
||||
override val name: String = "HentaiHand$extraName"
|
||||
override val supportsLatest = true
|
||||
|
||||
private val gson = Gson()
|
||||
|
||||
override val client: OkHttpClient = network.cloudflareClient.newBuilder()
|
||||
.addInterceptor { authIntercept(it) }
|
||||
.build()
|
||||
|
||||
private fun parseGenericResponse(response: Response): MangasPage {
|
||||
val data = gson.fromJson<JsonObject>(response.body()!!.string())
|
||||
return MangasPage(
|
||||
data.getAsJsonArray("data").map {
|
||||
SManga.create().apply {
|
||||
url = "/en/comic/${it["slug"].asString}"
|
||||
title = it["title"].asString
|
||||
thumbnail_url = it["thumb_url"].asString
|
||||
}
|
||||
},
|
||||
!data["next_page_url"].isJsonNull
|
||||
)
|
||||
}
|
||||
|
||||
// Popular
|
||||
|
||||
override fun popularMangaParse(response: Response): MangasPage = parseGenericResponse(response)
|
||||
|
||||
override fun popularMangaRequest(page: Int): Request {
|
||||
val url = "$baseUrl/api/comics?page=$page&sort=popularity&order=desc&duration=all"
|
||||
return GET(if (hhLangId == null) url else ("$url&languages=$hhLangId"))
|
||||
}
|
||||
|
||||
// Latest
|
||||
|
||||
override fun latestUpdatesParse(response: Response): MangasPage = parseGenericResponse(response)
|
||||
|
||||
override fun latestUpdatesRequest(page: Int): Request {
|
||||
val url = "$baseUrl/api/comics?page=$page&sort=uploaded_at&order=desc&duration=week"
|
||||
return GET(if (hhLangId == null) url else ("$url&languages=$hhLangId"))
|
||||
}
|
||||
|
||||
// Search
|
||||
|
||||
override fun searchMangaParse(response: Response): MangasPage = parseGenericResponse(response)
|
||||
|
||||
private fun lookupFilterId(query: String, uri: String): Int? {
|
||||
// filter query needs to be resolved to an ID
|
||||
return client.newCall(GET("$baseUrl/api/$uri?q=$query"))
|
||||
.asObservableSuccess()
|
||||
.subscribeOn(Schedulers.io())
|
||||
.map {
|
||||
val data = gson.fromJson<JsonObject>(it.body()!!.string())
|
||||
// only the first tag will be used
|
||||
data.getAsJsonArray("data").firstOrNull()?.let { t -> t["id"].asInt }
|
||||
}.toBlocking().first()
|
||||
}
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
|
||||
val url = HttpUrl.parse("$baseUrl/api/comics")!!.newBuilder()
|
||||
.addQueryParameter("page", page.toString())
|
||||
.addQueryParameter("q", query)
|
||||
|
||||
if (hhLangId != null)
|
||||
url.addQueryParameter("languages", hhLangId.toString())
|
||||
|
||||
(if (filters.isEmpty()) getFilterList() else filters).forEach { filter ->
|
||||
when (filter) {
|
||||
is SortFilter -> url.addQueryParameter("sort", getSortPairs()[filter.state].second)
|
||||
is OrderFilter -> url.addQueryParameter("order", getOrderPairs()[filter.state].second)
|
||||
is DurationFilter -> url.addQueryParameter("duration", getDurationPairs()[filter.state].second)
|
||||
is AttributesGroupFilter -> filter.state.forEach {
|
||||
if (it.state) url.addQueryParameter("attributes", it.value)
|
||||
}
|
||||
is LookupFilter -> {
|
||||
filter.state.split(",").map { it.trim() }.filter { it.isNotBlank() }.map {
|
||||
lookupFilterId(it, filter.uri) ?: throw Exception("No ${filter.singularName} \"$it\" was found")
|
||||
}.forEach {
|
||||
if (!(filter.uri == "languages" && it == hhLangId))
|
||||
url.addQueryParameter(filter.uri, it.toString())
|
||||
}
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
return GET(url.toString())
|
||||
}
|
||||
|
||||
// Details
|
||||
|
||||
private fun tagArrayToString(array: JsonArray, key: String = "name"): String? {
|
||||
if (array.size() == 0)
|
||||
return null
|
||||
return array.joinToString { it[key].asString }
|
||||
}
|
||||
|
||||
private fun mangaDetailsApiRequest(manga: SManga): Request {
|
||||
val slug = manga.url.removePrefix("/en/comic/")
|
||||
return GET("$baseUrl/api/comics/$slug")
|
||||
}
|
||||
|
||||
override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
|
||||
return client.newCall(mangaDetailsApiRequest(manga))
|
||||
.asObservableSuccess()
|
||||
.map { mangaDetailsParse(it).apply { initialized = true } }
|
||||
}
|
||||
|
||||
override fun mangaDetailsParse(response: Response): SManga {
|
||||
val data = gson.fromJson<JsonObject>(response.body()!!.string())
|
||||
return SManga.create().apply {
|
||||
|
||||
artist = tagArrayToString(data.getAsJsonArray("artists"))
|
||||
author = tagArrayToString(data.getAsJsonArray("authors")) ?: artist
|
||||
|
||||
genre = listOf("tags", "relationships").map {
|
||||
data.getAsJsonArray(it).map { t -> t["name"].asString }
|
||||
}.flatten().distinct().joinToString()
|
||||
|
||||
status = SManga.COMPLETED
|
||||
|
||||
description = listOf(
|
||||
Pair("Alternative Title", data["alternative_title"].nullString),
|
||||
Pair("Groups", tagArrayToString(data.getAsJsonArray("groups"))),
|
||||
Pair("Description", data["description"].nullString),
|
||||
Pair("Pages", data["pages"].asInt.toString()),
|
||||
Pair("Category", data["category"].nullObj?.get("name")?.asString),
|
||||
Pair("Language", data["language"].nullObj?.get("name")?.asString),
|
||||
Pair("Parodies", tagArrayToString(data.getAsJsonArray("parodies"))),
|
||||
Pair("Characters", tagArrayToString(data.getAsJsonArray("characters")))
|
||||
).filter { !it.second.isNullOrEmpty() }.joinToString("\n\n") { "${it.first}:\n${it.second}" }
|
||||
}
|
||||
}
|
||||
|
||||
// Chapters
|
||||
|
||||
override fun chapterListRequest(manga: SManga): Request = mangaDetailsApiRequest(manga)
|
||||
|
||||
override fun chapterListParse(response: Response): List<SChapter> {
|
||||
val data = gson.fromJson<JsonObject>(response.body()!!.string())
|
||||
return listOf(
|
||||
SChapter.create().apply {
|
||||
url = "/en/comic/${data["slug"].asString}/reader/1"
|
||||
name = "Chapter"
|
||||
date_upload = DATE_FORMAT.parse(data["uploaded_at"].asString)?.time ?: 0
|
||||
chapter_number = 1f
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Pages
|
||||
|
||||
override fun pageListRequest(chapter: SChapter): Request {
|
||||
val slug = chapter.url.removePrefix("/en/comic/").removeSuffix("/reader/1")
|
||||
return GET("$baseUrl/api/comics/$slug/images")
|
||||
}
|
||||
|
||||
override fun pageListParse(response: Response): List<Page> {
|
||||
val data = gson.fromJson<JsonObject>(response.body()!!.string())
|
||||
return data.getAsJsonArray("images").mapIndexed { i, it ->
|
||||
Page(i, "/en/comic/${data["comic"]["slug"].asString}/reader/${it["page"].asInt}", it["source_url"].asString)
|
||||
}
|
||||
}
|
||||
|
||||
override fun imageUrlParse(response: Response): String = throw UnsupportedOperationException("Not used")
|
||||
|
||||
// Authorization
|
||||
|
||||
private fun authIntercept(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
if (username.isEmpty() or password.isEmpty()) {
|
||||
return chain.proceed(request)
|
||||
}
|
||||
|
||||
if (token.isEmpty()) {
|
||||
token = this.login(chain, username, password)
|
||||
}
|
||||
val authRequest = request.newBuilder()
|
||||
.addHeader("Authorization", "Bearer $token")
|
||||
.build()
|
||||
return chain.proceed(authRequest)
|
||||
}
|
||||
|
||||
private fun login(chain: Interceptor.Chain, username: String, password: String): String {
|
||||
val jsonObject = JSONObject().apply {
|
||||
this.put("username", username)
|
||||
this.put("password", password)
|
||||
this.put("remember_me", true)
|
||||
}
|
||||
val body = RequestBody.create(MEDIA_TYPE, jsonObject.toString())
|
||||
val response = chain.proceed(POST("$baseUrl/api/login", headers, body))
|
||||
if (response.code() == 401) {
|
||||
throw Exception("Failed to login, check if username and password are correct")
|
||||
}
|
||||
|
||||
if (response.body() == null)
|
||||
throw Exception("Login response body is empty")
|
||||
try {
|
||||
return JSONObject(response.body()!!.string())
|
||||
.getJSONObject("auth")
|
||||
.getString("access_token")
|
||||
} catch (e: JSONException) {
|
||||
throw Exception("Cannot parse login response body")
|
||||
}
|
||||
}
|
||||
|
||||
private var token: String = ""
|
||||
private val username by lazy { getPrefUsername() }
|
||||
private val password by lazy { getPrefPassword() }
|
||||
|
||||
// Preferences
|
||||
|
||||
private val preferences: SharedPreferences by lazy {
|
||||
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
|
||||
}
|
||||
|
||||
override fun setupPreferenceScreen(screen: androidx.preference.PreferenceScreen) {
|
||||
screen.addPreference(screen.editTextPreference(USERNAME_TITLE, USERNAME_DEFAULT, username))
|
||||
screen.addPreference(screen.editTextPreference(PASSWORD_TITLE, PASSWORD_DEFAULT, password, true))
|
||||
}
|
||||
|
||||
private fun androidx.preference.PreferenceScreen.editTextPreference(title: String, default: String, value: String, isPassword: Boolean = false): androidx.preference.EditTextPreference {
|
||||
return androidx.preference.EditTextPreference(context).apply {
|
||||
key = title
|
||||
this.title = title
|
||||
summary = value
|
||||
this.setDefaultValue(default)
|
||||
dialogTitle = title
|
||||
|
||||
if (isPassword) {
|
||||
setOnBindEditTextListener {
|
||||
it.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
|
||||
}
|
||||
}
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
try {
|
||||
val res = preferences.edit().putString(title, newValue as String).commit()
|
||||
Toast.makeText(context, "Restart Tachiyomi to apply new setting.", Toast.LENGTH_LONG).show()
|
||||
res
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun setupPreferenceScreen(screen: PreferenceScreen) {
|
||||
screen.addPreference(screen.supportEditTextPreference(USERNAME_TITLE, USERNAME_DEFAULT, username))
|
||||
screen.addPreference(screen.supportEditTextPreference(PASSWORD_TITLE, PASSWORD_DEFAULT, password))
|
||||
}
|
||||
|
||||
private fun PreferenceScreen.supportEditTextPreference(title: String, default: String, value: String): EditTextPreference {
|
||||
return EditTextPreference(context).apply {
|
||||
key = title
|
||||
this.title = title
|
||||
summary = value
|
||||
this.setDefaultValue(default)
|
||||
dialogTitle = title
|
||||
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
try {
|
||||
val res = preferences.edit().putString(title, newValue as String).commit()
|
||||
Toast.makeText(context, "Restart Tachiyomi to apply new setting.", Toast.LENGTH_LONG).show()
|
||||
res
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getPrefUsername(): String = preferences.getString(USERNAME_TITLE, USERNAME_DEFAULT)!!
|
||||
private fun getPrefPassword(): String = preferences.getString(PASSWORD_TITLE, PASSWORD_DEFAULT)!!
|
||||
|
||||
// Filters
|
||||
|
||||
private class SortFilter(sortPairs: List<Pair<String, String>>) : Filter.Select<String>("Sort By", sortPairs.map { it.first }.toTypedArray())
|
||||
private class OrderFilter(orderPairs: List<Pair<String, String>>) : Filter.Select<String>("Order By", orderPairs.map { it.first }.toTypedArray())
|
||||
private class DurationFilter(durationPairs: List<Pair<String, String>>) : Filter.Select<String>("Duration", durationPairs.map { it.first }.toTypedArray())
|
||||
private class AttributeFilter(name: String, val value: String) : Filter.CheckBox(name)
|
||||
private class AttributesGroupFilter(attributePairs: List<Pair<String, String>>) : Filter.Group<AttributeFilter>("Attributes", attributePairs.map { AttributeFilter(it.first, it.second) })
|
||||
|
||||
private class CategoriesFilter : LookupFilter("Categories", "categories", "category")
|
||||
private class TagsFilter : LookupFilter("Tags", "tags", "tag")
|
||||
private class ArtistsFilter : LookupFilter("Artists", "artists", "artist")
|
||||
private class GroupsFilter : LookupFilter("Groups", "groups", "group")
|
||||
private class CharactersFilter : LookupFilter("Characters", "characters", "character")
|
||||
private class ParodiesFilter : LookupFilter("Parodies", "parodies", "parody")
|
||||
private class LanguagesFilter : LookupFilter("Other Languages", "languages", "language")
|
||||
open class LookupFilter(name: String, val uri: String, val singularName: String) : Filter.Text(name)
|
||||
|
||||
override fun getFilterList() = FilterList(
|
||||
SortFilter(getSortPairs()),
|
||||
OrderFilter(getOrderPairs()),
|
||||
DurationFilter(getDurationPairs()),
|
||||
Filter.Header("Separate terms with commas (,)"),
|
||||
CategoriesFilter(),
|
||||
TagsFilter(),
|
||||
ArtistsFilter(),
|
||||
GroupsFilter(),
|
||||
CharactersFilter(),
|
||||
ParodiesFilter(),
|
||||
LanguagesFilter(),
|
||||
AttributesGroupFilter(getAttributePairs())
|
||||
)
|
||||
|
||||
private fun getSortPairs() = listOf(
|
||||
Pair("Upload Date", "uploaded_at"),
|
||||
Pair("Title", "title"),
|
||||
Pair("Pages", "pages"),
|
||||
Pair("Favorites", "favorites"),
|
||||
Pair("Popularity", "popularity")
|
||||
)
|
||||
|
||||
private fun getOrderPairs() = listOf(
|
||||
Pair("Descending", "desc"),
|
||||
Pair("Ascending", "asc")
|
||||
)
|
||||
|
||||
private fun getDurationPairs() = listOf(
|
||||
Pair("Today", "day"),
|
||||
Pair("This Week", "week"),
|
||||
Pair("This Month", "month"),
|
||||
Pair("This Year", "year"),
|
||||
Pair("All Time", "all")
|
||||
)
|
||||
|
||||
private fun getAttributePairs() = listOf(
|
||||
Pair("Translated", "translated"),
|
||||
Pair("Speechless", "speechless"),
|
||||
Pair("Rewritten", "rewritten")
|
||||
)
|
||||
|
||||
companion object {
|
||||
@SuppressLint("SimpleDateFormat")
|
||||
private val DATE_FORMAT = SimpleDateFormat("yyyy-dd-MM")
|
||||
private val MEDIA_TYPE = MediaType.parse("application/json; charset=utf-8")
|
||||
private const val USERNAME_TITLE = "Username"
|
||||
private const val USERNAME_DEFAULT = ""
|
||||
private const val PASSWORD_TITLE = "Password"
|
||||
private const val PASSWORD_DEFAULT = ""
|
||||
}
|
||||
}
|
@ -1,51 +0,0 @@
|
||||
package eu.kanade.tachiyomi.extension.all.hentaihand
|
||||
|
||||
import eu.kanade.tachiyomi.annotations.Nsfw
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.source.SourceFactory
|
||||
|
||||
@Nsfw
|
||||
class HentaiHandFactory : SourceFactory {
|
||||
|
||||
override fun createSources(): List<Source> = listOf(
|
||||
// https://hentaihand.com/api/languages?per_page=50
|
||||
HentaiHand("other", extraName = " (Unfiltered)"),
|
||||
HentaiHand("en", 1),
|
||||
HentaiHand("zh", 2),
|
||||
HentaiHand("ja", 3),
|
||||
HentaiHand("other", 4, extraName = " (Text Cleaned)"),
|
||||
HentaiHand("eo", 5),
|
||||
HentaiHand("ceb", 6),
|
||||
HentaiHand("cs", 7),
|
||||
HentaiHand("ar", 8),
|
||||
HentaiHand("sk", 9),
|
||||
HentaiHand("mn", 10),
|
||||
HentaiHand("uk", 11),
|
||||
HentaiHand("la", 12),
|
||||
HentaiHand("tl", 13),
|
||||
HentaiHand("es", 14),
|
||||
HentaiHand("it", 15),
|
||||
HentaiHand("ko", 16),
|
||||
HentaiHand("th", 17),
|
||||
HentaiHand("pl", 18),
|
||||
HentaiHand("fr", 19),
|
||||
HentaiHand("pt", 20),
|
||||
HentaiHand("de", 21),
|
||||
HentaiHand("fi", 22),
|
||||
HentaiHand("ru", 23),
|
||||
HentaiHand("sv", 24),
|
||||
HentaiHand("hu", 25),
|
||||
HentaiHand("id", 26),
|
||||
HentaiHand("vi", 27),
|
||||
HentaiHand("da", 28),
|
||||
HentaiHand("ro", 29),
|
||||
HentaiHand("et", 30),
|
||||
HentaiHand("nl", 31),
|
||||
HentaiHand("ca", 32),
|
||||
HentaiHand("tr", 33),
|
||||
HentaiHand("el", 34),
|
||||
HentaiHand("no", 35),
|
||||
HentaiHand("sq", 1501),
|
||||
HentaiHand("bg", 1502),
|
||||
)
|
||||
}
|
@ -1,23 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="eu.kanade.tachiyomi.extension">
|
||||
|
||||
<application>
|
||||
<activity
|
||||
android:name=".all.hitomi.HitomiActivity"
|
||||
android:excludeFromRecents="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="hitomi.la"
|
||||
android:pathPattern="/cg/..*"
|
||||
android:scheme="https" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
@ -1,13 +0,0 @@
|
||||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'kotlin-android'
|
||||
|
||||
ext {
|
||||
extName = 'Hitomi.la'
|
||||
pkgNameSuffix = 'all.hitomi'
|
||||
extClass = '.HitomiFactory'
|
||||
extVersionCode = 5
|
||||
libVersion = '1.2'
|
||||
containsNsfw = true
|
||||
}
|
||||
|
||||
apply from: "$rootDir/common.gradle"
|
Before Width: | Height: | Size: 3.2 KiB |
Before Width: | Height: | Size: 1.8 KiB |
Before Width: | Height: | Size: 4.0 KiB |
Before Width: | Height: | Size: 8.2 KiB |
Before Width: | Height: | Size: 12 KiB |
Before Width: | Height: | Size: 56 KiB |
@ -1,93 +0,0 @@
|
||||
package eu.kanade.tachiyomi.extension.all.hitomi
|
||||
|
||||
import java.nio.ByteBuffer
|
||||
|
||||
/**
|
||||
* Simple cursor for use on byte arrays
|
||||
* @author nulldev
|
||||
*/
|
||||
class ByteCursor(val content: ByteArray) {
|
||||
var index = -1
|
||||
private set
|
||||
private var mark = -1
|
||||
|
||||
fun mark() {
|
||||
mark = index
|
||||
}
|
||||
|
||||
fun jumpToMark() {
|
||||
index = mark
|
||||
}
|
||||
|
||||
fun jumpToIndex(index: Int) {
|
||||
this.index = index
|
||||
}
|
||||
|
||||
fun next(): Byte {
|
||||
return content[++index]
|
||||
}
|
||||
|
||||
fun next(count: Int): ByteArray {
|
||||
val res = content.sliceArray(index + 1..index + count)
|
||||
skip(count)
|
||||
return res
|
||||
}
|
||||
|
||||
// Used to perform conversions
|
||||
private fun byteBuffer(count: Int): ByteBuffer {
|
||||
return ByteBuffer.wrap(next(count))
|
||||
}
|
||||
|
||||
// Epic hack to get an unsigned short properly...
|
||||
fun fakeNextShortInt(): Int = ByteBuffer
|
||||
.wrap(arrayOf(0x00, 0x00, *next(2).toTypedArray()).toByteArray())
|
||||
.getInt(0)
|
||||
|
||||
// fun nextShort(): Short = byteBuffer(2).getShort(0)
|
||||
fun nextInt(): Int = byteBuffer(4).getInt(0)
|
||||
fun nextLong(): Long = byteBuffer(8).getLong(0)
|
||||
fun nextFloat(): Float = byteBuffer(4).getFloat(0)
|
||||
fun nextDouble(): Double = byteBuffer(8).getDouble(0)
|
||||
|
||||
fun skip(count: Int) {
|
||||
index += count
|
||||
}
|
||||
|
||||
fun expect(vararg bytes: Byte) {
|
||||
if (bytes.size > remaining()) {
|
||||
throw IllegalStateException("Unexpected end of content!")
|
||||
}
|
||||
|
||||
for (i in 0..bytes.lastIndex) {
|
||||
val expected = bytes[i]
|
||||
val actual = content[index + i + 1]
|
||||
|
||||
if (expected != actual) {
|
||||
throw IllegalStateException("Unexpected content (expected: $expected, actual: $actual)!")
|
||||
}
|
||||
}
|
||||
|
||||
index += bytes.size
|
||||
}
|
||||
|
||||
fun checkEqual(vararg bytes: Byte): Boolean {
|
||||
if (bytes.size > remaining()) {
|
||||
return false
|
||||
}
|
||||
|
||||
for (i in 0..bytes.lastIndex) {
|
||||
val expected = bytes[i]
|
||||
val actual = content[index + i + 1]
|
||||
|
||||
if (expected != actual) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
fun atEnd() = index >= content.size - 1
|
||||
|
||||
fun remaining() = content.size - index - 1
|
||||
}
|
@ -1,406 +0,0 @@
|
||||
package eu.kanade.tachiyomi.extension.all.hitomi
|
||||
|
||||
import android.app.Application
|
||||
import android.content.SharedPreferences
|
||||
import android.support.v7.preference.CheckBoxPreference
|
||||
import android.support.v7.preference.PreferenceScreen
|
||||
import com.github.salomonbrys.kotson.array
|
||||
import com.github.salomonbrys.kotson.get
|
||||
import com.github.salomonbrys.kotson.string
|
||||
import com.google.gson.JsonParser
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.asObservableSuccess
|
||||
import eu.kanade.tachiyomi.source.ConfigurableSource
|
||||
import eu.kanade.tachiyomi.source.model.FilterList
|
||||
import eu.kanade.tachiyomi.source.model.MangasPage
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
import eu.kanade.tachiyomi.util.asJsoup
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.jsoup.nodes.Document
|
||||
import rx.Observable
|
||||
import rx.Single
|
||||
import rx.schedulers.Schedulers
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import java.util.Locale
|
||||
import androidx.preference.CheckBoxPreference as AndroidXCheckBoxPreference
|
||||
import androidx.preference.PreferenceScreen as AndroidXPreferenceScreen
|
||||
|
||||
/**
|
||||
* Ported from TachiyomiSy
|
||||
* Original work by NerdNumber9 for TachiyomiEH
|
||||
*/
|
||||
|
||||
open class Hitomi(override val lang: String, private val nozomiLang: String) : HttpSource(), ConfigurableSource {
|
||||
|
||||
override val supportsLatest = true
|
||||
|
||||
override val name = if (nozomiLang == "all") "Hitomi.la unfiltered" else "Hitomi.la"
|
||||
|
||||
override val baseUrl = BASE_URL
|
||||
|
||||
// Popular
|
||||
|
||||
override fun fetchPopularManga(page: Int): Observable<MangasPage> {
|
||||
return client.newCall(popularMangaRequest(page))
|
||||
.asObservableSuccess()
|
||||
.flatMap { responseToMangas(it) }
|
||||
}
|
||||
|
||||
override fun popularMangaRequest(page: Int) = HitomiNozomi.rangedGet(
|
||||
"$LTN_BASE_URL/popular-$nozomiLang.nozomi",
|
||||
100L * (page - 1),
|
||||
99L + 100 * (page - 1)
|
||||
)
|
||||
|
||||
private fun responseToMangas(response: Response): Observable<MangasPage> {
|
||||
val range = response.header("Content-Range")!!
|
||||
val total = range.substringAfter('/').toLong()
|
||||
val end = range.substringBefore('/').substringAfter('-').toLong()
|
||||
val body = response.body()!!
|
||||
return parseNozomiPage(body.bytes())
|
||||
.map {
|
||||
MangasPage(it, end < total - 1)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseNozomiPage(array: ByteArray): Observable<List<SManga>> {
|
||||
val cursor = ByteCursor(array)
|
||||
val ids = (1..array.size / 4).map {
|
||||
cursor.nextInt()
|
||||
}
|
||||
|
||||
return nozomiIdsToMangas(ids).toObservable()
|
||||
}
|
||||
|
||||
private fun nozomiIdsToMangas(ids: List<Int>): Single<List<SManga>> {
|
||||
return Single.zip(
|
||||
ids.map { int ->
|
||||
client.newCall(GET("$LTN_BASE_URL/galleryblock/$int.html"))
|
||||
.asObservableSuccess()
|
||||
.subscribeOn(Schedulers.io()) // Perform all these requests in parallel
|
||||
.map { parseGalleryBlock(it) }
|
||||
.toSingle()
|
||||
}
|
||||
) { it.map { m -> m as SManga } }
|
||||
}
|
||||
|
||||
private fun Document.selectFirst(selector: String) = this.select(selector).first()
|
||||
|
||||
private fun parseGalleryBlock(response: Response): SManga {
|
||||
val doc = response.asJsoup()
|
||||
return SManga.create().apply {
|
||||
val titleElement = doc.selectFirst("h1")
|
||||
title = titleElement.text()
|
||||
thumbnail_url = "https:" + if (useHqThumbPref()) {
|
||||
doc.selectFirst("img").attr("srcset").substringBefore(' ')
|
||||
} else {
|
||||
doc.selectFirst("img").attr("src")
|
||||
}
|
||||
url = titleElement.child(0).attr("href")
|
||||
}
|
||||
}
|
||||
|
||||
override fun popularMangaParse(response: Response) = throw UnsupportedOperationException("Not used")
|
||||
|
||||
// Latest
|
||||
|
||||
override fun fetchLatestUpdates(page: Int): Observable<MangasPage> {
|
||||
return client.newCall(latestUpdatesRequest(page))
|
||||
.asObservableSuccess()
|
||||
.flatMap { responseToMangas(it) }
|
||||
}
|
||||
|
||||
override fun latestUpdatesRequest(page: Int) = HitomiNozomi.rangedGet(
|
||||
"$LTN_BASE_URL/index-$nozomiLang.nozomi",
|
||||
100L * (page - 1),
|
||||
99L + 100 * (page - 1)
|
||||
)
|
||||
|
||||
override fun latestUpdatesParse(response: Response) = throw UnsupportedOperationException("Not used")
|
||||
|
||||
// Search
|
||||
|
||||
private var cachedTagIndexVersion: Long? = null
|
||||
private var tagIndexVersionCacheTime: Long = 0
|
||||
private fun tagIndexVersion(): Single<Long> {
|
||||
val sCachedTagIndexVersion = cachedTagIndexVersion
|
||||
return if (sCachedTagIndexVersion == null ||
|
||||
tagIndexVersionCacheTime + INDEX_VERSION_CACHE_TIME_MS < System.currentTimeMillis()
|
||||
) {
|
||||
HitomiNozomi.getIndexVersion(client, "tagindex").subscribeOn(Schedulers.io()).doOnNext {
|
||||
cachedTagIndexVersion = it
|
||||
tagIndexVersionCacheTime = System.currentTimeMillis()
|
||||
}.toSingle()
|
||||
} else {
|
||||
Single.just(sCachedTagIndexVersion)
|
||||
}
|
||||
}
|
||||
|
||||
private var cachedGalleryIndexVersion: Long? = null
|
||||
private var galleryIndexVersionCacheTime: Long = 0
|
||||
private fun galleryIndexVersion(): Single<Long> {
|
||||
val sCachedGalleryIndexVersion = cachedGalleryIndexVersion
|
||||
return if (sCachedGalleryIndexVersion == null ||
|
||||
galleryIndexVersionCacheTime + INDEX_VERSION_CACHE_TIME_MS < System.currentTimeMillis()
|
||||
) {
|
||||
HitomiNozomi.getIndexVersion(client, "galleriesindex").subscribeOn(Schedulers.io()).doOnNext {
|
||||
cachedGalleryIndexVersion = it
|
||||
galleryIndexVersionCacheTime = System.currentTimeMillis()
|
||||
}.toSingle()
|
||||
} else {
|
||||
Single.just(sCachedGalleryIndexVersion)
|
||||
}
|
||||
}
|
||||
|
||||
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
|
||||
return if (query.startsWith(PREFIX_ID_SEARCH)) {
|
||||
val id = query.removePrefix(PREFIX_ID_SEARCH)
|
||||
client.newCall(GET("$baseUrl/cg/$id", headers)).asObservableSuccess()
|
||||
.map { MangasPage(listOf(mangaDetailsParse(it).apply { url = "/cg/$id" }), false) }
|
||||
} else {
|
||||
val splitQuery = query.toLowerCase(Locale.ENGLISH).split(" ")
|
||||
|
||||
val positive = splitQuery.filter { !it.startsWith('-') }.toMutableList()
|
||||
if (nozomiLang != "all") positive += "language:$nozomiLang"
|
||||
val negative = (splitQuery - positive).map { it.removePrefix("-") }
|
||||
|
||||
// TODO Cache the results coming out of HitomiNozomi (this TODO dates back to TachiyomiEH)
|
||||
val hn = Single.zip(tagIndexVersion(), galleryIndexVersion()) { tv, gv -> tv to gv }
|
||||
.map { HitomiNozomi(client, it.first, it.second) }
|
||||
|
||||
var base = if (positive.isEmpty()) {
|
||||
hn.flatMap { n -> n.getGalleryIdsFromNozomi(null, "index", "all").map { n to it.toSet() } }
|
||||
} else {
|
||||
val q = positive.removeAt(0)
|
||||
hn.flatMap { n -> n.getGalleryIdsForQuery(q).map { n to it.toSet() } }
|
||||
}
|
||||
|
||||
base = positive.fold(base) { acc, q ->
|
||||
acc.flatMap { (nozomi, mangas) ->
|
||||
nozomi.getGalleryIdsForQuery(q).map {
|
||||
nozomi to mangas.intersect(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
base = negative.fold(base) { acc, q ->
|
||||
acc.flatMap { (nozomi, mangas) ->
|
||||
nozomi.getGalleryIdsForQuery(q).map {
|
||||
nozomi to (mangas - it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
base.flatMap { (_, ids) ->
|
||||
val chunks = ids.chunked(PAGE_SIZE)
|
||||
|
||||
nozomiIdsToMangas(chunks[page - 1]).map { mangas ->
|
||||
MangasPage(mangas, page < chunks.size)
|
||||
}
|
||||
}.toObservable()
|
||||
}
|
||||
}
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList) = throw UnsupportedOperationException("Not used")
|
||||
override fun searchMangaParse(response: Response) = throw UnsupportedOperationException("Not used")
|
||||
|
||||
// Details
|
||||
|
||||
override fun mangaDetailsParse(response: Response): SManga {
|
||||
val document = response.asJsoup()
|
||||
fun String.replaceSpaces() = this.replace(" ", "_")
|
||||
|
||||
return SManga.create().apply {
|
||||
thumbnail_url = document.select("div.cover img").attr("abs:src")
|
||||
author = document.select("div.gallery h2 a").joinToString { it.text() }
|
||||
val tableInfo = document.select("table tr")
|
||||
.map { tr ->
|
||||
val key = tr.select("td:first-child").text()
|
||||
val value = with(tr.select("td:last-child a")) {
|
||||
when (key) {
|
||||
"Series", "Characters" -> {
|
||||
if (text().isNotEmpty())
|
||||
joinToString { "${attr("href").removePrefix("/").substringBefore("/")}:${it.text().replaceSpaces()}" } else null
|
||||
}
|
||||
"Tags" -> joinToString { element ->
|
||||
element.text().let {
|
||||
when {
|
||||
it.contains("♀") -> "female:${it.substringBeforeLast(" ").replaceSpaces()}"
|
||||
it.contains("♂") -> "male:${it.substringBeforeLast(" ").replaceSpaces()}"
|
||||
else -> it
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> joinToString { it.text() }
|
||||
}
|
||||
}
|
||||
Pair(key, value)
|
||||
}
|
||||
.plus(Pair("Date uploaded", document.select("div.gallery span.date").text()))
|
||||
.toMap()
|
||||
description = tableInfo.filterNot { it.value.isNullOrEmpty() || it.key in listOf("Series", "Characters", "Tags") }.entries.joinToString("\n") { "${it.key}: ${it.value}" }
|
||||
genre = listOfNotNull(tableInfo["Series"], tableInfo["Characters"], tableInfo["Tags"]).joinToString()
|
||||
}
|
||||
}
|
||||
|
||||
// Chapters
|
||||
|
||||
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
|
||||
return Observable.just(
|
||||
listOf(
|
||||
SChapter.create().apply {
|
||||
url = manga.url
|
||||
name = "Chapter"
|
||||
chapter_number = 0.0f
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun chapterListParse(response: Response) = throw UnsupportedOperationException("Not used")
|
||||
|
||||
// Pages
|
||||
|
||||
private fun hlIdFromUrl(url: String) =
|
||||
url.split('/').last().split('-').last().substringBeforeLast('.')
|
||||
|
||||
override fun pageListRequest(chapter: SChapter): Request {
|
||||
return GET("$LTN_BASE_URL/galleries/${hlIdFromUrl(chapter.url)}.js")
|
||||
}
|
||||
|
||||
private val jsonParser = JsonParser()
|
||||
|
||||
override fun pageListParse(response: Response): List<Page> {
|
||||
val str = response.body()!!.string()
|
||||
val json = jsonParser.parse(str.removePrefix("var galleryinfo = "))
|
||||
return json["files"].array.mapIndexed { i, jsonElement ->
|
||||
val hash = jsonElement["hash"].string
|
||||
val ext = if (jsonElement["haswebp"].string == "0" || !hitomiAlwaysWebp()) jsonElement["name"].string.split('.').last() else "webp"
|
||||
val path = if (jsonElement["haswebp"].string == "0" || !hitomiAlwaysWebp()) "images" else "webp"
|
||||
val hashPath1 = hash.takeLast(1)
|
||||
val hashPath2 = hash.takeLast(3).take(2)
|
||||
|
||||
// https://ltn.hitomi.la/reader.js
|
||||
// function make_image_element()
|
||||
val secondSubdomain = if (jsonElement["haswebp"].string == "0" && jsonElement["hasavif"].string == "0") "b" else "a"
|
||||
|
||||
Page(i, "", "https://${firstSubdomainFromGalleryId(hashPath2)}$secondSubdomain.hitomi.la/$path/$hashPath1/$hashPath2/$hash.$ext")
|
||||
}
|
||||
}
|
||||
|
||||
// https://ltn.hitomi.la/common.js
|
||||
private fun firstSubdomainFromGalleryId(pathSegment: String): Char {
|
||||
var numberOfFrontends = 3
|
||||
var g = pathSegment.toInt(16)
|
||||
if (g < 0x30) numberOfFrontends = 2
|
||||
if (g < 0x09) g = 1
|
||||
|
||||
return (97 + g.rem(numberOfFrontends)).toChar()
|
||||
}
|
||||
|
||||
override fun imageRequest(page: Page): Request {
|
||||
val request = super.imageRequest(page)
|
||||
val hlId = request.url().pathSegments().let {
|
||||
it[it.lastIndex - 1]
|
||||
}
|
||||
return request.newBuilder()
|
||||
.header("Referer", "$BASE_URL/reader/$hlId.html")
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun imageUrlParse(response: Response) = throw UnsupportedOperationException("Not used")
|
||||
|
||||
companion object {
|
||||
private const val INDEX_VERSION_CACHE_TIME_MS = 1000 * 60 * 10
|
||||
private const val PAGE_SIZE = 25
|
||||
|
||||
const val PREFIX_ID_SEARCH = "id:"
|
||||
|
||||
// From HitomiSearchMetaData
|
||||
const val LTN_BASE_URL = "https://ltn.hitomi.la"
|
||||
const val BASE_URL = "https://hitomi.la"
|
||||
|
||||
// Preferences
|
||||
private const val WEBP_PREF_KEY = "HITOMI_WEBP"
|
||||
private const val WEBP_PREF_TITLE = "Webp pages"
|
||||
private const val WEBP_PREF_SUMMARY = "Download webp pages instead of jpeg (when available)"
|
||||
private const val WEBP_PREF_DEFAULT_VALUE = true
|
||||
|
||||
private const val COVER_PREF_KEY = "HITOMI_COVERS"
|
||||
private const val COVER_PREF_TITLE = "Use HQ covers"
|
||||
private const val COVER_PREF_SUMMARY = "See HQ covers while browsing"
|
||||
private const val COVER_PREF_DEFAULT_VALUE = true
|
||||
}
|
||||
|
||||
// Preferences
|
||||
|
||||
private val preferences: SharedPreferences by lazy {
|
||||
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
|
||||
}
|
||||
|
||||
override fun setupPreferenceScreen(screen: PreferenceScreen) {
|
||||
val webpPref = CheckBoxPreference(screen.context).apply {
|
||||
key = "${WEBP_PREF_KEY}_$lang"
|
||||
title = WEBP_PREF_TITLE
|
||||
summary = WEBP_PREF_SUMMARY
|
||||
setDefaultValue(WEBP_PREF_DEFAULT_VALUE)
|
||||
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
val checkValue = newValue as Boolean
|
||||
preferences.edit().putBoolean("${WEBP_PREF_KEY}_$lang", checkValue).commit()
|
||||
}
|
||||
}
|
||||
|
||||
val coverPref = CheckBoxPreference(screen.context).apply {
|
||||
key = "${COVER_PREF_KEY}_$lang"
|
||||
title = COVER_PREF_TITLE
|
||||
summary = COVER_PREF_SUMMARY
|
||||
setDefaultValue(COVER_PREF_DEFAULT_VALUE)
|
||||
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
val checkValue = newValue as Boolean
|
||||
preferences.edit().putBoolean("${COVER_PREF_KEY}_$lang", checkValue).commit()
|
||||
}
|
||||
}
|
||||
|
||||
screen.addPreference(webpPref)
|
||||
screen.addPreference(coverPref)
|
||||
}
|
||||
|
||||
override fun setupPreferenceScreen(screen: AndroidXPreferenceScreen) {
|
||||
val webpPref = AndroidXCheckBoxPreference(screen.context).apply {
|
||||
key = "${WEBP_PREF_KEY}_$lang"
|
||||
title = WEBP_PREF_TITLE
|
||||
summary = WEBP_PREF_SUMMARY
|
||||
setDefaultValue(WEBP_PREF_DEFAULT_VALUE)
|
||||
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
val checkValue = newValue as Boolean
|
||||
preferences.edit().putBoolean("${WEBP_PREF_KEY}_$lang", checkValue).commit()
|
||||
}
|
||||
}
|
||||
|
||||
val coverPref = AndroidXCheckBoxPreference(screen.context).apply {
|
||||
key = "${COVER_PREF_KEY}_$lang"
|
||||
title = COVER_PREF_TITLE
|
||||
summary = COVER_PREF_SUMMARY
|
||||
setDefaultValue(COVER_PREF_DEFAULT_VALUE)
|
||||
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
val checkValue = newValue as Boolean
|
||||
preferences.edit().putBoolean("${COVER_PREF_KEY}_$lang", checkValue).commit()
|
||||
}
|
||||
}
|
||||
|
||||
screen.addPreference(webpPref)
|
||||
screen.addPreference(coverPref)
|
||||
}
|
||||
|
||||
private fun hitomiAlwaysWebp(): Boolean = preferences.getBoolean("${WEBP_PREF_KEY}_$lang", WEBP_PREF_DEFAULT_VALUE)
|
||||
private fun useHqThumbPref(): Boolean = preferences.getBoolean("${COVER_PREF_KEY}_$lang", COVER_PREF_DEFAULT_VALUE)
|
||||
}
|
@ -1,38 +0,0 @@
|
||||
package eu.kanade.tachiyomi.extension.all.hitomi
|
||||
|
||||
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://hitomi.la/cg/xxxx intents
|
||||
* and redirects them to the main Tachiyomi process.
|
||||
*/
|
||||
class HitomiActivity : Activity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val pathSegments = intent?.data?.pathSegments
|
||||
if (pathSegments != null && pathSegments.size > 1) {
|
||||
val id = pathSegments[1]
|
||||
val mainIntent = Intent().apply {
|
||||
action = "eu.kanade.tachiyomi.SEARCH"
|
||||
putExtra("query", "${Hitomi.PREFIX_ID_SEARCH}$id")
|
||||
putExtra("filter", packageName)
|
||||
}
|
||||
|
||||
try {
|
||||
startActivity(mainIntent)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
Log.e("HitomiActivity", e.toString())
|
||||
}
|
||||
} else {
|
||||
Log.e("HitomiActivity", "Could not parse URI from intent $intent")
|
||||
}
|
||||
|
||||
finish()
|
||||
exitProcess(0)
|
||||
}
|
||||
}
|
@ -1,52 +0,0 @@
|
||||
package eu.kanade.tachiyomi.extension.all.hitomi
|
||||
|
||||
import eu.kanade.tachiyomi.annotations.Nsfw
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.source.SourceFactory
|
||||
|
||||
@Nsfw
|
||||
class HitomiFactory : SourceFactory {
|
||||
override fun createSources(): List<Source> = languageList
|
||||
.map { Hitomi(it.first, it.second) }
|
||||
}
|
||||
|
||||
private val languageList = listOf(
|
||||
Pair("other", "all"), // all languages
|
||||
Pair("id", "indonesian"),
|
||||
Pair("ca", "catalan"),
|
||||
Pair("ceb", "cebuano"),
|
||||
Pair("cs", "czech"),
|
||||
Pair("da", "danish"),
|
||||
Pair("de", "german"),
|
||||
Pair("et", "estonian"),
|
||||
Pair("en", "english"),
|
||||
Pair("es", "spanish"),
|
||||
Pair("eo", "esperanto"),
|
||||
Pair("fr", "french"),
|
||||
Pair("it", "italian"),
|
||||
Pair("la", "latin"),
|
||||
Pair("hu", "hungarian"),
|
||||
Pair("nl", "dutch"),
|
||||
Pair("no", "norwegian"),
|
||||
Pair("pl", "polish"),
|
||||
Pair("pt-BR", "portuguese"),
|
||||
Pair("ro", "romanian"),
|
||||
Pair("sq", "albanian"),
|
||||
Pair("sk", "slovak"),
|
||||
Pair("fi", "finnish"),
|
||||
Pair("sv", "swedish"),
|
||||
Pair("tl", "tagalog"),
|
||||
Pair("vi", "vietnamese"),
|
||||
Pair("tr", "turkish"),
|
||||
Pair("el", "greek"),
|
||||
Pair("mn", "mongolian"),
|
||||
Pair("ru", "russian"),
|
||||
Pair("uk", "ukrainian"),
|
||||
Pair("he", "hebrew"),
|
||||
Pair("ar", "arabic"),
|
||||
Pair("fa", "persian"),
|
||||
Pair("th", "thai"),
|
||||
Pair("ko", "korean"),
|
||||
Pair("zh", "chinese"),
|
||||
Pair("ja", "japanese")
|
||||
)
|
@ -1,257 +0,0 @@
|
||||
package eu.kanade.tachiyomi.extension.all.hitomi
|
||||
|
||||
import eu.kanade.tachiyomi.extension.all.hitomi.Hitomi.Companion.LTN_BASE_URL
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.asObservable
|
||||
import eu.kanade.tachiyomi.network.asObservableSuccess
|
||||
import okhttp3.Headers
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import rx.Observable
|
||||
import rx.Single
|
||||
import java.security.MessageDigest
|
||||
|
||||
private typealias HashedTerm = ByteArray
|
||||
|
||||
private data class DataPair(val offset: Long, val length: Int)
|
||||
private data class Node(
|
||||
val keys: List<ByteArray>,
|
||||
val datas: List<DataPair>,
|
||||
val subnodeAddresses: List<Long>
|
||||
)
|
||||
|
||||
/**
|
||||
* Kotlin port of the hitomi.la search algorithm
|
||||
* @author NerdNumber9
|
||||
*/
|
||||
class HitomiNozomi(
|
||||
private val client: OkHttpClient,
|
||||
private val tagIndexVersion: Long,
|
||||
private val galleriesIndexVersion: Long
|
||||
) {
|
||||
fun getGalleryIdsForQuery(query: String): Single<List<Int>> {
|
||||
val replacedQuery = query.replace('_', ' ')
|
||||
|
||||
if (':' in replacedQuery) {
|
||||
val sides = replacedQuery.split(':')
|
||||
val namespace = sides[0]
|
||||
var tag = sides[1]
|
||||
|
||||
var area: String? = namespace
|
||||
var language = "all"
|
||||
if (namespace == "female" || namespace == "male") {
|
||||
area = "tag"
|
||||
tag = replacedQuery
|
||||
} else if (namespace == "language") {
|
||||
area = null
|
||||
language = tag
|
||||
tag = "index"
|
||||
}
|
||||
|
||||
return getGalleryIdsFromNozomi(area, tag, language)
|
||||
}
|
||||
|
||||
val key = hashTerm(query)
|
||||
val field = "galleries"
|
||||
|
||||
return getNodeAtAddress(field, 0).flatMap { node ->
|
||||
if (node == null) {
|
||||
Single.just(null)
|
||||
} else {
|
||||
BSearch(field, key, node).flatMap { data ->
|
||||
if (data == null) {
|
||||
Single.just(null)
|
||||
} else {
|
||||
getGalleryIdsFromData(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getGalleryIdsFromData(data: DataPair?): Single<List<Int>> {
|
||||
if (data == null) {
|
||||
return Single.just(emptyList())
|
||||
}
|
||||
|
||||
val url = "$LTN_BASE_URL/$GALLERIES_INDEX_DIR/galleries.$galleriesIndexVersion.data"
|
||||
val (offset, length) = data
|
||||
if (length > 100000000 || length <= 0) {
|
||||
return Single.just(emptyList())
|
||||
}
|
||||
|
||||
return client.newCall(rangedGet(url, offset, offset + length - 1))
|
||||
.asObservable()
|
||||
.map {
|
||||
it.body()?.bytes() ?: ByteArray(0)
|
||||
}
|
||||
.onErrorReturn { ByteArray(0) }
|
||||
.map { inbuf ->
|
||||
if (inbuf.isEmpty()) {
|
||||
return@map emptyList<Int>()
|
||||
}
|
||||
|
||||
val view = ByteCursor(inbuf)
|
||||
val numberOfGalleryIds = view.nextInt()
|
||||
|
||||
val expectedLength = numberOfGalleryIds * 4 + 4
|
||||
|
||||
if (numberOfGalleryIds > 10000000 ||
|
||||
numberOfGalleryIds <= 0 ||
|
||||
inbuf.size != expectedLength
|
||||
) {
|
||||
return@map emptyList<Int>()
|
||||
}
|
||||
|
||||
(1..numberOfGalleryIds).map {
|
||||
view.nextInt()
|
||||
}
|
||||
}.toSingle()
|
||||
}
|
||||
|
||||
@Suppress("FunctionName")
|
||||
private fun BSearch(field: String, key: ByteArray, node: Node?): Single<DataPair?> {
|
||||
fun compareByteArrays(dv1: ByteArray, dv2: ByteArray): Int {
|
||||
val top = dv1.size.coerceAtMost(dv2.size)
|
||||
for (i in 0 until top) {
|
||||
val dv1i = dv1[i].toInt() and 0xFF
|
||||
val dv2i = dv2[i].toInt() and 0xFF
|
||||
if (dv1i < dv2i) {
|
||||
return -1
|
||||
} else if (dv1i > dv2i) {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
fun locateKey(key: ByteArray, node: Node): Pair<Boolean, Int> {
|
||||
var cmpResult = -1
|
||||
var lastI = 0
|
||||
for (nodeKey in node.keys) {
|
||||
cmpResult = compareByteArrays(key, nodeKey)
|
||||
if (cmpResult <= 0) break
|
||||
lastI++
|
||||
}
|
||||
return (cmpResult == 0) to lastI
|
||||
}
|
||||
|
||||
fun isLeaf(node: Node): Boolean {
|
||||
return !node.subnodeAddresses.any {
|
||||
it != 0L
|
||||
}
|
||||
}
|
||||
|
||||
if (node == null || node.keys.isEmpty()) {
|
||||
return Single.just(null)
|
||||
}
|
||||
|
||||
val (there, where) = locateKey(key, node)
|
||||
if (there) {
|
||||
return Single.just(node.datas[where])
|
||||
} else if (isLeaf(node)) {
|
||||
return Single.just(null)
|
||||
}
|
||||
|
||||
return getNodeAtAddress(field, node.subnodeAddresses[where]).flatMap { newNode ->
|
||||
BSearch(field, key, newNode)
|
||||
}
|
||||
}
|
||||
|
||||
private fun decodeNode(data: ByteArray): Node {
|
||||
val view = ByteCursor(data)
|
||||
|
||||
val numberOfKeys = view.nextInt()
|
||||
|
||||
val keys = (1..numberOfKeys).map {
|
||||
val keySize = view.nextInt()
|
||||
view.next(keySize)
|
||||
}
|
||||
|
||||
val numberOfDatas = view.nextInt()
|
||||
val datas = (1..numberOfDatas).map {
|
||||
val offset = view.nextLong()
|
||||
val length = view.nextInt()
|
||||
DataPair(offset, length)
|
||||
}
|
||||
|
||||
val numberOfSubnodeAddresses = B + 1
|
||||
val subnodeAddresses = (1..numberOfSubnodeAddresses).map {
|
||||
view.nextLong()
|
||||
}
|
||||
|
||||
return Node(keys, datas, subnodeAddresses)
|
||||
}
|
||||
|
||||
private fun getNodeAtAddress(field: String, address: Long): Single<Node?> {
|
||||
var url = "$LTN_BASE_URL/$INDEX_DIR/$field.$tagIndexVersion.index"
|
||||
if (field == "galleries") {
|
||||
url = "$LTN_BASE_URL/$GALLERIES_INDEX_DIR/galleries.$galleriesIndexVersion.index"
|
||||
}
|
||||
|
||||
return client.newCall(rangedGet(url, address, address + MAX_NODE_SIZE - 1))
|
||||
.asObservableSuccess()
|
||||
.map {
|
||||
it.body()?.bytes() ?: ByteArray(0)
|
||||
}
|
||||
.onErrorReturn { ByteArray(0) }
|
||||
.map { nodedata ->
|
||||
if (nodedata.isNotEmpty()) {
|
||||
decodeNode(nodedata)
|
||||
} else null
|
||||
}.toSingle()
|
||||
}
|
||||
|
||||
fun getGalleryIdsFromNozomi(area: String?, tag: String, language: String): Single<List<Int>> {
|
||||
var nozomiAddress = "$LTN_BASE_URL/$COMPRESSED_NOZOMI_PREFIX/$tag-$language$NOZOMI_EXTENSION"
|
||||
if (area != null) {
|
||||
nozomiAddress = "$LTN_BASE_URL/$COMPRESSED_NOZOMI_PREFIX/$area/$tag-$language$NOZOMI_EXTENSION"
|
||||
}
|
||||
|
||||
return client.newCall(
|
||||
Request.Builder()
|
||||
.url(nozomiAddress)
|
||||
.build()
|
||||
)
|
||||
.asObservableSuccess()
|
||||
.map { resp ->
|
||||
val body = resp.body()!!.bytes()
|
||||
val cursor = ByteCursor(body)
|
||||
(1..body.size / 4).map {
|
||||
cursor.nextInt()
|
||||
}
|
||||
}.toSingle()
|
||||
}
|
||||
|
||||
private fun hashTerm(query: String): HashedTerm {
|
||||
val md = MessageDigest.getInstance("SHA-256")
|
||||
md.update(query.toByteArray(HASH_CHARSET))
|
||||
return md.digest().copyOf(4)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val INDEX_DIR = "tagindex"
|
||||
private const val GALLERIES_INDEX_DIR = "galleriesindex"
|
||||
private const val COMPRESSED_NOZOMI_PREFIX = "n"
|
||||
private const val NOZOMI_EXTENSION = ".nozomi"
|
||||
private const val MAX_NODE_SIZE = 464
|
||||
private const val B = 16
|
||||
|
||||
private val HASH_CHARSET = Charsets.UTF_8
|
||||
|
||||
fun rangedGet(url: String, rangeBegin: Long, rangeEnd: Long?): Request {
|
||||
return GET(
|
||||
url,
|
||||
Headers.Builder()
|
||||
.add("Range", "bytes=$rangeBegin-${rangeEnd ?: ""}")
|
||||
.build()
|
||||
)
|
||||
}
|
||||
|
||||
fun getIndexVersion(httpClient: OkHttpClient, name: String): Observable<Long> {
|
||||
return httpClient.newCall(GET("$LTN_BASE_URL/$name/version?_=${System.currentTimeMillis()}"))
|
||||
.asObservableSuccess()
|
||||
.map { it.body()!!.string().toLong() }
|
||||
}
|
||||
}
|
||||
}
|
@ -1,2 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest package="eu.kanade.tachiyomi.extension" />
|
@ -1,13 +0,0 @@
|
||||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'kotlin-android'
|
||||
|
||||
ext {
|
||||
extName = 'IMHentai'
|
||||
pkgNameSuffix = 'all.imhentai'
|
||||
extClass = '.IMHentaiFactory'
|
||||
extVersionCode = 2
|
||||
libVersion = '1.2'
|
||||
containsNsfw = true
|
||||
}
|
||||
|
||||
apply from: "$rootDir/common.gradle"
|
Before Width: | Height: | Size: 4.9 KiB |
Before Width: | Height: | Size: 2.5 KiB |
Before Width: | Height: | Size: 7.7 KiB |
Before Width: | Height: | Size: 15 KiB |
Before Width: | Height: | Size: 24 KiB |
Before Width: | Height: | Size: 175 KiB |
@ -1,267 +0,0 @@
|
||||
package eu.kanade.tachiyomi.extension.all.imhentai
|
||||
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.POST
|
||||
import eu.kanade.tachiyomi.network.asObservableSuccess
|
||||
import eu.kanade.tachiyomi.source.model.Filter
|
||||
import eu.kanade.tachiyomi.source.model.FilterList
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import eu.kanade.tachiyomi.source.online.ParsedHttpSource
|
||||
import eu.kanade.tachiyomi.util.asJsoup
|
||||
import okhttp3.Headers
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.MediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody
|
||||
import okhttp3.Response
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
import org.jsoup.select.Elements
|
||||
import rx.Observable
|
||||
|
||||
class IMHentai(override val lang: String, private val imhLang: String) : ParsedHttpSource() {
|
||||
|
||||
private val pageLoadHeaders: Headers = Headers.Builder().apply {
|
||||
add("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8")
|
||||
add("X-Requested-With", "XMLHttpRequest")
|
||||
}.build()
|
||||
|
||||
override val baseUrl: String = "https://imhentai.xxx"
|
||||
override val name: String = "IMHentai"
|
||||
override val supportsLatest = true
|
||||
|
||||
override val client: OkHttpClient = network.cloudflareClient
|
||||
|
||||
// Popular
|
||||
|
||||
override fun popularMangaFromElement(element: Element): SManga {
|
||||
return SManga.create().apply {
|
||||
thumbnail_url = element.select(".inner_thumb img").attr("src")
|
||||
with(element.select(".caption a")) {
|
||||
url = this.attr("href")
|
||||
title = this.text()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun popularMangaNextPageSelector(): String = ".pagination li a:contains(Next):not([tabindex])"
|
||||
|
||||
override fun popularMangaSelector(): String = ".thumbs_container .thumb"
|
||||
|
||||
override fun popularMangaRequest(page: Int): Request = searchMangaRequest(page, "", getFilterList(SORT_ORDER_POPULAR))
|
||||
|
||||
// Latest
|
||||
|
||||
override fun latestUpdatesFromElement(element: Element): SManga = popularMangaFromElement(element)
|
||||
|
||||
override fun latestUpdatesNextPageSelector(): String = popularMangaNextPageSelector()
|
||||
|
||||
override fun latestUpdatesRequest(page: Int): Request = searchMangaRequest(page, "", getFilterList(SORT_ORDER_LATEST))
|
||||
|
||||
override fun latestUpdatesSelector(): String = popularMangaSelector()
|
||||
|
||||
// Search
|
||||
|
||||
override fun searchMangaFromElement(element: Element): SManga = popularMangaFromElement(element)
|
||||
|
||||
override fun searchMangaNextPageSelector(): String = popularMangaNextPageSelector()
|
||||
|
||||
private fun toBinary(boolean: Boolean) = if (boolean) "1" else "0"
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
val url = HttpUrl.parse("$baseUrl/search")!!.newBuilder()
|
||||
.addQueryParameter("key", query)
|
||||
.addQueryParameter("page", page.toString())
|
||||
.addQueryParameter(getLanguageURIByName(imhLang).uri, toBinary(true)) // main language always enabled
|
||||
|
||||
(if (filters.isEmpty()) getFilterList() else filters).forEach { filter ->
|
||||
when (filter) {
|
||||
is LanguageFilters -> {
|
||||
filter.state.forEach {
|
||||
url.addQueryParameter(it.uri, toBinary(it.state))
|
||||
}
|
||||
}
|
||||
is CategoryFilters -> {
|
||||
filter.state.forEach {
|
||||
url.addQueryParameter(it.uri, toBinary(it.state))
|
||||
}
|
||||
}
|
||||
is SortOrderFilter -> {
|
||||
getSortOrderURIs().forEachIndexed { index, pair ->
|
||||
url.addQueryParameter(pair.second, toBinary(filter.state == index))
|
||||
}
|
||||
}
|
||||
else -> { }
|
||||
}
|
||||
}
|
||||
|
||||
return GET(url.toString())
|
||||
}
|
||||
|
||||
override fun searchMangaSelector(): String = popularMangaSelector()
|
||||
|
||||
// Details
|
||||
|
||||
private fun Elements.csvText(splitTagSeparator: String = ", "): String {
|
||||
return this.joinToString {
|
||||
listOf(
|
||||
it.ownText(),
|
||||
it.select(".split_tag")?.text()
|
||||
?.trim()
|
||||
?.removePrefix("| ")
|
||||
)
|
||||
.filter { s -> !s.isNullOrBlank() }
|
||||
.joinToString(splitTagSeparator)
|
||||
}
|
||||
}
|
||||
|
||||
override fun mangaDetailsParse(document: Document): SManga = SManga.create().apply {
|
||||
val mangaInfoElement = document.select(".galleries_info")
|
||||
val infoMap = mangaInfoElement.select("li:not(.pages)").map {
|
||||
it.select("span.tags_text").text().removeSuffix(":") to it.select(".tag")
|
||||
}.toMap()
|
||||
|
||||
artist = infoMap["Artists"]?.csvText(" | ")
|
||||
|
||||
author = artist
|
||||
|
||||
genre = infoMap["Tags"]?.csvText()
|
||||
|
||||
status = SManga.COMPLETED
|
||||
|
||||
val pages = mangaInfoElement.select("li.pages").text().substringAfter("Pages: ")
|
||||
val altTitle = document.select(".subtitle").text().ifBlank { null }
|
||||
|
||||
description = listOf(
|
||||
"Parodies",
|
||||
"Characters",
|
||||
"Groups",
|
||||
"Languages",
|
||||
"Category"
|
||||
).map { it to infoMap[it]?.csvText() }
|
||||
.let { listOf(Pair("Alternate Title", altTitle)) + it + listOf(Pair("Pages", pages)) }
|
||||
.filter { !it.second.isNullOrEmpty() }
|
||||
.joinToString("\n\n") { "${it.first}:\n${it.second}" }
|
||||
}
|
||||
|
||||
// Chapters
|
||||
|
||||
private fun pageLoadMetaParse(document: Document): String {
|
||||
return document.select(".gallery_divider ~ input[type=\"hidden\"]").map { m ->
|
||||
m.attr("id") to m.attr("value")
|
||||
}.toMap().let {
|
||||
listOf(
|
||||
Pair("server", "load_server"),
|
||||
Pair("u_id", "gallery_id"),
|
||||
Pair("g_id", "load_id"),
|
||||
Pair("img_dir", "load_dir"),
|
||||
Pair("total_pages", "load_pages")
|
||||
).map { meta -> "${meta.first}=${it[meta.second]}" }
|
||||
.let { payload -> payload + listOf("type=2", "visible_pages=0") }
|
||||
.joinToString("&")
|
||||
}
|
||||
}
|
||||
|
||||
override fun chapterListParse(response: Response): List<SChapter> {
|
||||
return listOf(
|
||||
SChapter.create().apply {
|
||||
setUrlWithoutDomain(response.request().url().toString())
|
||||
name = "Chapter"
|
||||
chapter_number = 1f
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
override fun chapterFromElement(element: Element): SChapter = throw UnsupportedOperationException("Not used")
|
||||
|
||||
override fun chapterListSelector(): String = throw UnsupportedOperationException("Not used")
|
||||
|
||||
// Pages
|
||||
|
||||
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
|
||||
return client.newCall(GET("$baseUrl${chapter.url}"))
|
||||
.asObservableSuccess()
|
||||
.map { pageLoadMetaParse(it.asJsoup()) }
|
||||
.map { RequestBody.create(MediaType.parse("application/x-www-form-urlencoded; charset=UTF-8"), it) }
|
||||
.concatMap { client.newCall(POST(PAEG_LOAD_URL, pageLoadHeaders, it)).asObservableSuccess() }
|
||||
.map { pageListParse(it) }
|
||||
}
|
||||
|
||||
override fun pageListParse(document: Document): List<Page> {
|
||||
return document.select("a").mapIndexed { i, element ->
|
||||
Page(i, element.attr("href"), element.select(".lazy.preloader[src]").attr("src").replace("t.", "."))
|
||||
}
|
||||
}
|
||||
|
||||
override fun imageUrlParse(document: Document): String = throw UnsupportedOperationException("Not used")
|
||||
|
||||
// Filters
|
||||
|
||||
private class SortOrderFilter(sortOrderURIs: List<Pair<String, String>>, state: Int) :
|
||||
Filter.Select<String>("Sort By", sortOrderURIs.map { it.first }.toTypedArray(), state)
|
||||
private open class SearchFlagFilter(name: String, val uri: String, state: Boolean = true) : Filter.CheckBox(name, state)
|
||||
private class LanguageFilter(name: String, uri: String = name) : SearchFlagFilter(name, uri, false)
|
||||
private class LanguageFilters(flags: List<LanguageFilter>) : Filter.Group<LanguageFilter>("Other Languages", flags)
|
||||
private class CategoryFilters(flags: List<SearchFlagFilter>) : Filter.Group<SearchFlagFilter>("Categories", flags)
|
||||
|
||||
override fun getFilterList() = getFilterList(SORT_ORDER_DEFAULT)
|
||||
|
||||
private fun getFilterList(sortOrderState: Int) = FilterList(
|
||||
SortOrderFilter(getSortOrderURIs(), sortOrderState),
|
||||
CategoryFilters(getCategoryURIs()),
|
||||
LanguageFilters(getLanguageURIs().filter { it.name != imhLang }) // exclude main lang
|
||||
)
|
||||
|
||||
private fun getCategoryURIs() = listOf(
|
||||
SearchFlagFilter("Manga", "manga"),
|
||||
SearchFlagFilter("Doujinshi", "doujinshi"),
|
||||
SearchFlagFilter("Western", "western"),
|
||||
SearchFlagFilter("Image Set", "imageset"),
|
||||
SearchFlagFilter("Artist CG", "artistcg"),
|
||||
SearchFlagFilter("Game CG", "gamecg")
|
||||
)
|
||||
|
||||
// update sort order indices in companion object if order is changed
|
||||
private fun getSortOrderURIs() = listOf(
|
||||
Pair("Popular", "pp"),
|
||||
Pair("Latest", "lt"),
|
||||
Pair("Downloads", "dl"),
|
||||
Pair("Top Rated", "tr")
|
||||
)
|
||||
|
||||
private fun getLanguageURIs() = listOf(
|
||||
LanguageFilter(LANGUAGE_ENGLISH, "en"),
|
||||
LanguageFilter(LANGUAGE_JAPANESE, "jp"),
|
||||
LanguageFilter(LANGUAGE_SPANISH, "es"),
|
||||
LanguageFilter(LANGUAGE_FRENCH, "fr"),
|
||||
LanguageFilter(LANGUAGE_KOREAN, "kr"),
|
||||
LanguageFilter(LANGUAGE_GERMAN, "de"),
|
||||
LanguageFilter(LANGUAGE_RUSSIAN, "ru")
|
||||
)
|
||||
|
||||
private fun getLanguageURIByName(name: String): LanguageFilter {
|
||||
return getLanguageURIs().first { it.name == name }
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
// references to sort order indices
|
||||
private const val SORT_ORDER_POPULAR = 0
|
||||
private const val SORT_ORDER_LATEST = 1
|
||||
private const val SORT_ORDER_DEFAULT = SORT_ORDER_POPULAR
|
||||
|
||||
// references to be used in factory
|
||||
const val LANGUAGE_ENGLISH = "English"
|
||||
const val LANGUAGE_JAPANESE = "Japanese"
|
||||
const val LANGUAGE_SPANISH = "Spanish"
|
||||
const val LANGUAGE_FRENCH = "French"
|
||||
const val LANGUAGE_KOREAN = "Korean"
|
||||
const val LANGUAGE_GERMAN = "German"
|
||||
const val LANGUAGE_RUSSIAN = "Russian"
|
||||
|
||||
private const val PAEG_LOAD_URL: String = "https://imhentai.xxx/inc/thumbs_loader.php"
|
||||
}
|
||||
}
|
@ -1,19 +0,0 @@
|
||||
package eu.kanade.tachiyomi.extension.all.imhentai
|
||||
|
||||
import eu.kanade.tachiyomi.annotations.Nsfw
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.source.SourceFactory
|
||||
|
||||
@Nsfw
|
||||
class IMHentaiFactory : SourceFactory {
|
||||
|
||||
override fun createSources(): List<Source> = listOf(
|
||||
IMHentai("en", IMHentai.LANGUAGE_ENGLISH),
|
||||
IMHentai("ja", IMHentai.LANGUAGE_JAPANESE),
|
||||
IMHentai("es", IMHentai.LANGUAGE_SPANISH),
|
||||
IMHentai("fr", IMHentai.LANGUAGE_FRENCH),
|
||||
IMHentai("ko", IMHentai.LANGUAGE_KOREAN),
|
||||
IMHentai("de", IMHentai.LANGUAGE_GERMAN),
|
||||
IMHentai("ru", IMHentai.LANGUAGE_RUSSIAN)
|
||||
)
|
||||
}
|
@ -1,2 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest package="eu.kanade.tachiyomi.extension" />
|
@ -1,175 +0,0 @@
|
||||
## 1.2.23
|
||||
|
||||
Minimum Komga version required: `0.75.0`
|
||||
|
||||
### Features
|
||||
|
||||
* ignore DNS over HTTPS so it can reach IP addresses
|
||||
|
||||
## 1.2.22
|
||||
|
||||
Minimum Komga version required: `0.75.0`
|
||||
|
||||
### Features
|
||||
|
||||
* add error logs and better catch exceptions
|
||||
|
||||
## 1.2.21
|
||||
|
||||
Minimum Komga version required: `0.75.0`
|
||||
|
||||
### Features
|
||||
|
||||
* browse read lists (from the filter menu)
|
||||
* filter by collection, respecting the collection's ordering
|
||||
|
||||
## 1.2.20
|
||||
|
||||
Minimum Komga version required: `0.75.0`
|
||||
|
||||
### Features
|
||||
|
||||
* filter by authors, grouped by role
|
||||
|
||||
## 1.2.19
|
||||
|
||||
Minimum Komga version required: `0.68.0`
|
||||
|
||||
### Features
|
||||
|
||||
* display Series authors
|
||||
* display Series summary from books if no summary exists for Series
|
||||
|
||||
## 1.2.18
|
||||
|
||||
Minimum Komga version required: `0.63.2`
|
||||
|
||||
### Fix
|
||||
|
||||
* use metadata.releaseDate or fileLastModified for chapter date
|
||||
|
||||
## 1.2.17
|
||||
|
||||
Minimum Komga version required: `0.63.2`
|
||||
|
||||
### Fix
|
||||
|
||||
* list of collections for filtering could be empty in some conditions
|
||||
|
||||
## 1.2.16
|
||||
|
||||
Minimum Komga version required: `0.59.0`
|
||||
|
||||
### Features
|
||||
|
||||
* filter by genres, tags and publishers
|
||||
|
||||
## 1.2.15
|
||||
|
||||
Minimum Komga version required: `0.56.0`
|
||||
|
||||
### Features
|
||||
|
||||
* remove the 1000 chapters limit
|
||||
* display series description and tags (genres + tags)
|
||||
|
||||
## 1.2.14
|
||||
|
||||
Minimum Komga version required: `0.41.0`
|
||||
|
||||
### Features
|
||||
|
||||
* change chapter display name to use the display number instead of the sort number
|
||||
|
||||
## 1.2.13
|
||||
|
||||
Minimum Komga version required: `0.41.0`
|
||||
|
||||
### Features
|
||||
|
||||
* compatibility for the upcoming version of Komga which have changes in the API (IDs are String instead of Long)
|
||||
|
||||
## 1.2.12
|
||||
|
||||
Minimum Komga version required: `0.41.0`
|
||||
|
||||
### Features
|
||||
|
||||
* filter by collection
|
||||
|
||||
## 1.2.11
|
||||
|
||||
Minimum Komga version required: `0.35.2`
|
||||
|
||||
### Features
|
||||
|
||||
* Set password preferences inputTypes
|
||||
|
||||
## 1.2.10
|
||||
|
||||
Minimum Komga version required: `0.35.2`
|
||||
|
||||
### Features
|
||||
|
||||
* unread only filter (closes gotson/komga#180)
|
||||
* prefix book titles with number (closes gotson/komga#169)
|
||||
|
||||
## 1.2.9
|
||||
|
||||
Minimum Komga version required: `0.22.0`
|
||||
|
||||
### Features
|
||||
|
||||
* use SourceFactory to have multiple Komga servers (3 for the moment)
|
||||
|
||||
## 1.2.8
|
||||
|
||||
Minimum Komga version required: `0.22.0`
|
||||
|
||||
### Features
|
||||
|
||||
* use book metadata title for chapter display name
|
||||
* use book metadata sort number for chapter number
|
||||
|
||||
## 1.2.7
|
||||
|
||||
### Features
|
||||
|
||||
* use series metadata title for display name
|
||||
* filter on series status
|
||||
|
||||
## 1.2.6
|
||||
|
||||
### Features
|
||||
|
||||
* Add support for AndroidX preferences
|
||||
|
||||
## 1.2.5
|
||||
|
||||
### Features
|
||||
|
||||
* add sort options in filter
|
||||
|
||||
## 1.2.4
|
||||
|
||||
### Features
|
||||
|
||||
* better handling of authentication
|
||||
|
||||
## 1.2.3
|
||||
|
||||
### Features
|
||||
|
||||
* filters by library
|
||||
|
||||
## 1.2.2
|
||||
|
||||
### Features
|
||||
|
||||
* request converted image from server if format is not supported
|
||||
|
||||
## 1.2.1
|
||||
|
||||
### Features
|
||||
|
||||
* first version
|
@ -1,17 +0,0 @@
|
||||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'kotlin-android'
|
||||
|
||||
ext {
|
||||
extName = 'Komga'
|
||||
pkgNameSuffix = 'all.komga'
|
||||
extClass = '.KomgaFactory'
|
||||
extVersionCode = 23
|
||||
libVersion = '1.2'
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'io.reactivex:rxandroid:1.2.1'
|
||||
implementation 'io.reactivex:rxjava:1.3.8'
|
||||
}
|
||||
|
||||
apply from: "$rootDir/common.gradle"
|
Before Width: | Height: | Size: 5.9 KiB |
Before Width: | Height: | Size: 3.0 KiB |
Before Width: | Height: | Size: 8.0 KiB |
Before Width: | Height: | Size: 16 KiB |
Before Width: | Height: | Size: 22 KiB |
Before Width: | Height: | Size: 119 KiB |
@ -1,559 +0,0 @@
|
||||
package eu.kanade.tachiyomi.extension.all.komga
|
||||
|
||||
import android.app.Application
|
||||
import android.content.SharedPreferences
|
||||
import android.support.v7.preference.EditTextPreference
|
||||
import android.support.v7.preference.PreferenceScreen
|
||||
import android.text.InputType
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import com.github.salomonbrys.kotson.fromJson
|
||||
import com.google.gson.Gson
|
||||
import eu.kanade.tachiyomi.extension.BuildConfig
|
||||
import eu.kanade.tachiyomi.extension.all.komga.dto.AuthorDto
|
||||
import eu.kanade.tachiyomi.extension.all.komga.dto.BookDto
|
||||
import eu.kanade.tachiyomi.extension.all.komga.dto.CollectionDto
|
||||
import eu.kanade.tachiyomi.extension.all.komga.dto.LibraryDto
|
||||
import eu.kanade.tachiyomi.extension.all.komga.dto.PageDto
|
||||
import eu.kanade.tachiyomi.extension.all.komga.dto.PageWrapperDto
|
||||
import eu.kanade.tachiyomi.extension.all.komga.dto.ReadListDto
|
||||
import eu.kanade.tachiyomi.extension.all.komga.dto.SeriesDto
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.source.ConfigurableSource
|
||||
import eu.kanade.tachiyomi.source.model.Filter
|
||||
import eu.kanade.tachiyomi.source.model.FilterList
|
||||
import eu.kanade.tachiyomi.source.model.MangasPage
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
import okhttp3.Credentials
|
||||
import okhttp3.Dns
|
||||
import okhttp3.Headers
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import rx.Single
|
||||
import rx.android.schedulers.AndroidSchedulers
|
||||
import rx.schedulers.Schedulers
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
open class Komga(suffix: String = "") : ConfigurableSource, HttpSource() {
|
||||
override fun popularMangaRequest(page: Int): Request =
|
||||
GET("$baseUrl/api/v1/series?page=${page - 1}", headers)
|
||||
|
||||
override fun popularMangaParse(response: Response): MangasPage =
|
||||
processSeriesPage(response)
|
||||
|
||||
override fun latestUpdatesRequest(page: Int): Request =
|
||||
GET("$baseUrl/api/v1/series/latest?page=${page - 1}", headers)
|
||||
|
||||
override fun latestUpdatesParse(response: Response): MangasPage =
|
||||
processSeriesPage(response)
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
val collectionId = (filters.find { it is CollectionSelect } as? CollectionSelect)?.let {
|
||||
it.values[it.state].id
|
||||
}
|
||||
|
||||
val type = when {
|
||||
collectionId != null -> "collections/$collectionId/series"
|
||||
filters.find { it is TypeSelect }?.state == 1 -> "readlists"
|
||||
else -> "series"
|
||||
}
|
||||
|
||||
val url = HttpUrl.parse("$baseUrl/api/v1/$type?search=$query&page=${page - 1}")!!.newBuilder()
|
||||
|
||||
filters.forEach { filter ->
|
||||
when (filter) {
|
||||
is UnreadOnly -> {
|
||||
if (filter.state) {
|
||||
url.addQueryParameter("read_status", "UNREAD")
|
||||
}
|
||||
}
|
||||
is LibraryGroup -> {
|
||||
val libraryToInclude = mutableListOf<String>()
|
||||
filter.state.forEach { content ->
|
||||
if (content.state) {
|
||||
libraryToInclude.add(content.id)
|
||||
}
|
||||
}
|
||||
if (libraryToInclude.isNotEmpty()) {
|
||||
url.addQueryParameter("library_id", libraryToInclude.joinToString(","))
|
||||
}
|
||||
}
|
||||
is StatusGroup -> {
|
||||
val statusToInclude = mutableListOf<String>()
|
||||
filter.state.forEach { content ->
|
||||
if (content.state) {
|
||||
statusToInclude.add(content.name.toUpperCase(Locale.ROOT))
|
||||
}
|
||||
}
|
||||
if (statusToInclude.isNotEmpty()) {
|
||||
url.addQueryParameter("status", statusToInclude.joinToString(","))
|
||||
}
|
||||
}
|
||||
is GenreGroup -> {
|
||||
val genreToInclude = mutableListOf<String>()
|
||||
filter.state.forEach { content ->
|
||||
if (content.state) {
|
||||
genreToInclude.add(content.name)
|
||||
}
|
||||
}
|
||||
if (genreToInclude.isNotEmpty()) {
|
||||
url.addQueryParameter("genre", genreToInclude.joinToString(","))
|
||||
}
|
||||
}
|
||||
is TagGroup -> {
|
||||
val tagToInclude = mutableListOf<String>()
|
||||
filter.state.forEach { content ->
|
||||
if (content.state) {
|
||||
tagToInclude.add(content.name)
|
||||
}
|
||||
}
|
||||
if (tagToInclude.isNotEmpty()) {
|
||||
url.addQueryParameter("tag", tagToInclude.joinToString(","))
|
||||
}
|
||||
}
|
||||
is PublisherGroup -> {
|
||||
val publisherToInclude = mutableListOf<String>()
|
||||
filter.state.forEach { content ->
|
||||
if (content.state) {
|
||||
publisherToInclude.add(content.name)
|
||||
}
|
||||
}
|
||||
if (publisherToInclude.isNotEmpty()) {
|
||||
url.addQueryParameter("publisher", publisherToInclude.joinToString(","))
|
||||
}
|
||||
}
|
||||
is AuthorGroup -> {
|
||||
val authorToInclude = mutableListOf<AuthorDto>()
|
||||
filter.state.forEach { content ->
|
||||
if (content.state) {
|
||||
authorToInclude.add(content.author)
|
||||
}
|
||||
}
|
||||
authorToInclude.forEach {
|
||||
url.addQueryParameter("author", "${it.name},${it.role}")
|
||||
}
|
||||
}
|
||||
is Filter.Sort -> {
|
||||
var sortCriteria = when (filter.state?.index) {
|
||||
0 -> "metadata.titleSort"
|
||||
1 -> "createdDate"
|
||||
2 -> "lastModifiedDate"
|
||||
else -> ""
|
||||
}
|
||||
if (sortCriteria.isNotEmpty()) {
|
||||
sortCriteria += "," + if (filter.state?.ascending!!) "asc" else "desc"
|
||||
url.addQueryParameter("sort", sortCriteria)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return GET(url.toString(), headers)
|
||||
}
|
||||
|
||||
override fun searchMangaParse(response: Response): MangasPage =
|
||||
processSeriesPage(response)
|
||||
|
||||
override fun mangaDetailsRequest(manga: SManga): Request =
|
||||
GET(baseUrl + manga.url, headers)
|
||||
|
||||
override fun mangaDetailsParse(response: Response): SManga =
|
||||
if (response.fromReadList()) {
|
||||
val readList = gson.fromJson<ReadListDto>(response.body()?.charStream()!!)
|
||||
readList.toSManga()
|
||||
} else {
|
||||
val series = gson.fromJson<SeriesDto>(response.body()?.charStream()!!)
|
||||
series.toSManga()
|
||||
}
|
||||
|
||||
override fun chapterListRequest(manga: SManga): Request =
|
||||
GET("$baseUrl${manga.url}/books?unpaged=true&media_status=READY", headers)
|
||||
|
||||
override fun chapterListParse(response: Response): List<SChapter> {
|
||||
val page = gson.fromJson<PageWrapperDto<BookDto>>(response.body()?.charStream()!!).content
|
||||
|
||||
val r = page.map { book ->
|
||||
SChapter.create().apply {
|
||||
chapter_number = book.metadata.numberSort
|
||||
name = "${if (!response.fromReadList()) "${book.metadata.number} - " else ""}${book.metadata.title} (${book.size})"
|
||||
url = "$baseUrl/api/v1/books/${book.id}"
|
||||
date_upload = book.metadata.releaseDate?.let { parseDate(it) }
|
||||
?: parseDateTime(book.fileLastModified)
|
||||
}
|
||||
}
|
||||
return if (!response.fromReadList()) r.sortedByDescending { it.chapter_number } else r.reversed()
|
||||
}
|
||||
|
||||
override fun pageListRequest(chapter: SChapter): Request =
|
||||
GET("${chapter.url}/pages")
|
||||
|
||||
override fun pageListParse(response: Response): List<Page> {
|
||||
val pages = gson.fromJson<List<PageDto>>(response.body()?.charStream()!!)
|
||||
return pages.map {
|
||||
val url = "${response.request().url()}/${it.number}" +
|
||||
if (!supportedImageTypes.contains(it.mediaType)) {
|
||||
"?convert=png"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
Page(
|
||||
index = it.number - 1,
|
||||
imageUrl = url
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun processSeriesPage(response: Response): MangasPage {
|
||||
if (response.fromReadList()) {
|
||||
with(gson.fromJson<PageWrapperDto<ReadListDto>>(response.body()?.charStream()!!)) {
|
||||
return MangasPage(content.map { it.toSManga() }, !last)
|
||||
}
|
||||
} else {
|
||||
with(gson.fromJson<PageWrapperDto<SeriesDto>>(response.body()?.charStream()!!)) {
|
||||
return MangasPage(content.map { it.toSManga() }, !last)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun SeriesDto.toSManga(): SManga =
|
||||
SManga.create().apply {
|
||||
title = metadata.title
|
||||
url = "/api/v1/series/$id"
|
||||
thumbnail_url = "$baseUrl/api/v1/series/$id/thumbnail"
|
||||
status = when (metadata.status) {
|
||||
"ONGOING" -> SManga.ONGOING
|
||||
"ENDED" -> SManga.COMPLETED
|
||||
else -> SManga.UNKNOWN
|
||||
}
|
||||
genre = (metadata.genres + metadata.tags).joinToString(", ")
|
||||
description = metadata.summary.ifBlank { booksMetadata.summary }
|
||||
booksMetadata.authors.groupBy { it.role }.let { map ->
|
||||
author = map["writer"]?.map { it.name }?.distinct()?.joinToString()
|
||||
artist = map["penciller"]?.map { it.name }?.distinct()?.joinToString()
|
||||
}
|
||||
}
|
||||
|
||||
private fun ReadListDto.toSManga(): SManga =
|
||||
SManga.create().apply {
|
||||
title = name
|
||||
url = "/api/v1/readlists/$id"
|
||||
thumbnail_url = "$baseUrl/api/v1/readlists/$id/thumbnail"
|
||||
status = SManga.UNKNOWN
|
||||
}
|
||||
|
||||
private fun Response.fromReadList() = request().url().toString().contains("/api/v1/readlists")
|
||||
|
||||
private fun parseDate(date: String?): Long =
|
||||
if (date == null)
|
||||
Date().time
|
||||
else {
|
||||
try {
|
||||
SimpleDateFormat("yyyy-MM-dd", Locale.US).parse(date).time
|
||||
} catch (ex: Exception) {
|
||||
Date().time
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseDateTime(date: String?): Long =
|
||||
if (date == null)
|
||||
Date().time
|
||||
else {
|
||||
try {
|
||||
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US).parse(date).time
|
||||
} catch (ex: Exception) {
|
||||
try {
|
||||
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.S", Locale.US).parse(date).time
|
||||
} catch (ex: Exception) {
|
||||
Date().time
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun imageUrlParse(response: Response): String = ""
|
||||
|
||||
private class TypeSelect : Filter.Select<String>("Search for", arrayOf(TYPE_SERIES, TYPE_READLISTS))
|
||||
private class LibraryFilter(val id: String, name: String) : Filter.CheckBox(name, false)
|
||||
private class LibraryGroup(libraries: List<LibraryFilter>) : Filter.Group<LibraryFilter>("Libraries", libraries)
|
||||
private class CollectionSelect(collections: List<CollectionFilterEntry>) : Filter.Select<CollectionFilterEntry>("Collection", collections.toTypedArray())
|
||||
private class SeriesSort : Filter.Sort("Sort", arrayOf("Alphabetically", "Date added", "Date updated"), Selection(0, true))
|
||||
private class StatusFilter(name: String) : Filter.CheckBox(name, false)
|
||||
private class StatusGroup(filters: List<StatusFilter>) : Filter.Group<StatusFilter>("Status", filters)
|
||||
private class UnreadOnly : Filter.CheckBox("Unread only", false)
|
||||
private class GenreFilter(genre: String) : Filter.CheckBox(genre, false)
|
||||
private class GenreGroup(genres: List<GenreFilter>) : Filter.Group<GenreFilter>("Genres", genres)
|
||||
private class TagFilter(tag: String) : Filter.CheckBox(tag, false)
|
||||
private class TagGroup(tags: List<TagFilter>) : Filter.Group<TagFilter>("Tags", tags)
|
||||
private class PublisherFilter(publisher: String) : Filter.CheckBox(publisher, false)
|
||||
private class PublisherGroup(publishers: List<PublisherFilter>) : Filter.Group<PublisherFilter>("Publishers", publishers)
|
||||
private class AuthorFilter(val author: AuthorDto) : Filter.CheckBox(author.name, false)
|
||||
private class AuthorGroup(role: String, authors: List<AuthorFilter>) : Filter.Group<AuthorFilter>(role, authors)
|
||||
|
||||
private data class CollectionFilterEntry(
|
||||
val name: String,
|
||||
val id: String? = null
|
||||
) {
|
||||
override fun toString() = name
|
||||
}
|
||||
|
||||
override fun getFilterList(): FilterList {
|
||||
val filters = try {
|
||||
mutableListOf<Filter<*>>(
|
||||
UnreadOnly(),
|
||||
TypeSelect(),
|
||||
CollectionSelect(listOf(CollectionFilterEntry("None")) + collections.map { CollectionFilterEntry(it.name, it.id) }),
|
||||
LibraryGroup(libraries.map { LibraryFilter(it.id, it.name) }.sortedBy { it.name.toLowerCase() }),
|
||||
StatusGroup(listOf("Ongoing", "Ended", "Abandoned", "Hiatus").map { StatusFilter(it) }),
|
||||
GenreGroup(genres.map { GenreFilter(it) }),
|
||||
TagGroup(tags.map { TagFilter(it) }),
|
||||
PublisherGroup(publishers.map { PublisherFilter(it) })
|
||||
).also {
|
||||
it.addAll(authors.map { (role, authors) -> AuthorGroup(role, authors.map { AuthorFilter(it) }) })
|
||||
it.add(SeriesSort())
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(LOG_TAG, "error while creating filter list", e)
|
||||
emptyList()
|
||||
}
|
||||
|
||||
return FilterList(filters)
|
||||
}
|
||||
|
||||
private var libraries = emptyList<LibraryDto>()
|
||||
private var collections = emptyList<CollectionDto>()
|
||||
private var genres = emptySet<String>()
|
||||
private var tags = emptySet<String>()
|
||||
private var publishers = emptySet<String>()
|
||||
private var authors = emptyMap<String, List<AuthorDto>>() // roles to list of authors
|
||||
|
||||
override val name = "Komga${if (suffix.isNotBlank()) " ($suffix)" else ""}"
|
||||
override val lang = "en"
|
||||
override val supportsLatest = true
|
||||
private val LOG_TAG = "extension.all.komga${if (suffix.isNotBlank()) ".$suffix" else ""}"
|
||||
|
||||
override val baseUrl by lazy { getPrefBaseUrl() }
|
||||
private val username by lazy { getPrefUsername() }
|
||||
private val password by lazy { getPrefPassword() }
|
||||
private val gson by lazy { Gson() }
|
||||
|
||||
override fun headersBuilder(): Headers.Builder =
|
||||
Headers.Builder()
|
||||
.add("User-Agent", "Tachiyomi Komga v${BuildConfig.VERSION_NAME}")
|
||||
|
||||
private val preferences: SharedPreferences by lazy {
|
||||
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
|
||||
}
|
||||
|
||||
override val client: OkHttpClient =
|
||||
network.client.newBuilder()
|
||||
.authenticator { _, response ->
|
||||
if (response.request().header("Authorization") != null) {
|
||||
null // Give up, we've already failed to authenticate.
|
||||
} else {
|
||||
response.request().newBuilder()
|
||||
.addHeader("Authorization", Credentials.basic(username, password))
|
||||
.build()
|
||||
}
|
||||
}
|
||||
.dns(Dns.SYSTEM) // don't use DNS over HTTPS as it breaks IP addressing
|
||||
.build()
|
||||
|
||||
override fun setupPreferenceScreen(screen: androidx.preference.PreferenceScreen) {
|
||||
screen.addPreference(screen.editTextPreference(ADDRESS_TITLE, ADDRESS_DEFAULT, baseUrl))
|
||||
screen.addPreference(screen.editTextPreference(USERNAME_TITLE, USERNAME_DEFAULT, username))
|
||||
screen.addPreference(screen.editTextPreference(PASSWORD_TITLE, PASSWORD_DEFAULT, password, true))
|
||||
}
|
||||
|
||||
private fun androidx.preference.PreferenceScreen.editTextPreference(title: String, default: String, value: String, isPassword: Boolean = false): androidx.preference.EditTextPreference {
|
||||
return androidx.preference.EditTextPreference(context).apply {
|
||||
key = title
|
||||
this.title = title
|
||||
summary = value
|
||||
this.setDefaultValue(default)
|
||||
dialogTitle = title
|
||||
|
||||
if (isPassword) {
|
||||
setOnBindEditTextListener {
|
||||
it.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
|
||||
}
|
||||
}
|
||||
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
try {
|
||||
val res = preferences.edit().putString(title, newValue as String).commit()
|
||||
Toast.makeText(context, "Restart Tachiyomi to apply new setting.", Toast.LENGTH_LONG).show()
|
||||
res
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun setupPreferenceScreen(screen: PreferenceScreen) {
|
||||
screen.addPreference(screen.supportEditTextPreference(ADDRESS_TITLE, ADDRESS_DEFAULT, baseUrl))
|
||||
screen.addPreference(screen.supportEditTextPreference(USERNAME_TITLE, USERNAME_DEFAULT, username))
|
||||
screen.addPreference(screen.supportEditTextPreference(PASSWORD_TITLE, PASSWORD_DEFAULT, password))
|
||||
}
|
||||
|
||||
private fun PreferenceScreen.supportEditTextPreference(title: String, default: String, value: String): EditTextPreference {
|
||||
return EditTextPreference(context).apply {
|
||||
key = title
|
||||
this.title = title
|
||||
summary = value
|
||||
this.setDefaultValue(default)
|
||||
dialogTitle = title
|
||||
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
try {
|
||||
val res = preferences.edit().putString(title, newValue as String).commit()
|
||||
Toast.makeText(context, "Restart Tachiyomi to apply new setting.", Toast.LENGTH_LONG).show()
|
||||
res
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getPrefBaseUrl(): String = preferences.getString(ADDRESS_TITLE, ADDRESS_DEFAULT)!!
|
||||
private fun getPrefUsername(): String = preferences.getString(USERNAME_TITLE, USERNAME_DEFAULT)!!
|
||||
private fun getPrefPassword(): String = preferences.getString(PASSWORD_TITLE, PASSWORD_DEFAULT)!!
|
||||
|
||||
init {
|
||||
if (baseUrl.isNotBlank()) {
|
||||
Single.fromCallable {
|
||||
client.newCall(GET("$baseUrl/api/v1/libraries", headers)).execute()
|
||||
}
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
{ response ->
|
||||
libraries = try {
|
||||
gson.fromJson(response.body()?.charStream()!!)
|
||||
} catch (e: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
},
|
||||
{ tr ->
|
||||
Log.e(LOG_TAG, "error while loading libraries for filters", tr)
|
||||
}
|
||||
)
|
||||
|
||||
Single.fromCallable {
|
||||
client.newCall(GET("$baseUrl/api/v1/collections?unpaged=true", headers)).execute()
|
||||
}
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
{ response ->
|
||||
collections = try {
|
||||
gson.fromJson<PageWrapperDto<CollectionDto>>(response.body()?.charStream()!!).content
|
||||
} catch (e: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
},
|
||||
{ tr ->
|
||||
Log.e(LOG_TAG, "error while loading collections for filters", tr)
|
||||
}
|
||||
)
|
||||
|
||||
Single.fromCallable {
|
||||
client.newCall(GET("$baseUrl/api/v1/genres", headers)).execute()
|
||||
}
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
{ response ->
|
||||
genres = try {
|
||||
gson.fromJson(response.body()?.charStream()!!)
|
||||
} catch (e: Exception) {
|
||||
emptySet()
|
||||
}
|
||||
},
|
||||
{ tr ->
|
||||
Log.e(LOG_TAG, "error while loading genres for filters", tr)
|
||||
}
|
||||
)
|
||||
|
||||
Single.fromCallable {
|
||||
client.newCall(GET("$baseUrl/api/v1/tags", headers)).execute()
|
||||
}
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
{ response ->
|
||||
tags = try {
|
||||
gson.fromJson(response.body()?.charStream()!!)
|
||||
} catch (e: Exception) {
|
||||
emptySet()
|
||||
}
|
||||
},
|
||||
{ tr ->
|
||||
Log.e(LOG_TAG, "error while loading tags for filters", tr)
|
||||
}
|
||||
)
|
||||
|
||||
Single.fromCallable {
|
||||
client.newCall(GET("$baseUrl/api/v1/publishers", headers)).execute()
|
||||
}
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
{ response ->
|
||||
publishers = try {
|
||||
gson.fromJson(response.body()?.charStream()!!)
|
||||
} catch (e: Exception) {
|
||||
emptySet()
|
||||
}
|
||||
},
|
||||
{ tr ->
|
||||
Log.e(LOG_TAG, "error while loading publishers for filters", tr)
|
||||
}
|
||||
)
|
||||
|
||||
Single.fromCallable {
|
||||
client.newCall(GET("$baseUrl/api/v1/authors", headers)).execute()
|
||||
}
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
{ response ->
|
||||
authors = try {
|
||||
val list: List<AuthorDto> = gson.fromJson(response.body()?.charStream()!!)
|
||||
list.groupBy { it.role }
|
||||
} catch (e: Exception) {
|
||||
emptyMap()
|
||||
}
|
||||
},
|
||||
{ tr ->
|
||||
Log.e(LOG_TAG, "error while loading authors for filters", tr)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val ADDRESS_TITLE = "Address"
|
||||
private const val ADDRESS_DEFAULT = ""
|
||||
private const val USERNAME_TITLE = "Username"
|
||||
private const val USERNAME_DEFAULT = ""
|
||||
private const val PASSWORD_TITLE = "Password"
|
||||
private const val PASSWORD_DEFAULT = ""
|
||||
|
||||
private val supportedImageTypes = listOf("image/jpeg", "image/png", "image/gif", "image/webp")
|
||||
|
||||
private const val TYPE_SERIES = "Series"
|
||||
private const val TYPE_READLISTS = "Read lists"
|
||||
}
|
||||
}
|
@ -1,14 +0,0 @@
|
||||
package eu.kanade.tachiyomi.extension.all.komga
|
||||
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.source.SourceFactory
|
||||
|
||||
class KomgaFactory : SourceFactory {
|
||||
|
||||
override fun createSources(): List<Source> =
|
||||
listOf(
|
||||
Komga(),
|
||||
Komga("2"),
|
||||
Komga("3")
|
||||
)
|
||||
}
|
@ -1,115 +0,0 @@
|
||||
package eu.kanade.tachiyomi.extension.all.komga.dto
|
||||
|
||||
data class LibraryDto(
|
||||
val id: String,
|
||||
val name: String
|
||||
)
|
||||
|
||||
data class SeriesDto(
|
||||
val id: String,
|
||||
val libraryId: String,
|
||||
val name: String,
|
||||
val created: String?,
|
||||
val lastModified: String?,
|
||||
val fileLastModified: String,
|
||||
val booksCount: Int,
|
||||
val metadata: SeriesMetadataDto,
|
||||
val booksMetadata: BookMetadataAggregationDto
|
||||
)
|
||||
|
||||
data class SeriesMetadataDto(
|
||||
val status: String,
|
||||
val created: String?,
|
||||
val lastModified: String?,
|
||||
val title: String,
|
||||
val titleSort: String,
|
||||
val summary: String,
|
||||
val summaryLock: Boolean,
|
||||
val readingDirection: String,
|
||||
val readingDirectionLock: Boolean,
|
||||
val publisher: String,
|
||||
val publisherLock: Boolean,
|
||||
val ageRating: Int?,
|
||||
val ageRatingLock: Boolean,
|
||||
val language: String,
|
||||
val languageLock: Boolean,
|
||||
val genres: Set<String>,
|
||||
val genresLock: Boolean,
|
||||
val tags: Set<String>,
|
||||
val tagsLock: Boolean
|
||||
)
|
||||
|
||||
data class BookMetadataAggregationDto(
|
||||
val authors: List<AuthorDto> = emptyList(),
|
||||
val releaseDate: String?,
|
||||
val summary: String,
|
||||
val summaryNumber: String,
|
||||
|
||||
val created: String,
|
||||
val lastModified: String
|
||||
)
|
||||
|
||||
data class BookDto(
|
||||
val id: String,
|
||||
val seriesId: String,
|
||||
val name: String,
|
||||
val number: Float,
|
||||
val created: String?,
|
||||
val lastModified: String?,
|
||||
val fileLastModified: String,
|
||||
val sizeBytes: Long,
|
||||
val size: String,
|
||||
val media: MediaDto,
|
||||
val metadata: BookMetadataDto
|
||||
)
|
||||
|
||||
data class MediaDto(
|
||||
val status: String,
|
||||
val mediaType: String,
|
||||
val pagesCount: Int
|
||||
)
|
||||
|
||||
data class PageDto(
|
||||
val number: Int,
|
||||
val fileName: String,
|
||||
val mediaType: String
|
||||
)
|
||||
|
||||
data class BookMetadataDto(
|
||||
val title: String,
|
||||
val titleLock: Boolean,
|
||||
val summary: String,
|
||||
val summaryLock: Boolean,
|
||||
val number: String,
|
||||
val numberLock: Boolean,
|
||||
val numberSort: Float,
|
||||
val numberSortLock: Boolean,
|
||||
val releaseDate: String?,
|
||||
val releaseDateLock: Boolean,
|
||||
val authors: List<AuthorDto>,
|
||||
val authorsLock: Boolean
|
||||
)
|
||||
|
||||
data class AuthorDto(
|
||||
val name: String,
|
||||
val role: String
|
||||
)
|
||||
|
||||
data class CollectionDto(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val ordered: Boolean,
|
||||
val seriesIds: List<String>,
|
||||
val createdDate: String,
|
||||
val lastModifiedDate: String,
|
||||
val filtered: Boolean
|
||||
)
|
||||
|
||||
data class ReadListDto(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val bookIds: List<String>,
|
||||
val createdDate: String,
|
||||
val lastModifiedDate: String,
|
||||
val filtered: Boolean
|
||||
)
|
@ -1,13 +0,0 @@
|
||||
package eu.kanade.tachiyomi.extension.all.komga.dto
|
||||
|
||||
data class PageWrapperDto<T>(
|
||||
val content: List<T>,
|
||||
val empty: Boolean,
|
||||
val first: Boolean,
|
||||
val last: Boolean,
|
||||
val number: Long,
|
||||
val numberOfElements: Long,
|
||||
val size: Long,
|
||||
val totalElements: Long,
|
||||
val totalPages: Long
|
||||
)
|
@ -1,2 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest package="eu.kanade.tachiyomi.extension" />
|
@ -1,17 +0,0 @@
|
||||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'kotlin-android'
|
||||
|
||||
ext {
|
||||
extName = 'LANraragi'
|
||||
pkgNameSuffix = 'all.lanraragi'
|
||||
extClass = '.LANraragi'
|
||||
extVersionCode = 6
|
||||
libVersion = '1.2'
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'io.reactivex:rxandroid:1.2.1'
|
||||
implementation 'io.reactivex:rxjava:1.3.6'
|
||||
}
|
||||
|
||||
apply from: "$rootDir/common.gradle"
|
Before Width: | Height: | Size: 3.3 KiB |
Before Width: | Height: | Size: 1.8 KiB |