Add HBrowse
This commit is contained in:
parent
45fc2f2e0e
commit
b4c1e6a44c
@ -303,6 +303,15 @@ dependencies {
|
||||
implementation 'com.github.mfornos:humanize-slim:1.2.2'
|
||||
|
||||
implementation 'com.android.support:gridlayout-v7:28.0.0'
|
||||
|
||||
final def markwon_version = '4.1.0'
|
||||
|
||||
implementation "io.noties.markwon:core:$markwon_version"
|
||||
implementation "io.noties.markwon:ext-strikethrough:$markwon_version"
|
||||
implementation "io.noties.markwon:ext-tables:$markwon_version"
|
||||
implementation "io.noties.markwon:html:$markwon_version"
|
||||
implementation "io.noties.markwon:image:$markwon_version"
|
||||
implementation "io.noties.markwon:linkify:$markwon_version"
|
||||
}
|
||||
|
||||
buildscript {
|
||||
|
@ -257,6 +257,16 @@
|
||||
android:host="pururin.io"
|
||||
android:pathPrefix="/gallery/"
|
||||
android:scheme="https" />
|
||||
|
||||
<!-- HBrowse -->
|
||||
<data
|
||||
android:host="www.hbrowse.com"
|
||||
android:pathPrefix="/"
|
||||
android:scheme="http" />
|
||||
<data
|
||||
android:host="www.hbrowse.com"
|
||||
android:pathPrefix="/"
|
||||
android:scheme="https" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity
|
||||
|
@ -12,6 +12,7 @@ import eu.kanade.tachiyomi.extension.ExtensionManager
|
||||
import eu.kanade.tachiyomi.network.NetworkHelper
|
||||
import eu.kanade.tachiyomi.source.SourceManager
|
||||
import exh.eh.EHentaiUpdateHelper
|
||||
import io.noties.markwon.Markwon
|
||||
import rx.Observable
|
||||
import rx.schedulers.Schedulers
|
||||
import uy.kohesive.injekt.api.*
|
||||
@ -44,6 +45,8 @@ class AppModule(val app: Application) : InjektModule {
|
||||
|
||||
addSingletonFactory { EHentaiUpdateHelper(app) }
|
||||
|
||||
addSingletonFactory { Markwon.create(app) }
|
||||
|
||||
// Asynchronously init expensive components for a faster cold start
|
||||
|
||||
rxAsync { get<PreferencesHelper>() }
|
||||
|
@ -10,10 +10,7 @@ import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
import eu.kanade.tachiyomi.source.online.all.*
|
||||
import eu.kanade.tachiyomi.source.online.english.EightMuses
|
||||
import eu.kanade.tachiyomi.source.online.english.HentaiCafe
|
||||
import eu.kanade.tachiyomi.source.online.english.Pururin
|
||||
import eu.kanade.tachiyomi.source.online.english.Tsumino
|
||||
import eu.kanade.tachiyomi.source.online.english.*
|
||||
import rx.Observable
|
||||
import exh.EH_SOURCE_ID
|
||||
import exh.EXH_SOURCE_ID
|
||||
@ -116,6 +113,7 @@ open class SourceManager(private val context: Context) {
|
||||
exSrcs += Tsumino(context)
|
||||
exSrcs += Hitomi()
|
||||
exSrcs += EightMuses()
|
||||
exSrcs += HBrowse()
|
||||
return exSrcs
|
||||
}
|
||||
|
||||
|
@ -2,6 +2,10 @@ package eu.kanade.tachiyomi.source.model
|
||||
|
||||
sealed class Filter<T>(val name: String, var state: T) {
|
||||
open class Header(name: String) : Filter<Any>(name, 0)
|
||||
// --> EXH
|
||||
// name = button text
|
||||
open class HelpDialog(name: String, val dialogTitle: String = name, val markdown: String) : Filter<Any>(name, 0)
|
||||
// <-- EXH
|
||||
open class Separator(name: String = "") : Filter<Any>(name, 0)
|
||||
abstract class Select<V>(name: String, val values: Array<V>, state: Int = 0) : Filter<Int>(name, state)
|
||||
abstract class Text(name: String, state: String = "") : Filter<String>(name, state)
|
||||
|
@ -0,0 +1,955 @@
|
||||
package eu.kanade.tachiyomi.source.online.english
|
||||
|
||||
import android.net.Uri
|
||||
import com.github.salomonbrys.kotson.array
|
||||
import com.github.salomonbrys.kotson.string
|
||||
import com.google.gson.JsonParser
|
||||
import com.lvla.rxjava.interopkt.toV1Single
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.POST
|
||||
import eu.kanade.tachiyomi.network.asObservable
|
||||
import eu.kanade.tachiyomi.network.asObservableSuccess
|
||||
import eu.kanade.tachiyomi.source.model.*
|
||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
import eu.kanade.tachiyomi.source.online.LewdSource
|
||||
import eu.kanade.tachiyomi.source.online.UrlImportableSource
|
||||
import eu.kanade.tachiyomi.util.asJsoup
|
||||
import exh.metadata.metadata.HBrowseSearchMetadata
|
||||
import exh.metadata.metadata.base.RaisedTag
|
||||
import exh.search.Namespace
|
||||
import exh.search.SearchEngine
|
||||
import exh.search.Text
|
||||
import exh.util.await
|
||||
import exh.util.dropBlank
|
||||
import exh.util.urlImportFetchSearchManga
|
||||
import info.debatty.java.stringsimilarity.Levenshtein
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.rx2.asSingle
|
||||
import okhttp3.*
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
import rx.Observable
|
||||
import rx.schedulers.Schedulers
|
||||
import kotlin.math.ceil
|
||||
|
||||
class HBrowse : HttpSource(), LewdSource<HBrowseSearchMetadata, Document>, UrlImportableSource {
|
||||
/**
|
||||
* An ISO 639-1 compliant language code (two letters in lower case).
|
||||
*/
|
||||
override val lang: String = "en"
|
||||
/**
|
||||
* Base url of the website without the trailing slash, like: http://mysite.com
|
||||
*/
|
||||
override val baseUrl = HBrowseSearchMetadata.BASE_URL
|
||||
|
||||
override val name: String = "HBrowse"
|
||||
|
||||
override val supportsLatest = true
|
||||
|
||||
override val metaClass = HBrowseSearchMetadata::class
|
||||
|
||||
override fun headersBuilder() = Headers.Builder()
|
||||
.add("Cookie", BASE_COOKIES)
|
||||
|
||||
private val clientWithoutCookies = client.newBuilder()
|
||||
.cookieJar(CookieJar.NO_COOKIES)
|
||||
.build()
|
||||
|
||||
private val nonRedirectingClientWithoutCookies = clientWithoutCookies.newBuilder()
|
||||
.followRedirects(false)
|
||||
.build()
|
||||
|
||||
private val searchEngine = SearchEngine()
|
||||
|
||||
/**
|
||||
* Returns the request for the popular manga given the page.
|
||||
*
|
||||
* @param page the page number to retrieve.
|
||||
*/
|
||||
override fun popularMangaRequest(page: Int)
|
||||
= GET("$baseUrl/browse/title/rank/DESC", headers)
|
||||
|
||||
private fun parseListing(response: Response): MangasPage {
|
||||
val doc = response.asJsoup()
|
||||
val main = doc.selectFirst("#main")
|
||||
val items = main.select(".thumbTable > tbody")
|
||||
val manga = items.map { mangaEle ->
|
||||
SManga.create().apply {
|
||||
val thumbElement = mangaEle.selectFirst(".thumbImg")
|
||||
url = "/" + thumbElement.parent().attr("href").split("/").dropBlank().first()
|
||||
title = thumbElement.parent().attr("title").substringAfter('\'').substringBeforeLast('\'')
|
||||
thumbnail_url = baseUrl + thumbElement.attr("src")
|
||||
}
|
||||
}
|
||||
|
||||
val hasNextPage = doc.selectFirst("#main > p > a[title~=jump]:nth-last-child(1)") != null
|
||||
return MangasPage(
|
||||
manga,
|
||||
hasNextPage
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an observable containing a page with a list of manga. Normally it's not needed to
|
||||
* override this method.
|
||||
*
|
||||
* @param page the page number to retrieve.
|
||||
* @param query the search query.
|
||||
* @param filters the list of filters to apply.
|
||||
*/
|
||||
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
|
||||
return urlImportFetchSearchManga(query) {
|
||||
fetchSearchMangaInternal(page, query, filters)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the response from the site and returns a [MangasPage] object.
|
||||
*
|
||||
* @param response the response from the site.
|
||||
*/
|
||||
override fun popularMangaParse(response: Response) = parseListing(response)
|
||||
|
||||
/**
|
||||
* Returns the request for the search manga given the page.
|
||||
*
|
||||
* @param page the page number to retrieve.
|
||||
* @param query the search query.
|
||||
* @param filters the list of filters to apply.
|
||||
*/
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList)
|
||||
= throw UnsupportedOperationException("Should not be called!")
|
||||
|
||||
private fun fetchSearchMangaInternal(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
|
||||
return GlobalScope.async(Dispatchers.IO) {
|
||||
val modeFilter = filters.filterIsInstance<ModeFilter>().firstOrNull()
|
||||
val sortFilter = filters.filterIsInstance<SortFilter>().firstOrNull()
|
||||
|
||||
var base: String? = null
|
||||
var isSortFilter = false
|
||||
// <NS, VALUE, EXCLUDED>
|
||||
var tagQuery: List<Triple<String, String, Boolean>>? = null
|
||||
|
||||
if(sortFilter != null) {
|
||||
sortFilter.state?.let { state ->
|
||||
if(query.isNotBlank()) {
|
||||
throw IllegalArgumentException("Cannot use sorting while text/tag search is active!")
|
||||
}
|
||||
|
||||
isSortFilter = true
|
||||
base = "/browse/title/${SortFilter.SORT_OPTIONS[state.index].first}/${if(state.ascending) "ASC" else "DESC"}"
|
||||
}
|
||||
}
|
||||
|
||||
if(base == null) {
|
||||
base = if(modeFilter != null && modeFilter.state == 1) {
|
||||
tagQuery = searchEngine.parseQuery(query, false).map {
|
||||
when (it) {
|
||||
is Text -> {
|
||||
var minDist = Int.MAX_VALUE.toDouble()
|
||||
// ns, value
|
||||
var minContent: Pair<String, String> = "" to ""
|
||||
for(ns in ALL_TAGS) {
|
||||
val (v, d) = ns.value.nearest(query, minDist)
|
||||
if(d < minDist) {
|
||||
minDist = d
|
||||
minContent = ns.key to v
|
||||
}
|
||||
}
|
||||
minContent
|
||||
}
|
||||
is Namespace -> {
|
||||
// Map ns aliases
|
||||
val mappedNs = NS_MAPPINGS[it.namespace] ?: it.namespace
|
||||
|
||||
var key = mappedNs
|
||||
if(ALL_TAGS.containsKey(key)) key = ALL_TAGS.keys.sorted().nearest(mappedNs).first
|
||||
|
||||
// Find nearest NS
|
||||
val nsContents = ALL_TAGS[key]
|
||||
|
||||
key to nsContents!!.nearest(it.tag?.rawTextOnly() ?: "").first
|
||||
}
|
||||
else -> error("Unknown type!")
|
||||
}.let { p ->
|
||||
Triple(p.first, p.second, it.excluded)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
"/result"
|
||||
} else {
|
||||
"/search"
|
||||
}
|
||||
}
|
||||
|
||||
base += "/$page"
|
||||
|
||||
if(isSortFilter) {
|
||||
parseListing(client.newCall(GET(baseUrl + base, headers))
|
||||
.asObservableSuccess()
|
||||
.toSingle()
|
||||
.await(Schedulers.io()))
|
||||
} else {
|
||||
val body = if(tagQuery != null) {
|
||||
FormBody.Builder()
|
||||
.add("type", "advance")
|
||||
.apply {
|
||||
tagQuery.forEach {
|
||||
add(it.first + "_" + it.second, if(it.third) "n" else "y")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
FormBody.Builder()
|
||||
.add("type", "search")
|
||||
.add("needle", query)
|
||||
}
|
||||
val processRequest = POST(
|
||||
"$baseUrl/content/process.php",
|
||||
headers,
|
||||
body = body.build()
|
||||
)
|
||||
val processResponse = nonRedirectingClientWithoutCookies.newCall(processRequest)
|
||||
.asObservable()
|
||||
.toSingle()
|
||||
.await(Schedulers.io())
|
||||
|
||||
if(!processResponse.isRedirect)
|
||||
throw IllegalStateException("Unexpected process response code!")
|
||||
|
||||
val sessId = processResponse.headers("Set-Cookie").find {
|
||||
it.startsWith("PHPSESSID")
|
||||
} ?: throw IllegalStateException("Missing server session cookie!")
|
||||
|
||||
val response = clientWithoutCookies.newCall(GET(baseUrl + base,
|
||||
headersBuilder()
|
||||
.set("Cookie", BASE_COOKIES + " " + sessId.substringBefore(';'))
|
||||
.build()))
|
||||
.asObservableSuccess()
|
||||
.toSingle()
|
||||
.await(Schedulers.io())
|
||||
|
||||
val doc = response.asJsoup()
|
||||
val manga = doc.select(".browseDescription").map {
|
||||
SManga.create().apply {
|
||||
val first = it.child(0)
|
||||
url = first.attr("href")
|
||||
title = first.attr("title").substringAfter('\'').removeSuffix("'").replace('_', ' ')
|
||||
thumbnail_url = HBrowseSearchMetadata.guessThumbnailUrl(url.substring(1))
|
||||
}
|
||||
}
|
||||
val hasNextPage = doc.selectFirst("#main > p > a[title~=jump]:nth-last-child(1)") != null
|
||||
MangasPage(
|
||||
manga,
|
||||
hasNextPage
|
||||
)
|
||||
}
|
||||
}.asSingle(GlobalScope.coroutineContext).toV1Single().toObservable()
|
||||
}
|
||||
|
||||
// Collection must be sorted and cannot be sorted
|
||||
private fun List<String>.nearest(string: String, maxDist: Double = Int.MAX_VALUE.toDouble()): Pair<String, Double> {
|
||||
val idx = binarySearch(string)
|
||||
return if(idx < 0) {
|
||||
val l = Levenshtein()
|
||||
var minSoFar = maxDist
|
||||
var minIndexSoFar = 0
|
||||
forEachIndexed { index, s ->
|
||||
val d = l.distance(string, s, ceil(minSoFar).toInt())
|
||||
if(d < minSoFar) {
|
||||
minSoFar = d
|
||||
minIndexSoFar = index
|
||||
}
|
||||
}
|
||||
get(minIndexSoFar) to minSoFar
|
||||
} else {
|
||||
get(idx) to 0.0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the response from the site and returns a [MangasPage] object.
|
||||
*
|
||||
* @param response the response from the site.
|
||||
*/
|
||||
override fun searchMangaParse(response: Response) = parseListing(response)
|
||||
|
||||
/**
|
||||
* Returns the request for latest manga given the page.
|
||||
*
|
||||
* @param page the page number to retrieve.
|
||||
*/
|
||||
override fun latestUpdatesRequest(page: Int) = GET("$baseUrl/browse/title/date/DESC", headers)
|
||||
|
||||
/**
|
||||
* Parses the response from the site and returns a [MangasPage] object.
|
||||
*
|
||||
* @param response the response from the site.
|
||||
*/
|
||||
override fun latestUpdatesParse(response: Response) = parseListing(response)
|
||||
|
||||
/**
|
||||
* Parses the response from the site and returns the details of a manga.
|
||||
*
|
||||
* @param response the response from the site.
|
||||
*/
|
||||
override fun mangaDetailsParse(response: Response): SManga {
|
||||
throw UnsupportedOperationException("Should not be called!")
|
||||
}
|
||||
|
||||
override fun parseIntoMetadata(metadata: HBrowseSearchMetadata, input: Document) {
|
||||
val tables = parseIntoTables(input)
|
||||
with(metadata) {
|
||||
hbId = Uri.parse(input.location()).pathSegments.first().toLong()
|
||||
|
||||
tags.clear()
|
||||
(tables[""]!! + tables["categories"]!!).forEach { (k, v) ->
|
||||
when(val lowercaseNs = k.toLowerCase()) {
|
||||
"title" -> title = v.text()
|
||||
"length" -> length = v.text().substringBefore(" ").toInt()
|
||||
else -> {
|
||||
v.getElementsByTag("a").forEach {
|
||||
tags += RaisedTag(
|
||||
lowercaseNs,
|
||||
it.text(),
|
||||
HBrowseSearchMetadata.TAG_TYPE_DEFAULT
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an observable with the updated details for a manga. Normally it's not needed to
|
||||
* override this method.
|
||||
*
|
||||
* @param manga the manga to be updated.
|
||||
*/
|
||||
override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
|
||||
return client.newCall(mangaDetailsRequest(manga))
|
||||
.asObservableSuccess()
|
||||
.flatMap {
|
||||
parseToManga(manga, it.asJsoup()).andThen(Observable.just(manga))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the response from the site and returns a list of chapters.
|
||||
*
|
||||
* @param response the response from the site.
|
||||
*/
|
||||
override fun chapterListParse(response: Response): List<SChapter> {
|
||||
return parseIntoTables(response.asJsoup())["read manga online"]?.map { (key, value) ->
|
||||
SChapter.create().apply {
|
||||
url = value.selectFirst(".listLink").attr("href")
|
||||
|
||||
name = key
|
||||
}
|
||||
} ?: emptyList()
|
||||
}
|
||||
|
||||
private fun parseIntoTables(doc: Document): Map<String, Map<String, Element>> {
|
||||
return doc.select("#main > .listTable").map { ele ->
|
||||
val tableName = ele.previousElementSibling()?.text()?.toLowerCase() ?: ""
|
||||
tableName to ele.select("tr").map {
|
||||
it.child(0).text() to it.child(1)
|
||||
}.toMap()
|
||||
}.toMap()
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the response from the site and returns a list of pages.
|
||||
*
|
||||
* @param response the response from the site.
|
||||
*/
|
||||
override fun pageListParse(response: Response): List<Page> {
|
||||
val doc = response.asJsoup()
|
||||
val basePath = listOf("data") + response.request().url().pathSegments()
|
||||
val scripts = doc.getElementsByTag("script").map { it.data() }
|
||||
for(script in scripts) {
|
||||
val totalPages = TOTAL_PAGES_REGEX.find(script)?.groupValues?.getOrNull(1)?.toIntOrNull() ?: continue
|
||||
val pageList = PAGE_LIST_REGEX.find(script)?.groupValues?.getOrNull(1) ?: continue
|
||||
|
||||
return jsonParser.parse(pageList).array.take(totalPages).map {
|
||||
it.string
|
||||
}.mapIndexed { index, pageName ->
|
||||
Page(
|
||||
index,
|
||||
pageName,
|
||||
"$baseUrl/${basePath.joinToString("/")}/$pageName"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
class HelpFilter : Filter.HelpDialog("Usage instructions", markdown = """
|
||||
### Modes
|
||||
There are three available filter modes:
|
||||
- Text search
|
||||
- Tag search
|
||||
- Sort mode
|
||||
|
||||
You can only use a single mode at a time. Switch between the text and tag search modes using the dropdown menu. Switch to sorting mode by selecting a sorting option.
|
||||
|
||||
### Text search
|
||||
Search for galleries by title, artist or origin.
|
||||
|
||||
### Tag search
|
||||
Search for galleries by tag (e.g. search for a specific genre, type, setting, etc). Uses nhentai/e-hentai syntax. Refer to the "Search" section on [this page](https://nhentai.net/info/) for more information.
|
||||
|
||||
### Sort mode
|
||||
View a list of all galleries sorted by a specific parameter. Exit sorting mode by resetting the filters using the reset button near the bottom of the screen.
|
||||
|
||||
### Tag list
|
||||
""".trimIndent() + "\n$TAGS_AS_MARKDOWN")
|
||||
|
||||
class ModeFilter : Filter.Select<String>("Mode", arrayOf(
|
||||
"Text search",
|
||||
"Tag search"
|
||||
))
|
||||
|
||||
class SortFilter : Filter.Sort("Sort", SORT_OPTIONS.map { it.second }.toTypedArray()) {
|
||||
companion object {
|
||||
// internal to display
|
||||
val SORT_OPTIONS = listOf(
|
||||
"length" to "Length",
|
||||
"date" to "Date added",
|
||||
"rank" to "Rank"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getFilterList() = FilterList(
|
||||
HelpFilter(),
|
||||
ModeFilter(),
|
||||
SortFilter()
|
||||
)
|
||||
|
||||
/**
|
||||
* Parses the response from the site and returns the absolute url to the source image.
|
||||
*
|
||||
* @param response the response from the site.
|
||||
*/
|
||||
override fun imageUrlParse(response: Response): String {
|
||||
throw UnsupportedOperationException("Should not be called!")
|
||||
}
|
||||
|
||||
override val matchingHosts = listOf(
|
||||
"www.hbrowse.com",
|
||||
"hbrowse.com"
|
||||
)
|
||||
|
||||
override fun mapUrlToMangaUrl(uri: Uri): String? {
|
||||
return "$baseUrl/${uri.pathSegments.first()}"
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val PAGE_LIST_REGEX = Regex("list *= *(\\[.*]);")
|
||||
private val TOTAL_PAGES_REGEX = Regex("totalPages *= *([0-9]*);")
|
||||
|
||||
private val jsonParser by lazy { JsonParser() }
|
||||
|
||||
private const val BASE_COOKIES = "thumbnails=1;"
|
||||
|
||||
private val NS_MAPPINGS = mapOf(
|
||||
"set" to "setting",
|
||||
"loc" to "setting",
|
||||
"location" to "setting",
|
||||
"fet" to "fetish",
|
||||
"relation" to "relationship",
|
||||
"male" to "malebody",
|
||||
"female" to "femalebody",
|
||||
"pos" to "position"
|
||||
)
|
||||
|
||||
private val ALL_TAGS = mapOf(
|
||||
"genre" to listOf(
|
||||
"action",
|
||||
"adventure",
|
||||
"anime",
|
||||
"bizarre",
|
||||
"comedy",
|
||||
"drama",
|
||||
"fantasy",
|
||||
"gore",
|
||||
"historic",
|
||||
"horror",
|
||||
"medieval",
|
||||
"modern",
|
||||
"myth",
|
||||
"psychological",
|
||||
"romance",
|
||||
"school_life",
|
||||
"scifi",
|
||||
"supernatural",
|
||||
"video_game",
|
||||
"visual_novel"
|
||||
),
|
||||
"type" to listOf(
|
||||
"anthology",
|
||||
"bestiality",
|
||||
"dandere",
|
||||
"deredere",
|
||||
"deviant",
|
||||
"fully_colored",
|
||||
"furry",
|
||||
"futanari",
|
||||
"gender_bender",
|
||||
"guro",
|
||||
"harem",
|
||||
"incest",
|
||||
"kuudere",
|
||||
"lolicon",
|
||||
"long_story",
|
||||
"netorare",
|
||||
"non-con",
|
||||
"partly_colored",
|
||||
"reverse_harem",
|
||||
"ryona",
|
||||
"short_story",
|
||||
"shotacon",
|
||||
"transgender",
|
||||
"tsundere",
|
||||
"uncensored",
|
||||
"vanilla",
|
||||
"yandere",
|
||||
"yaoi",
|
||||
"yuri"
|
||||
),
|
||||
"setting" to listOf(
|
||||
"amusement_park",
|
||||
"attic",
|
||||
"automobile",
|
||||
"balcony",
|
||||
"basement",
|
||||
"bath",
|
||||
"beach",
|
||||
"bedroom",
|
||||
"cabin",
|
||||
"castle",
|
||||
"cave",
|
||||
"church",
|
||||
"classroom",
|
||||
"deck",
|
||||
"dining_room",
|
||||
"doctors",
|
||||
"dojo",
|
||||
"doorway",
|
||||
"dream",
|
||||
"dressing_room",
|
||||
"dungeon",
|
||||
"elevator",
|
||||
"festival",
|
||||
"gym",
|
||||
"haunted_building",
|
||||
"hospital",
|
||||
"hotel",
|
||||
"hot_springs",
|
||||
"kitchen",
|
||||
"laboratory",
|
||||
"library",
|
||||
"living_room",
|
||||
"locker_room",
|
||||
"mansion",
|
||||
"office",
|
||||
"other",
|
||||
"outdoor",
|
||||
"outer_space",
|
||||
"park",
|
||||
"pool",
|
||||
"prison",
|
||||
"public",
|
||||
"restaurant",
|
||||
"restroom",
|
||||
"roof",
|
||||
"sauna",
|
||||
"school",
|
||||
"school_nurses_office",
|
||||
"shower",
|
||||
"shrine",
|
||||
"storage_room",
|
||||
"store",
|
||||
"street",
|
||||
"teachers_lounge",
|
||||
"theater",
|
||||
"tight_space",
|
||||
"toilet",
|
||||
"train",
|
||||
"transit",
|
||||
"virtual_reality",
|
||||
"warehouse",
|
||||
"wilderness"
|
||||
),
|
||||
"fetish" to listOf(
|
||||
"androphobia",
|
||||
"apron",
|
||||
"assertive_girl",
|
||||
"bikini",
|
||||
"bloomers",
|
||||
"breast_expansion",
|
||||
"business_suit",
|
||||
"chastity_device",
|
||||
"chinese_dress",
|
||||
"christmas",
|
||||
"collar",
|
||||
"corset",
|
||||
"cosplay_(female)",
|
||||
"cosplay_(male)",
|
||||
"crossdressing_(female)",
|
||||
"crossdressing_(male)",
|
||||
"eye_patch",
|
||||
"food",
|
||||
"giantess",
|
||||
"glasses",
|
||||
"gothic_lolita",
|
||||
"gyaru",
|
||||
"gynophobia",
|
||||
"high_heels",
|
||||
"hot_pants",
|
||||
"impregnation",
|
||||
"kemonomimi",
|
||||
"kimono",
|
||||
"knee_high_socks",
|
||||
"lab_coat",
|
||||
"latex",
|
||||
"leotard",
|
||||
"lingerie",
|
||||
"maid_outfit",
|
||||
"mother_and_daughter",
|
||||
"none",
|
||||
"nonhuman_girl",
|
||||
"olfactophilia",
|
||||
"pregnant",
|
||||
"rich_girl",
|
||||
"school_swimsuit",
|
||||
"shy_girl",
|
||||
"sisters",
|
||||
"sleeping_girl",
|
||||
"sporty",
|
||||
"stockings",
|
||||
"strapon",
|
||||
"student_uniform",
|
||||
"swimsuit",
|
||||
"tanned",
|
||||
"tattoo",
|
||||
"time_stop",
|
||||
"twins_(coed)",
|
||||
"twins_(female)",
|
||||
"twins_(male)",
|
||||
"uniform",
|
||||
"wedding_dress"
|
||||
),
|
||||
"role" to listOf(
|
||||
"alien",
|
||||
"android",
|
||||
"angel",
|
||||
"athlete",
|
||||
"bride",
|
||||
"bunnygirl",
|
||||
"cheerleader",
|
||||
"delinquent",
|
||||
"demon",
|
||||
"doctor",
|
||||
"dominatrix",
|
||||
"escort",
|
||||
"foreigner",
|
||||
"ghost",
|
||||
"housewife",
|
||||
"idol",
|
||||
"magical_girl",
|
||||
"maid",
|
||||
"mamono",
|
||||
"massagist",
|
||||
"miko",
|
||||
"mythical_being",
|
||||
"neet",
|
||||
"nekomimi",
|
||||
"newlywed",
|
||||
"ninja",
|
||||
"normal",
|
||||
"nun",
|
||||
"nurse",
|
||||
"office_lady",
|
||||
"other",
|
||||
"police",
|
||||
"priest",
|
||||
"princess",
|
||||
"queen",
|
||||
"school_nurse",
|
||||
"scientist",
|
||||
"sorcerer",
|
||||
"student",
|
||||
"succubus",
|
||||
"teacher",
|
||||
"tomboy",
|
||||
"tutor",
|
||||
"waitress",
|
||||
"warrior",
|
||||
"witch"
|
||||
),
|
||||
"relationship" to listOf(
|
||||
"acquaintance",
|
||||
"anothers_daughter",
|
||||
"anothers_girlfriend",
|
||||
"anothers_mother",
|
||||
"anothers_sister",
|
||||
"anothers_wife",
|
||||
"aunt",
|
||||
"babysitter",
|
||||
"childhood_friend",
|
||||
"classmate",
|
||||
"cousin",
|
||||
"customer",
|
||||
"daughter",
|
||||
"daughter-in-law",
|
||||
"employee",
|
||||
"employer",
|
||||
"enemy",
|
||||
"fiance",
|
||||
"friend",
|
||||
"friends_daughter",
|
||||
"friends_girlfriend",
|
||||
"friends_mother",
|
||||
"friends_sister",
|
||||
"friends_wife",
|
||||
"girlfriend",
|
||||
"landlord",
|
||||
"manager",
|
||||
"master",
|
||||
"mother",
|
||||
"mother-in-law",
|
||||
"neighbor",
|
||||
"niece",
|
||||
"none",
|
||||
"older_sister",
|
||||
"patient",
|
||||
"pet",
|
||||
"physician",
|
||||
"relative",
|
||||
"relatives_friend",
|
||||
"relatives_girlfriend",
|
||||
"relatives_wife",
|
||||
"servant",
|
||||
"server",
|
||||
"sister-in-law",
|
||||
"slave",
|
||||
"stepdaughter",
|
||||
"stepmother",
|
||||
"stepsister",
|
||||
"stranger",
|
||||
"student",
|
||||
"teacher",
|
||||
"tutee",
|
||||
"tutor",
|
||||
"twin",
|
||||
"underclassman",
|
||||
"upperclassman",
|
||||
"wife",
|
||||
"workmate",
|
||||
"younger_sister"
|
||||
),
|
||||
"malebody" to listOf(
|
||||
"adult",
|
||||
"animal",
|
||||
"animal_ears",
|
||||
"bald",
|
||||
"beard",
|
||||
"dark_skin",
|
||||
"elderly",
|
||||
"exaggerated_penis",
|
||||
"fat",
|
||||
"furry",
|
||||
"goatee",
|
||||
"hairy",
|
||||
"half_animal",
|
||||
"horns",
|
||||
"large_penis",
|
||||
"long_hair",
|
||||
"middle_age",
|
||||
"monster",
|
||||
"muscular",
|
||||
"mustache",
|
||||
"none",
|
||||
"short",
|
||||
"short_hair",
|
||||
"skinny",
|
||||
"small_penis",
|
||||
"tail",
|
||||
"tall",
|
||||
"tanned",
|
||||
"tan_line",
|
||||
"teenager",
|
||||
"wings",
|
||||
"young"
|
||||
),
|
||||
"femalebody" to listOf(
|
||||
"adult",
|
||||
"animal_ears",
|
||||
"bald",
|
||||
"big_butt",
|
||||
"chubby",
|
||||
"dark_skin",
|
||||
"elderly",
|
||||
"elf_ears",
|
||||
"exaggerated_breasts",
|
||||
"fat",
|
||||
"furry",
|
||||
"hairy",
|
||||
"hair_bun",
|
||||
"half_animal",
|
||||
"halo",
|
||||
"hime_cut",
|
||||
"horns",
|
||||
"large_breasts",
|
||||
"long_hair",
|
||||
"middle_age",
|
||||
"monster_girl",
|
||||
"muscular",
|
||||
"none",
|
||||
"pigtails",
|
||||
"ponytail",
|
||||
"short",
|
||||
"short_hair",
|
||||
"skinny",
|
||||
"small_breasts",
|
||||
"tail",
|
||||
"tall",
|
||||
"tanned",
|
||||
"tan_line",
|
||||
"teenager",
|
||||
"twintails",
|
||||
"wings",
|
||||
"young"
|
||||
),
|
||||
"grouping" to listOf(
|
||||
"foursome_(1_female)",
|
||||
"foursome_(1_male)",
|
||||
"foursome_(mixed)",
|
||||
"foursome_(only_female)",
|
||||
"one_on_one",
|
||||
"one_on_one_(2_females)",
|
||||
"one_on_one_(2_males)",
|
||||
"orgy_(1_female)",
|
||||
"orgy_(1_male)",
|
||||
"orgy_(mainly_female)",
|
||||
"orgy_(mainly_male)",
|
||||
"orgy_(mixed)",
|
||||
"orgy_(only_female)",
|
||||
"orgy_(only_male)",
|
||||
"solo_(female)",
|
||||
"solo_(male)",
|
||||
"threesome_(1_female)",
|
||||
"threesome_(1_male)",
|
||||
"threesome_(only_female)",
|
||||
"threesome_(only_male)"
|
||||
),
|
||||
"scene" to listOf(
|
||||
"adultery",
|
||||
"ahegao",
|
||||
"anal_(female)",
|
||||
"anal_(male)",
|
||||
"aphrodisiac",
|
||||
"armpit_sex",
|
||||
"asphyxiation",
|
||||
"blackmail",
|
||||
"blowjob",
|
||||
"bondage",
|
||||
"breast_feeding",
|
||||
"breast_sucking",
|
||||
"bukkake",
|
||||
"cheating_(female)",
|
||||
"cheating_(male)",
|
||||
"chikan",
|
||||
"clothed_sex",
|
||||
"consensual",
|
||||
"cunnilingus",
|
||||
"defloration",
|
||||
"discipline",
|
||||
"dominance",
|
||||
"double_penetration",
|
||||
"drunk",
|
||||
"enema",
|
||||
"exhibitionism",
|
||||
"facesitting",
|
||||
"fingering_(female)",
|
||||
"fingering_(male)",
|
||||
"fisting",
|
||||
"footjob",
|
||||
"grinding",
|
||||
"groping",
|
||||
"handjob",
|
||||
"humiliation",
|
||||
"hypnosis",
|
||||
"intercrural",
|
||||
"interracial_sex",
|
||||
"interspecies_sex",
|
||||
"lactation",
|
||||
"lotion",
|
||||
"masochism",
|
||||
"masturbation",
|
||||
"mind_break",
|
||||
"nonhuman",
|
||||
"orgy",
|
||||
"paizuri",
|
||||
"phone_sex",
|
||||
"props",
|
||||
"rape",
|
||||
"reverse_rape",
|
||||
"rimjob",
|
||||
"sadism",
|
||||
"scat",
|
||||
"sex_toys",
|
||||
"spanking",
|
||||
"squirt",
|
||||
"submission",
|
||||
"sumata",
|
||||
"swingers",
|
||||
"tentacles",
|
||||
"voyeurism",
|
||||
"watersports",
|
||||
"x-ray_blowjob",
|
||||
"x-ray_sex"
|
||||
),
|
||||
"position" to listOf(
|
||||
"69",
|
||||
"acrobat",
|
||||
"arch",
|
||||
"bodyguard",
|
||||
"butterfly",
|
||||
"cowgirl",
|
||||
"dancer",
|
||||
"deck_chair",
|
||||
"deep_stick",
|
||||
"doggy",
|
||||
"drill",
|
||||
"ex_sex",
|
||||
"jockey",
|
||||
"lap_dance",
|
||||
"leg_glider",
|
||||
"lotus",
|
||||
"mastery",
|
||||
"missionary",
|
||||
"none",
|
||||
"other",
|
||||
"pile_driver",
|
||||
"prison_guard",
|
||||
"reverse_piggyback",
|
||||
"rodeo",
|
||||
"spoons",
|
||||
"standing",
|
||||
"teaspoons",
|
||||
"unusual",
|
||||
"victory"
|
||||
)
|
||||
).mapValues { it.value.sorted() }
|
||||
|
||||
private val TAGS_AS_MARKDOWN = ALL_TAGS.map { (ns, values) ->
|
||||
"#### $ns\n" + values.map { "- $it" }.joinToString("\n") }.joinToString("\n\n")
|
||||
}
|
||||
}
|
@ -292,6 +292,9 @@ open class BrowseCataloguePresenter(
|
||||
return mapNotNull {
|
||||
when (it) {
|
||||
is Filter.Header -> HeaderItem(it)
|
||||
// --> EXH
|
||||
is Filter.HelpDialog -> HelpDialogItem(it)
|
||||
// <-- EXH
|
||||
is Filter.Separator -> SeparatorItem(it)
|
||||
is Filter.CheckBox -> CheckboxItem(it)
|
||||
is Filter.TriState -> TriStateItem(it)
|
||||
|
61
app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/filter/HelpDialogItem.kt
Executable file
61
app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/filter/HelpDialogItem.kt
Executable file
@ -0,0 +1,61 @@
|
||||
package eu.kanade.tachiyomi.ui.catalogue.filter
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.support.v7.widget.RecyclerView
|
||||
import android.view.View
|
||||
import android.widget.Button
|
||||
import android.widget.TextView
|
||||
import com.afollestad.materialdialogs.MaterialDialog
|
||||
import eu.davidea.flexibleadapter.FlexibleAdapter
|
||||
import eu.davidea.flexibleadapter.items.AbstractHeaderItem
|
||||
import eu.davidea.flexibleadapter.items.IFlexible
|
||||
import eu.davidea.viewholders.FlexibleViewHolder
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.source.model.Filter
|
||||
import io.noties.markwon.Markwon
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
class HelpDialogItem(val filter: Filter.HelpDialog) : AbstractHeaderItem<HelpDialogItem.Holder>() {
|
||||
private val markwon: Markwon by injectLazy()
|
||||
|
||||
@SuppressLint("PrivateResource")
|
||||
override fun getLayoutRes(): Int {
|
||||
return R.layout.navigation_view_help_dialog
|
||||
}
|
||||
|
||||
override fun createViewHolder(view: View, adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>): Holder {
|
||||
return Holder(view, adapter)
|
||||
}
|
||||
|
||||
override fun bindViewHolder(adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>, holder: Holder, position: Int, payloads: List<Any?>?) {
|
||||
val view = holder.button as TextView
|
||||
view.text = filter.name
|
||||
view.setOnClickListener {
|
||||
val v = TextView(view.context)
|
||||
|
||||
val parsed = markwon.parse(filter.markdown)
|
||||
val rendered = markwon.render(parsed)
|
||||
markwon.setParsedMarkdown(v, rendered)
|
||||
|
||||
MaterialDialog.Builder(view.context)
|
||||
.title(filter.dialogTitle)
|
||||
.customView(v, true)
|
||||
.positiveText("Ok")
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
return filter == (other as HelpDialogItem).filter
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
return filter.hashCode()
|
||||
}
|
||||
|
||||
class Holder(view: View, adapter: FlexibleAdapter<*>) : FlexibleViewHolder(view, adapter) {
|
||||
val button: Button = itemView.findViewById(R.id.dialog_open_button)
|
||||
}
|
||||
}
|
@ -21,6 +21,7 @@ val PURURIN_SOURCE_ID = delegatedSourceId<Pururin>()
|
||||
const val TSUMINO_SOURCE_ID = LEWD_SOURCE_SERIES + 9
|
||||
const val HITOMI_SOURCE_ID = LEWD_SOURCE_SERIES + 10
|
||||
const val EIGHTMUSES_SOURCE_ID = LEWD_SOURCE_SERIES + 11
|
||||
const val HBROWSE_SOURCE_ID = LEWD_SOURCE_SERIES + 12
|
||||
const val MERGED_SOURCE_ID = LEWD_SOURCE_SERIES + 69
|
||||
|
||||
private val DELEGATED_LEWD_SOURCES = listOf(
|
||||
|
@ -0,0 +1,52 @@
|
||||
package exh.metadata.metadata
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import exh.metadata.metadata.EightMusesSearchMetadata.Companion.ARTIST_NAMESPACE
|
||||
import exh.metadata.metadata.base.RaisedSearchMetadata
|
||||
import exh.plusAssign
|
||||
|
||||
class HBrowseSearchMetadata : RaisedSearchMetadata() {
|
||||
var hbId: Long? = null
|
||||
|
||||
var title: String? by titleDelegate(TITLE_TYPE_MAIN)
|
||||
|
||||
// Length in pages
|
||||
var length: Int? = null
|
||||
|
||||
override fun copyTo(manga: SManga) {
|
||||
manga.url = "/$hbId"
|
||||
|
||||
title?.let {
|
||||
manga.title = it
|
||||
}
|
||||
|
||||
// Guess thumbnail URL if manga does not have thumbnail URL
|
||||
if(manga.thumbnail_url.isNullOrBlank()) {
|
||||
manga.thumbnail_url = guessThumbnailUrl(hbId.toString())
|
||||
}
|
||||
|
||||
manga.artist = tags.ofNamespace(ARTIST_NAMESPACE).joinToString { it.name }
|
||||
|
||||
val titleDesc = StringBuilder()
|
||||
title?.let { titleDesc += "Title: $it\n" }
|
||||
length?.let { titleDesc += "Length: $it page(s)\n" }
|
||||
|
||||
val tagsDesc = tagsToDescription()
|
||||
|
||||
manga.description = listOf(titleDesc.toString(), tagsDesc.toString())
|
||||
.filter(String::isNotBlank)
|
||||
.joinToString(separator = "\n")
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val BASE_URL = "https://www.hbrowse.com"
|
||||
|
||||
private const val TITLE_TYPE_MAIN = 0
|
||||
|
||||
const val TAG_TYPE_DEFAULT = 0
|
||||
|
||||
fun guessThumbnailUrl(hbid: String): String {
|
||||
return "$BASE_URL/thumbnails/${hbid}_1.jpg#guessed"
|
||||
}
|
||||
}
|
||||
}
|
@ -1,12 +1,10 @@
|
||||
package exh.search
|
||||
|
||||
import eu.kanade.tachiyomi.data.database.tables.MangaTable
|
||||
import exh.metadata.sql.tables.SearchMetadataTable
|
||||
import exh.metadata.sql.tables.SearchTagTable
|
||||
import exh.metadata.sql.tables.SearchTitleTable
|
||||
|
||||
class SearchEngine {
|
||||
|
||||
private val queryCache = mutableMapOf<String, List<QueryComponent>>()
|
||||
|
||||
fun textToSubQueries(namespace: String?,
|
||||
@ -116,7 +114,7 @@ class SearchEngine {
|
||||
return baseQuery to completeParams
|
||||
}
|
||||
|
||||
fun parseQuery(query: String) = queryCache.getOrPut(query) {
|
||||
fun parseQuery(query: String, enableWildcard: Boolean = true) = queryCache.getOrPut(query) {
|
||||
val res = mutableListOf<QueryComponent>()
|
||||
|
||||
var inQuotes = false
|
||||
@ -155,10 +153,10 @@ class SearchEngine {
|
||||
for(char in query.toLowerCase()) {
|
||||
if(char == '"') {
|
||||
inQuotes = !inQuotes
|
||||
} else if(char == '?' || char == '_') {
|
||||
} else if(enableWildcard && (char == '?' || char == '_')) {
|
||||
flushText()
|
||||
queuedText.add(SingleWildcard(char.toString()))
|
||||
} else if(char == '*' || char == '%') {
|
||||
} else if(enableWildcard && (char == '*' || char == '%')) {
|
||||
flushText()
|
||||
queuedText.add(MultiWildcard(char.toString()))
|
||||
} else if(char == '-') {
|
||||
|
19
app/src/main/res/layout/navigation_view_help_dialog.xml
Executable file
19
app/src/main/res/layout/navigation_view_help_dialog.xml
Executable file
@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?attr/listPreferredItemHeightSmall"
|
||||
android:paddingLeft="?attr/listPreferredItemPaddingLeft"
|
||||
android:paddingRight="?attr/listPreferredItemPaddingRight"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:focusable="true">
|
||||
|
||||
|
||||
<Button
|
||||
android:id="@+id/dialog_open_button"
|
||||
style="@style/Theme.Widget.Button.Borderless"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"
|
||||
android:text="Button" />
|
||||
</LinearLayout>
|
Loading…
x
Reference in New Issue
Block a user