Rewrite tag searching to use SQL
Fix EHentai/ExHentai Fix hitomi.la Fix hitomi.la crashing application Rewrite hitomi.la search engine to be faster, use less CPU and require no preloading Fix nhentai Add additional filters to nhentai Fix PervEden Introduce delegated sources Rewrite HentaiCafe to be a delegated source Introduce ability to save/load search presets Temporarily disable misbehaving native Tachiyomi migrations Fix tap-to-search-tag breaking on aliased tags Add debug menu Add experimental automatic captcha solver Add app name to wakelock names Add ability to interrupt metadata migrator Fix incognito open-in-browser being zoomed in immediately when it's opened
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
package exh
|
||||
|
||||
import eu.kanade.tachiyomi.source.SourceManager
|
||||
|
||||
/**
|
||||
* Source helpers
|
||||
*/
|
||||
@@ -15,13 +17,17 @@ const val PERV_EDEN_IT_SOURCE_ID = LEWD_SOURCE_SERIES + 6
|
||||
|
||||
const val NHENTAI_SOURCE_ID = LEWD_SOURCE_SERIES + 7
|
||||
|
||||
@Deprecated("Now a delegated source")
|
||||
const val HENTAI_CAFE_SOURCE_ID = LEWD_SOURCE_SERIES + 8
|
||||
|
||||
const val TSUMINO_SOURCE_ID = LEWD_SOURCE_SERIES + 9
|
||||
|
||||
const val HITOMI_SOURCE_ID = LEWD_SOURCE_SERIES + 10
|
||||
|
||||
fun isLewdSource(source: Long) = source in 6900..6999
|
||||
// TODO hentai.cafe is a lewd source!
|
||||
fun isLewdSource(source: Long) = source in 6900..6999 || SourceManager.DELEGATED_SOURCES.any {
|
||||
it.value.sourceId == source
|
||||
}
|
||||
|
||||
fun isEhSource(source: Long) = source == EH_SOURCE_ID
|
||||
|| source == EH_METADATA_SOURCE_ID
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package exh
|
||||
|
||||
import com.pushtorefresh.storio.sqlite.queries.Query
|
||||
import com.pushtorefresh.storio.sqlite.queries.RawQuery
|
||||
import eu.kanade.tachiyomi.data.database.DatabaseHelper
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.data.database.resolvers.MangaUrlPutResolver
|
||||
import eu.kanade.tachiyomi.data.database.tables.MangaTable
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.net.URI
|
||||
import java.net.URISyntaxException
|
||||
|
||||
object EXHMigrations {
|
||||
private val db: DatabaseHelper by injectLazy()
|
||||
|
||||
private const val CURRENT_MIGRATION_VERSION = 1
|
||||
|
||||
/**
|
||||
* Performs a migration when the application is updated.
|
||||
*
|
||||
* @param preferences Preferences of the application.
|
||||
* @return true if a migration is performed, false otherwise.
|
||||
*/
|
||||
fun upgrade(preferences: PreferencesHelper): Boolean {
|
||||
val context = preferences.context
|
||||
val oldVersion = preferences.eh_lastVersionCode().getOrDefault()
|
||||
if (oldVersion < CURRENT_MIGRATION_VERSION) {
|
||||
preferences.eh_lastVersionCode().set(CURRENT_MIGRATION_VERSION)
|
||||
|
||||
if(oldVersion < 1) {
|
||||
db.inTransaction {
|
||||
// Migrate HentaiCafe source IDs
|
||||
db.lowLevel().executeSQL(RawQuery.builder()
|
||||
.query("""
|
||||
UPDATE ${MangaTable.TABLE}
|
||||
SET ${MangaTable.COL_SOURCE} = 260868874183818481
|
||||
WHERE ${MangaTable.COL_SOURCE} = $HENTAI_CAFE_SOURCE_ID
|
||||
""".trimIndent())
|
||||
.affectsTables(MangaTable.TABLE)
|
||||
.build())
|
||||
|
||||
// Migrate nhentai URLs
|
||||
val nhentaiManga = db.db.get()
|
||||
.listOfObjects(Manga::class.java)
|
||||
.withQuery(Query.builder()
|
||||
.table(MangaTable.TABLE)
|
||||
.where("${MangaTable.COL_SOURCE} = $NHENTAI_SOURCE_ID")
|
||||
.build())
|
||||
.prepare()
|
||||
.executeAsBlocking()
|
||||
|
||||
nhentaiManga.forEach {
|
||||
it.url = getUrlWithoutDomain(it.url)
|
||||
}
|
||||
|
||||
db.db.put()
|
||||
.objects(nhentaiManga)
|
||||
// Extremely slow without the resolver :/
|
||||
.withPutResolver(MangaUrlPutResolver())
|
||||
.prepare()
|
||||
.executeAsBlocking()
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun getUrlWithoutDomain(orig: String): String {
|
||||
return try {
|
||||
val uri = URI(orig)
|
||||
var out = uri.path
|
||||
if (uri.query != null)
|
||||
out += "?" + uri.query
|
||||
if (uri.fragment != null)
|
||||
out += "#" + uri.fragment
|
||||
out
|
||||
} catch (e: URISyntaxException) {
|
||||
orig
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -138,7 +138,7 @@ class GalleryAdder {
|
||||
|
||||
val cleanedUrl = when(source) {
|
||||
EH_SOURCE_ID, EXH_SOURCE_ID -> ExGalleryMetadata.normalizeUrl(getUrlWithoutDomain(realUrl))
|
||||
NHENTAI_SOURCE_ID -> realUrl //nhentai uses URLs directly (oops, my bad when implementing this source)
|
||||
NHENTAI_SOURCE_ID -> getUrlWithoutDomain(realUrl)
|
||||
PERV_EDEN_EN_SOURCE_ID,
|
||||
PERV_EDEN_IT_SOURCE_ID -> getUrlWithoutDomain(realUrl)
|
||||
HENTAI_CAFE_SOURCE_ID -> getUrlWithoutDomain(realUrl)
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package exh.debug
|
||||
|
||||
import com.pushtorefresh.storio.sqlite.queries.RawQuery
|
||||
import eu.kanade.tachiyomi.data.database.DatabaseHelper
|
||||
import eu.kanade.tachiyomi.data.database.tables.MangaTable
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
object DebugFunctions {
|
||||
val db: DatabaseHelper by injectLazy()
|
||||
val prefs: PreferencesHelper by injectLazy()
|
||||
|
||||
fun addAllMangaInDatabaseToLibrary() {
|
||||
db.inTransaction {
|
||||
db.lowLevel().executeSQL(RawQuery.builder()
|
||||
.query("""
|
||||
UPDATE ${MangaTable.TABLE}
|
||||
SET ${MangaTable.COL_FAVORITE} = 1
|
||||
""".trimIndent())
|
||||
.affectsTables(MangaTable.TABLE)
|
||||
.build())
|
||||
}
|
||||
}
|
||||
|
||||
fun countMangaInDatabaseInLibrary() = db.getMangas().executeAsBlocking().count { it.favorite }
|
||||
|
||||
fun countMangaInDatabaseNotInLibrary() = db.getMangas().executeAsBlocking().count { !it.favorite }
|
||||
|
||||
fun countMangaInDatabase() = db.getMangas().executeAsBlocking().size
|
||||
|
||||
fun countMetadataInDatabase() = db.getSearchMetadata().executeAsBlocking().size
|
||||
|
||||
fun countMangaInLibraryWithMissingMetadata() = db.getMangas().executeAsBlocking().count {
|
||||
it.favorite && db.getSearchMetadataForManga(it.id!!).executeAsBlocking() == null
|
||||
}
|
||||
|
||||
fun clearSavedSearches() = prefs.eh_savedSearches().set("")
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package exh.debug
|
||||
|
||||
import android.support.v7.preference.PreferenceScreen
|
||||
import android.util.Log
|
||||
import com.afollestad.materialdialogs.MaterialDialog
|
||||
import eu.kanade.tachiyomi.ui.setting.SettingsController
|
||||
import eu.kanade.tachiyomi.ui.setting.onClick
|
||||
import eu.kanade.tachiyomi.ui.setting.preference
|
||||
import kotlin.reflect.full.declaredFunctions
|
||||
|
||||
class SettingsDebugController : SettingsController() {
|
||||
override fun setupPreferenceScreen(screen: PreferenceScreen) = with(screen) {
|
||||
title = "DEBUG MENU"
|
||||
|
||||
DebugFunctions::class.declaredFunctions.forEach {
|
||||
preference {
|
||||
title = it.name.replace(Regex("(.)(\\p{Upper})"), "$1 $2").toLowerCase().capitalize()
|
||||
isPersistent = false
|
||||
|
||||
onClick {
|
||||
try {
|
||||
val result = it.call(DebugFunctions)
|
||||
MaterialDialog.Builder(context)
|
||||
.content("Function returned result:\n\n$result")
|
||||
} catch(t: Throwable) {
|
||||
MaterialDialog.Builder(context)
|
||||
.content("Function threw exception:\n\n${Log.getStackTraceString(t)}")
|
||||
}.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -87,12 +87,12 @@ class FavoritesSyncHelper(val context: Context) {
|
||||
ignore { wakeLock?.release() }
|
||||
wakeLock = ignore {
|
||||
context.powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK,
|
||||
"ExhFavoritesSyncWakelock")
|
||||
"teh:ExhFavoritesSyncWakelock")
|
||||
}
|
||||
ignore { wifiLock?.release() }
|
||||
wifiLock = ignore {
|
||||
context.wifiManager.createWifiLock(WifiManager.WIFI_MODE_FULL,
|
||||
"ExhFavoritesSyncWifi")
|
||||
"teh:ExhFavoritesSyncWifi")
|
||||
}
|
||||
|
||||
storage.getRealm().use { realm ->
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
package exh.hitomi
|
||||
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.asObservable
|
||||
import eu.kanade.tachiyomi.network.asObservableSuccess
|
||||
import exh.metadata.metadata.HitomiSearchMetadata.Companion.LTN_BASE_URL
|
||||
import okhttp3.Headers
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.vepta.vdm.ByteCursor
|
||||
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()
|
||||
}
|
||||
|
||||
private fun BSearch(field: String, key: ByteArray, node: Node?): Single<DataPair?> {
|
||||
fun compareByteArrays(dv1: ByteArray, dv2: ByteArray): Int {
|
||||
val top = Math.min(dv1.size, 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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,21 +14,21 @@ import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
fun Realm.loadAllMetadata(): Map<KClass<out SearchableGalleryMetadata>, RealmResults<out SearchableGalleryMetadata>> =
|
||||
Injekt.get<SourceManager>().getOnlineSources().filterIsInstance<LewdSource<*, *>>().map {
|
||||
it.queryAll()
|
||||
}.associate {
|
||||
it.clazz to it.query(this@loadAllMetadata).sort(SearchableGalleryMetadata::mangaId.name).findAll()
|
||||
}.toMap()
|
||||
//fun Realm.loadAllMetadata(): Map<KClass<out SearchableGalleryMetadata>, RealmResults<out SearchableGalleryMetadata>> =
|
||||
// Injekt.get<SourceManager>().getOnlineSources().filterIsInstance<LewdSource<*, *>>().map {
|
||||
// it.queryAll()
|
||||
// }.associate {
|
||||
// it.clazz to it.query(this@loadAllMetadata).sort(SearchableGalleryMetadata::mangaId.name).findAll()
|
||||
// }.toMap()
|
||||
|
||||
fun Realm.queryMetadataFromManga(manga: Manga,
|
||||
meta: RealmQuery<SearchableGalleryMetadata>? = null):
|
||||
RealmQuery<out SearchableGalleryMetadata> =
|
||||
Injekt.get<SourceManager>().get(manga.source)?.let {
|
||||
(it as LewdSource<*, *>).queryFromUrl(manga.url) as GalleryQuery<SearchableGalleryMetadata>
|
||||
}?.query(this, meta) ?: throw IllegalArgumentException("Unknown source type!")
|
||||
//fun Realm.queryMetadataFromManga(manga: Manga,
|
||||
// meta: RealmQuery<SearchableGalleryMetadata>? = null):
|
||||
// RealmQuery<out SearchableGalleryMetadata> =
|
||||
// Injekt.get<SourceManager>().get(manga.source)?.let {
|
||||
// (it as LewdSource<*, *>).queryFromUrl(manga.url) as GalleryQuery<SearchableGalleryMetadata>
|
||||
// }?.query(this, meta) ?: throw IllegalArgumentException("Unknown source type!")
|
||||
|
||||
fun Realm.syncMangaIds(mangas: List<LibraryItem>) {
|
||||
/*fun Realm.syncMangaIds(mangas: List<LibraryItem>) {
|
||||
Timber.d("--> EH: Begin syncing ${mangas.size} manga IDs...")
|
||||
executeTransaction {
|
||||
mangas.forEach { manga ->
|
||||
@@ -46,7 +46,7 @@ fun Realm.syncMangaIds(mangas: List<LibraryItem>) {
|
||||
}
|
||||
}
|
||||
Timber.d("--> EH: Finish syncing ${mangas.size} manga IDs!")
|
||||
}
|
||||
}*/
|
||||
|
||||
val Manga.metadataClass
|
||||
get() = (Injekt.get<SourceManager>().get(source) as? LewdSource<*, *>)?.queryAll()?.clazz
|
||||
//val Manga.metadataClass
|
||||
// get() = (Injekt.get<SourceManager>().get(source) as? LewdSource<*, *>)?.queryAll()?.clazz
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
package exh.metadata.metadata
|
||||
|
||||
import android.net.Uri
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import exh.metadata.*
|
||||
import exh.metadata.metadata.base.RaisedSearchMetadata
|
||||
import exh.plusAssign
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import java.util.*
|
||||
|
||||
class EHentaiSearchMetadata : RaisedSearchMetadata() {
|
||||
var gId: String?
|
||||
get() = indexedExtra
|
||||
set(value) { indexedExtra = value }
|
||||
|
||||
var gToken: String? = null
|
||||
var exh: Boolean? = null
|
||||
var thumbnailUrl: String? = null
|
||||
|
||||
var title by titleDelegate(TITLE_TYPE_TITLE)
|
||||
var altTitle by titleDelegate(TITLE_TYPE_ALT_TITLE)
|
||||
|
||||
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
|
||||
|
||||
override fun copyTo(manga: SManga) {
|
||||
gId?.let { gId ->
|
||||
gToken?.let { gToken ->
|
||||
manga.url = idAndTokenToUrl(gId, gToken)
|
||||
}
|
||||
}
|
||||
thumbnailUrl?.let { manga.thumbnail_url = it }
|
||||
|
||||
//No title bug?
|
||||
val titleObj = if(Injekt.get<PreferencesHelper>().useJapaneseTitle().getOrDefault())
|
||||
altTitle ?: title
|
||||
else
|
||||
title
|
||||
titleObj?.let { manga.title = it }
|
||||
|
||||
//Set artist (if we can find one)
|
||||
tags.filter { it.namespace == EH_ARTIST_NAMESPACE }.let {
|
||||
if(it.isNotEmpty()) manga.artist = it.joinToString(transform = { it.name })
|
||||
}
|
||||
|
||||
//Copy tags -> genres
|
||||
manga.genre = tagsToGenreString()
|
||||
|
||||
//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 ->
|
||||
ONGOING_SUFFIX.find {
|
||||
t.endsWith(it, ignoreCase = true)
|
||||
}?.let {
|
||||
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()
|
||||
genre?.let { detailsDesc += "Genre: $it\n" }
|
||||
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 { detailsDesc += " ($it)" }
|
||||
detailsDesc += "\n"
|
||||
}
|
||||
|
||||
val tagsDesc = tagsToDescription()
|
||||
|
||||
manga.description = listOf(titleDesc.toString(), detailsDesc.toString(), tagsDesc.toString())
|
||||
.filter(String::isNotBlank)
|
||||
.joinToString(separator = "\n")
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TITLE_TYPE_TITLE = 0
|
||||
private const val TITLE_TYPE_ALT_TITLE = 1
|
||||
|
||||
const val TAG_TYPE_NORMAL = 0
|
||||
const val TAG_TYPE_LIGHT = 1
|
||||
|
||||
const val EH_GENRE_NAMESPACE = "genre"
|
||||
private const val EH_ARTIST_NAMESPACE = "artist"
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
fun galleryId(url: String) = splitGalleryUrl(url)[1]
|
||||
|
||||
fun galleryToken(url: String) =
|
||||
splitGalleryUrl(url)[2]
|
||||
|
||||
fun normalizeUrl(url: String)
|
||||
= idAndTokenToUrl(galleryId(url), galleryToken(url))
|
||||
|
||||
fun idAndTokenToUrl(id: String, token: String)
|
||||
= "/g/$id/$token/?nw=always"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package exh.metadata.metadata
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import exh.metadata.metadata.base.RaisedSearchMetadata
|
||||
|
||||
class HentaiCafeSearchMetadata : RaisedSearchMetadata() {
|
||||
var hcId: String? = null
|
||||
var readerId: String? = null
|
||||
|
||||
var url get() = hcId?.let { "$BASE_URL/$it" }
|
||||
set(a) {
|
||||
a?.let {
|
||||
hcId = hcIdFromUrl(a)
|
||||
}
|
||||
}
|
||||
|
||||
var thumbnailUrl: String? = null
|
||||
|
||||
var title by titleDelegate(TITLE_TYPE_MAIN)
|
||||
|
||||
var artist: String? = null
|
||||
|
||||
override fun copyTo(manga: SManga) {
|
||||
thumbnailUrl?.let { manga.thumbnail_url = it }
|
||||
|
||||
manga.title = title!!
|
||||
manga.artist = artist
|
||||
manga.author = artist
|
||||
|
||||
//Not available
|
||||
manga.status = SManga.UNKNOWN
|
||||
|
||||
val detailsDesc = "Title: $title\n" +
|
||||
"Artist: $artist\n"
|
||||
|
||||
val tagsDesc = tagsToDescription()
|
||||
|
||||
manga.genre = tagsToGenreString()
|
||||
|
||||
manga.description = listOf(detailsDesc, tagsDesc.toString())
|
||||
.filter(String::isNotBlank)
|
||||
.joinToString(separator = "\n")
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TITLE_TYPE_MAIN = 0
|
||||
|
||||
const val TAG_TYPE_DEFAULT = 0
|
||||
|
||||
val BASE_URL = "https://hentai.cafe"
|
||||
|
||||
fun hcIdFromUrl(url: String)
|
||||
= url.split("/").last { it.isNotBlank() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package exh.metadata.metadata
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import exh.metadata.EX_DATE_FORMAT
|
||||
import exh.metadata.metadata.base.RaisedSearchMetadata
|
||||
import exh.plusAssign
|
||||
import java.util.*
|
||||
|
||||
class HitomiSearchMetadata: RaisedSearchMetadata() {
|
||||
var url get() = hlId?.let { urlFromHlId(it) }
|
||||
set(a) {
|
||||
a?.let {
|
||||
hlId = hlIdFromUrl(a)
|
||||
}
|
||||
}
|
||||
|
||||
var hlId: String? = null
|
||||
|
||||
var title by titleDelegate(TITLE_TYPE_MAIN)
|
||||
|
||||
var thumbnailUrl: String? = null
|
||||
|
||||
var artists: List<String> = emptyList()
|
||||
|
||||
var group: String? = null
|
||||
|
||||
var type: String? = null
|
||||
|
||||
var language: String? = null
|
||||
|
||||
var series: List<String> = emptyList()
|
||||
|
||||
var characters: List<String> = emptyList()
|
||||
|
||||
var uploadDate: Long? = null
|
||||
|
||||
override fun copyTo(manga: SManga) {
|
||||
thumbnailUrl?.let { manga.thumbnail_url = it }
|
||||
|
||||
val titleDesc = StringBuilder()
|
||||
|
||||
title?.let {
|
||||
manga.title = it
|
||||
titleDesc += "Title: $it\n"
|
||||
}
|
||||
|
||||
val detailsDesc = StringBuilder()
|
||||
|
||||
manga.artist = artists.joinToString()
|
||||
|
||||
detailsDesc += "Artist(s): ${manga.artist}\n"
|
||||
|
||||
group?.let {
|
||||
detailsDesc += "Group: $it\n"
|
||||
}
|
||||
|
||||
type?.let {
|
||||
detailsDesc += "Type: ${it.capitalize()}\n"
|
||||
}
|
||||
|
||||
(language ?: "unknown").let {
|
||||
detailsDesc += "Language: ${it.capitalize()}\n"
|
||||
}
|
||||
|
||||
if(series.isNotEmpty())
|
||||
detailsDesc += "Series: ${series.joinToString()}\n"
|
||||
|
||||
if(characters.isNotEmpty())
|
||||
detailsDesc += "Characters: ${characters.joinToString()}\n"
|
||||
|
||||
uploadDate?.let {
|
||||
detailsDesc += "Upload date: ${EX_DATE_FORMAT.format(Date(it))}\n"
|
||||
}
|
||||
|
||||
manga.status = SManga.UNKNOWN
|
||||
|
||||
//Copy tags -> genres
|
||||
manga.genre = tagsToGenreString()
|
||||
|
||||
val tagsDesc = tagsToDescription()
|
||||
|
||||
manga.description = listOf(titleDesc.toString(), detailsDesc.toString(), tagsDesc.toString())
|
||||
.filter(String::isNotBlank)
|
||||
.joinToString(separator = "\n")
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TITLE_TYPE_MAIN = 0
|
||||
|
||||
const val TAG_TYPE_DEFAULT = 0
|
||||
|
||||
val LTN_BASE_URL = "https://ltn.hitomi.la"
|
||||
val BASE_URL = "https://hitomi.la"
|
||||
val IMG_BASE_URL = "https://aa.hitomi.la/galleries"
|
||||
|
||||
fun hlIdFromUrl(url: String)
|
||||
= url.split('/').last().substringBeforeLast('.')
|
||||
|
||||
fun urlFromHlId(id: String)
|
||||
= "$BASE_URL/galleries/$id.html"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package exh.metadata.metadata
|
||||
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import exh.metadata.*
|
||||
import exh.metadata.metadata.base.RaisedSearchMetadata
|
||||
import exh.plusAssign
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import java.util.*
|
||||
|
||||
class NHentaiSearchMetadata : RaisedSearchMetadata() {
|
||||
var url get() = nhId?.let { BASE_URL + nhIdToPath(it) }
|
||||
set(a) {
|
||||
a?.let {
|
||||
nhId = nhUrlToId(a)
|
||||
}
|
||||
}
|
||||
|
||||
var nhId: Long? = null
|
||||
|
||||
var uploadDate: Long? = null
|
||||
|
||||
var favoritesCount: Long? = null
|
||||
|
||||
var mediaId: String? = null
|
||||
|
||||
var japaneseTitle by titleDelegate(TITLE_TYPE_JAPANESE)
|
||||
var englishTitle by titleDelegate(TITLE_TYPE_ENGLISH)
|
||||
var shortTitle by titleDelegate(TITLE_TYPE_SHORT)
|
||||
|
||||
var coverImageType: String? = null
|
||||
var pageImageTypes: List<String> = emptyList()
|
||||
var thumbnailImageType: String? = null
|
||||
|
||||
var scanlator: String? = null
|
||||
|
||||
override fun copyTo(manga: SManga) {
|
||||
nhId?.let { manga.url = nhIdToPath(it) }
|
||||
|
||||
if(mediaId != null) {
|
||||
val hqThumbs = Injekt.get<PreferencesHelper>().eh_nh_useHighQualityThumbs().getOrDefault()
|
||||
typeToExtension(if(hqThumbs) coverImageType else thumbnailImageType)?.let {
|
||||
manga.thumbnail_url = "https://t.nhentai.net/galleries/$mediaId/${if(hqThumbs)
|
||||
"cover"
|
||||
else "thumb"}.$it"
|
||||
}
|
||||
}
|
||||
|
||||
manga.title = englishTitle ?: japaneseTitle ?: shortTitle!!
|
||||
|
||||
//Set artist (if we can find one)
|
||||
tags.filter { it.namespace == NHENTAI_ARTIST_NAMESPACE }.let {
|
||||
if(it.isNotEmpty()) manga.artist = it.joinToString(transform = { it.name })
|
||||
}
|
||||
|
||||
var category: String? = null
|
||||
tags.filter { it.namespace == NHENTAI_CATEGORIES_NAMESPACE }.let {
|
||||
if(it.isNotEmpty()) category = it.joinToString(transform = { it.name })
|
||||
}
|
||||
|
||||
//Copy tags -> genres
|
||||
manga.genre = tagsToGenreString()
|
||||
|
||||
//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
|
||||
englishTitle?.let { t ->
|
||||
ONGOING_SUFFIX.find {
|
||||
t.endsWith(it, ignoreCase = true)
|
||||
}?.let {
|
||||
manga.status = SManga.ONGOING
|
||||
}
|
||||
}
|
||||
|
||||
val titleDesc = StringBuilder()
|
||||
englishTitle?.let { titleDesc += "English Title: $it\n" }
|
||||
japaneseTitle?.let { titleDesc += "Japanese Title: $it\n" }
|
||||
shortTitle?.let { titleDesc += "Short Title: $it\n" }
|
||||
|
||||
val detailsDesc = StringBuilder()
|
||||
category?.let { detailsDesc += "Category: $it\n" }
|
||||
uploadDate?.let { detailsDesc += "Upload Date: ${EX_DATE_FORMAT.format(Date(it * 1000))}\n" }
|
||||
pageImageTypes.size.let { detailsDesc += "Length: $it pages\n" }
|
||||
favoritesCount?.let { detailsDesc += "Favorited: $it times\n" }
|
||||
scanlator?.nullIfBlank()?.let { detailsDesc += "Scanlator: $it\n" }
|
||||
|
||||
val tagsDesc = tagsToDescription()
|
||||
|
||||
manga.description = listOf(titleDesc.toString(), detailsDesc.toString(), tagsDesc.toString())
|
||||
.filter(String::isNotBlank)
|
||||
.joinToString(separator = "\n")
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TITLE_TYPE_JAPANESE = 0
|
||||
private const val TITLE_TYPE_ENGLISH = 1
|
||||
private const val TITLE_TYPE_SHORT = 2
|
||||
|
||||
const val TAG_TYPE_DEFAULT = 0
|
||||
|
||||
val BASE_URL = "https://nhentai.net"
|
||||
|
||||
private const val NHENTAI_ARTIST_NAMESPACE = "artist"
|
||||
private const val NHENTAI_CATEGORIES_NAMESPACE = "category"
|
||||
|
||||
fun typeToExtension(t: String?) =
|
||||
when(t) {
|
||||
"p" -> "png"
|
||||
"j" -> "jpg"
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun nhUrlToId(url: String)
|
||||
= url.split("/").last { it.isNotBlank() }.toLong()
|
||||
|
||||
fun nhIdToPath(id: Long) = "/g/$id/"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package exh.metadata.metadata
|
||||
|
||||
import android.net.Uri
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import exh.PERV_EDEN_EN_SOURCE_ID
|
||||
import exh.PERV_EDEN_IT_SOURCE_ID
|
||||
import exh.metadata.metadata.base.RaisedSearchMetadata
|
||||
import exh.metadata.metadata.base.RaisedTitle
|
||||
import exh.plusAssign
|
||||
|
||||
class PervEdenSearchMetadata : RaisedSearchMetadata() {
|
||||
var pvId: String? = null
|
||||
|
||||
var url: String? = null
|
||||
var thumbnailUrl: String? = null
|
||||
|
||||
var title by titleDelegate(TITLE_TYPE_MAIN)
|
||||
var altTitles
|
||||
get() = titles.filter { it.type == TITLE_TYPE_ALT }.map { it.title }
|
||||
set(value) {
|
||||
titles.removeAll { it.type == TITLE_TYPE_ALT }
|
||||
titles += value.map { RaisedTitle(it, TITLE_TYPE_ALT) }
|
||||
}
|
||||
|
||||
var artist: String? = null
|
||||
|
||||
var type: String? = null
|
||||
|
||||
var rating: Float? = null
|
||||
|
||||
var status: String? = null
|
||||
|
||||
var lang: String? = null
|
||||
|
||||
override fun copyTo(manga: SManga) {
|
||||
url?.let { manga.url = it }
|
||||
thumbnailUrl?.let { manga.thumbnail_url = it }
|
||||
|
||||
val titleDesc = StringBuilder()
|
||||
title?.let {
|
||||
manga.title = it
|
||||
titleDesc += "Title: $it\n"
|
||||
}
|
||||
if(altTitles.isNotEmpty())
|
||||
titleDesc += "Alternate Titles: \n" + altTitles.map {
|
||||
"▪ $it"
|
||||
}.joinToString(separator = "\n", postfix = "\n")
|
||||
|
||||
val detailsDesc = StringBuilder()
|
||||
artist?.let {
|
||||
manga.artist = it
|
||||
detailsDesc += "Artist: $it\n"
|
||||
}
|
||||
|
||||
type?.let {
|
||||
detailsDesc += "Type: $it\n"
|
||||
}
|
||||
|
||||
status?.let {
|
||||
manga.status = when(it) {
|
||||
"Ongoing" -> SManga.ONGOING
|
||||
"Completed", "Suspended" -> SManga.COMPLETED
|
||||
else -> SManga.UNKNOWN
|
||||
}
|
||||
detailsDesc += "Status: $it\n"
|
||||
}
|
||||
|
||||
rating?.let {
|
||||
detailsDesc += "Rating: %.2\n".format(it)
|
||||
}
|
||||
|
||||
//Copy tags -> genres
|
||||
manga.genre = tagsToGenreString()
|
||||
|
||||
val tagsDesc = tagsToDescription()
|
||||
|
||||
manga.description = listOf(titleDesc.toString(), detailsDesc.toString(), tagsDesc.toString())
|
||||
.filter(String::isNotBlank)
|
||||
.joinToString(separator = "\n")
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
private const val TITLE_TYPE_MAIN = 0
|
||||
private const val TITLE_TYPE_ALT = 1
|
||||
|
||||
const val TAG_TYPE_DEFAULT = 0
|
||||
|
||||
private fun splitGalleryUrl(url: String)
|
||||
= url.let {
|
||||
Uri.parse(it).pathSegments.filterNot(String::isNullOrBlank)
|
||||
}
|
||||
|
||||
fun pvIdFromUrl(url: String) = splitGalleryUrl(url).last()
|
||||
}
|
||||
}
|
||||
|
||||
enum class PervEdenLang(val id: Long) {
|
||||
//DO NOT RENAME THESE TO CAPITAL LETTERS! The enum names are used to build URLs
|
||||
en(PERV_EDEN_EN_SOURCE_ID),
|
||||
it(PERV_EDEN_IT_SOURCE_ID);
|
||||
|
||||
companion object {
|
||||
fun source(id: Long)
|
||||
= PervEdenLang.values().find { it.id == id }
|
||||
?: throw IllegalArgumentException("Unknown source ID: $id!")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package exh.metadata.metadata
|
||||
|
||||
import android.net.Uri
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import exh.metadata.EX_DATE_FORMAT
|
||||
import exh.metadata.buildTagsDescription
|
||||
import exh.metadata.joinEmulatedTagsToGenreString
|
||||
import exh.metadata.metadata.base.RaisedSearchMetadata
|
||||
import exh.plusAssign
|
||||
import java.util.*
|
||||
|
||||
class TsuminoSearchMetadata : RaisedSearchMetadata() {
|
||||
var tmId: Int? = null
|
||||
|
||||
var title by titleDelegate(TITLE_TYPE_MAIN)
|
||||
|
||||
var artist: String? = null
|
||||
|
||||
var uploadDate: Long? = null
|
||||
|
||||
var length: Int? = null
|
||||
|
||||
var ratingString: String? = null
|
||||
|
||||
var category: String? = null
|
||||
|
||||
var collection: String? = null
|
||||
|
||||
var group: String? = null
|
||||
|
||||
var parody: List<String> = emptyList()
|
||||
|
||||
var character: List<String> = emptyList()
|
||||
|
||||
override fun copyTo(manga: SManga) {
|
||||
title?.let { manga.title = it }
|
||||
manga.thumbnail_url = BASE_URL + thumbUrlFromId(tmId.toString())
|
||||
|
||||
artist?.let { manga.artist = it }
|
||||
|
||||
manga.status = SManga.UNKNOWN
|
||||
|
||||
val titleDesc = "Title: $title\n"
|
||||
|
||||
val detailsDesc = StringBuilder()
|
||||
uploader?.let { detailsDesc += "Uploader: $it\n" }
|
||||
uploadDate?.let { detailsDesc += "Uploaded: ${EX_DATE_FORMAT.format(Date(it))}\n" }
|
||||
length?.let { detailsDesc += "Length: $it pages\n" }
|
||||
ratingString?.let { detailsDesc += "Rating: $it\n" }
|
||||
category?.let {
|
||||
detailsDesc += "Category: $it\n"
|
||||
}
|
||||
collection?.let { detailsDesc += "Collection: $it\n" }
|
||||
group?.let { detailsDesc += "Group: $it\n" }
|
||||
val parodiesString = parody.joinToString()
|
||||
if(parodiesString.isNotEmpty()) {
|
||||
detailsDesc += "Parody: $parodiesString\n"
|
||||
}
|
||||
val charactersString = character.joinToString()
|
||||
if(charactersString.isNotEmpty()) {
|
||||
detailsDesc += "Character: $charactersString\n"
|
||||
}
|
||||
|
||||
//Copy tags -> genres
|
||||
manga.genre = tagsToGenreString()
|
||||
|
||||
val tagsDesc = tagsToDescription()
|
||||
|
||||
manga.description = listOf(titleDesc, detailsDesc.toString(), tagsDesc.toString())
|
||||
.filter(String::isNotBlank)
|
||||
.joinToString(separator = "\n")
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TITLE_TYPE_MAIN = 0
|
||||
|
||||
const val TAG_TYPE_DEFAULT = 0
|
||||
|
||||
val BASE_URL = "https://www.tsumino.com"
|
||||
|
||||
fun tmIdFromUrl(url: String)
|
||||
= Uri.parse(url).pathSegments[2]
|
||||
|
||||
fun mangaUrlFromId(id: String) = "/Book/Info/$id"
|
||||
|
||||
fun thumbUrlFromId(id: String) = "/Image/Thumb/$id"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package exh.metadata.metadata.base
|
||||
|
||||
import com.pushtorefresh.storio.operations.PreparedOperation
|
||||
import eu.kanade.tachiyomi.data.database.DatabaseHelper
|
||||
import exh.metadata.sql.models.SearchMetadata
|
||||
import exh.metadata.sql.models.SearchTag
|
||||
import exh.metadata.sql.models.SearchTitle
|
||||
import rx.Completable
|
||||
import rx.Single
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
data class FlatMetadata(
|
||||
val metadata: SearchMetadata,
|
||||
val tags: List<SearchTag>,
|
||||
val titles: List<SearchTitle>
|
||||
) {
|
||||
inline fun <reified T : RaisedSearchMetadata> raise(): T = raise(T::class)
|
||||
|
||||
fun <T : RaisedSearchMetadata> raise(clazz: KClass<T>)
|
||||
= RaisedSearchMetadata.raiseFlattenGson
|
||||
.fromJson(metadata.extra, clazz.java).apply {
|
||||
fillBaseFields(this@FlatMetadata)
|
||||
}
|
||||
}
|
||||
|
||||
fun DatabaseHelper.getFlatMetadataForManga(mangaId: Long): PreparedOperation<FlatMetadata?> {
|
||||
fun getSingle() = getSearchMetadataForManga(mangaId).asRxSingle().flatMap { meta ->
|
||||
if(meta == null) Single.just(null)
|
||||
else Single.zip(
|
||||
getSearchTagsForManga(mangaId).asRxSingle(),
|
||||
getSearchTitlesForManga(mangaId).asRxSingle()
|
||||
) { tags, titles ->
|
||||
FlatMetadata(meta, tags, titles)
|
||||
}
|
||||
}
|
||||
|
||||
return object : PreparedOperation<FlatMetadata?> {
|
||||
/**
|
||||
* Creates [rx.Observable] that emits result of Operation.
|
||||
*
|
||||
*
|
||||
* Observable may be "Hot" or "Cold", please read documentation of the concrete implementation.
|
||||
*
|
||||
* @return observable result of operation with only one [rx.Observer.onNext] call.
|
||||
*/
|
||||
override fun createObservable() = getSingle().toObservable()
|
||||
|
||||
/**
|
||||
* Executes operation synchronously in current thread.
|
||||
*
|
||||
*
|
||||
* Notice: Blocking I/O operation should not be executed on the Main Thread,
|
||||
* it can cause ANR (Activity Not Responding dialog), block the UI and drop animations frames.
|
||||
* So please, execute blocking I/O operation only from background thread.
|
||||
* See [WorkerThread].
|
||||
*
|
||||
* @return nullable result of operation.
|
||||
*/
|
||||
override fun executeAsBlocking() = getSingle().toBlocking().value()
|
||||
|
||||
/**
|
||||
* Creates [rx.Observable] that emits result of Operation.
|
||||
*
|
||||
*
|
||||
* Observable may be "Hot" (usually "Warm") or "Cold", please read documentation of the concrete implementation.
|
||||
*
|
||||
* @return observable result of operation with only one [rx.Observer.onNext] call.
|
||||
*/
|
||||
override fun asRxObservable() = getSingle().toObservable()
|
||||
|
||||
/**
|
||||
* Creates [rx.Single] that emits result of Operation lazily when somebody subscribes to it.
|
||||
*
|
||||
*
|
||||
*
|
||||
* @return single result of operation.
|
||||
*/
|
||||
override fun asRxSingle() = getSingle()
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
fun DatabaseHelper.insertFlatMetadata(flatMetadata: FlatMetadata) = Completable.fromCallable {
|
||||
require(flatMetadata.metadata.mangaId != -1L)
|
||||
|
||||
inTransaction {
|
||||
insertSearchMetadata(flatMetadata.metadata).executeAsBlocking()
|
||||
setSearchTagsForManga(flatMetadata.metadata.mangaId, flatMetadata.tags)
|
||||
setSearchTitlesForManga(flatMetadata.metadata.mangaId, flatMetadata.titles)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package exh.metadata.metadata.base
|
||||
|
||||
import com.google.gson.GsonBuilder
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import exh.metadata.forEach
|
||||
import exh.metadata.sql.models.SearchMetadata
|
||||
import exh.metadata.sql.models.SearchTag
|
||||
import exh.metadata.sql.models.SearchTitle
|
||||
import exh.plusAssign
|
||||
import kotlin.properties.ReadWriteProperty
|
||||
import kotlin.reflect.KProperty
|
||||
|
||||
abstract class RaisedSearchMetadata {
|
||||
@Transient
|
||||
var mangaId: Long = -1
|
||||
|
||||
@Transient
|
||||
var uploader: String? = null
|
||||
|
||||
@Transient
|
||||
protected open var indexedExtra: String? = null
|
||||
|
||||
@Transient
|
||||
val tags = mutableListOf<RaisedTag>()
|
||||
|
||||
@Transient
|
||||
val titles = mutableListOf<RaisedTitle>()
|
||||
|
||||
fun getTitleOfType(type: Int): String? = titles.find { it.type == type }?.title
|
||||
|
||||
fun replaceTitleOfType(type: Int, newTitle: String?) {
|
||||
titles.removeAll { it.type == type }
|
||||
if(newTitle != null) titles += RaisedTitle(newTitle, type)
|
||||
}
|
||||
|
||||
abstract fun copyTo(manga: SManga)
|
||||
|
||||
fun tagsToGenreString()
|
||||
= tags.filter { it.type != TAG_TYPE_VIRTUAL }
|
||||
.joinToString { (if(it.namespace != null) "${it.namespace}: " else "") + it.name }
|
||||
|
||||
fun tagsToDescription()
|
||||
= StringBuilder("Tags:\n").apply {
|
||||
//BiConsumer only available in Java 8, don't bother calling forEach directly on 'tags'
|
||||
val groupedTags = tags.filter { it.type != TAG_TYPE_VIRTUAL }.groupBy {
|
||||
it.namespace
|
||||
}.entries
|
||||
|
||||
groupedTags.forEach { namespace, tags ->
|
||||
if (tags.isNotEmpty()) {
|
||||
val joinedTags = tags.joinToString(separator = " ", transform = { "<${it.name}>" })
|
||||
if(namespace != null) {
|
||||
this += "▪ "
|
||||
this += namespace
|
||||
this += ": "
|
||||
}
|
||||
this += joinedTags
|
||||
this += "\n"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun flatten(): FlatMetadata {
|
||||
require(mangaId != -1L)
|
||||
|
||||
val extra = raiseFlattenGson.toJson(this)
|
||||
return FlatMetadata(
|
||||
SearchMetadata(
|
||||
mangaId,
|
||||
uploader,
|
||||
extra,
|
||||
indexedExtra,
|
||||
0
|
||||
),
|
||||
tags.map {
|
||||
SearchTag(
|
||||
null,
|
||||
mangaId,
|
||||
it.namespace,
|
||||
it.name,
|
||||
it.type
|
||||
)
|
||||
},
|
||||
titles.map {
|
||||
SearchTitle(
|
||||
null,
|
||||
mangaId,
|
||||
it.title,
|
||||
it.type
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun fillBaseFields(metadata: FlatMetadata) {
|
||||
mangaId = metadata.metadata.mangaId
|
||||
uploader = metadata.metadata.uploader
|
||||
indexedExtra = metadata.metadata.indexedExtra
|
||||
|
||||
this.tags.clear()
|
||||
this.tags += metadata.tags.map {
|
||||
RaisedTag(it.namespace, it.name, it.type)
|
||||
}
|
||||
|
||||
this.titles.clear()
|
||||
this.titles += metadata.titles.map {
|
||||
RaisedTitle(it.title, it.type)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
// Virtual tags allow searching of otherwise unindexed fields
|
||||
const val TAG_TYPE_VIRTUAL = -2
|
||||
|
||||
val raiseFlattenGson = GsonBuilder().create()
|
||||
|
||||
fun titleDelegate(type: Int) = object : ReadWriteProperty<RaisedSearchMetadata, String?> {
|
||||
/**
|
||||
* Returns the value of the property for the given object.
|
||||
* @param thisRef the object for which the value is requested.
|
||||
* @param property the metadata for the property.
|
||||
* @return the property value.
|
||||
*/
|
||||
override fun getValue(thisRef: RaisedSearchMetadata, property: KProperty<*>)
|
||||
= thisRef.getTitleOfType(type)
|
||||
|
||||
/**
|
||||
* Sets the value of the property for the given object.
|
||||
* @param thisRef the object for which the value is requested.
|
||||
* @param property the metadata for the property.
|
||||
* @param value the value to set.
|
||||
*/
|
||||
override fun setValue(thisRef: RaisedSearchMetadata, property: KProperty<*>, value: String?)
|
||||
= thisRef.replaceTitleOfType(type, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package exh.metadata.metadata.base
|
||||
|
||||
data class RaisedTag(val namespace: String?,
|
||||
val name: String,
|
||||
val type: Int)
|
||||
@@ -0,0 +1,6 @@
|
||||
package exh.metadata.metadata.base
|
||||
|
||||
data class RaisedTitle(
|
||||
val title: String,
|
||||
val type: Int = 0
|
||||
)
|
||||
@@ -29,7 +29,7 @@ open class NHentaiMetadata : RealmObject(), SearchableGalleryMetadata {
|
||||
var url get() = nhId?.let { "$BASE_URL/g/$it" }
|
||||
set(a) {
|
||||
a?.let {
|
||||
nhId = nhIdFromUrl(a)
|
||||
nhId = nhUrlToId(a)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ open class NHentaiMetadata : RealmObject(), SearchableGalleryMetadata {
|
||||
val url: String
|
||||
) : GalleryQuery<NHentaiMetadata>(NHentaiMetadata::class) {
|
||||
override fun transform() = Query(
|
||||
nhIdFromUrl(url)
|
||||
nhUrlToId(url)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -154,7 +154,7 @@ open class NHentaiMetadata : RealmObject(), SearchableGalleryMetadata {
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun nhIdFromUrl(url: String)
|
||||
fun nhUrlToId(url: String)
|
||||
= url.split("/").last { it.isNotBlank() }.toLong()
|
||||
|
||||
val TITLE_FIELDS = listOf(
|
||||
|
||||
@@ -104,29 +104,6 @@ open class PervEdenGalleryMetadata : RealmObject(), SearchableGalleryMetadata {
|
||||
.joinToString(separator = "\n")
|
||||
}
|
||||
|
||||
class EmptyQuery : GalleryQuery<PervEdenGalleryMetadata>(PervEdenGalleryMetadata::class)
|
||||
|
||||
class UrlQuery(
|
||||
val url: String,
|
||||
val lang: PervEdenLang
|
||||
) : GalleryQuery<PervEdenGalleryMetadata>(PervEdenGalleryMetadata::class) {
|
||||
override fun transform() = Query(
|
||||
pvIdFromUrl(url),
|
||||
lang
|
||||
)
|
||||
}
|
||||
|
||||
class Query(val pvId: String,
|
||||
val lang: PervEdenLang
|
||||
) : GalleryQuery<PervEdenGalleryMetadata>(PervEdenGalleryMetadata::class) {
|
||||
override fun map() = mapOf(
|
||||
PervEdenGalleryMetadata::pvId to Query::pvId
|
||||
)
|
||||
|
||||
override fun override(meta: RealmQuery<PervEdenGalleryMetadata>)
|
||||
= meta.equalTo(PervEdenGalleryMetadata::lang.name, lang.name)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private fun splitGalleryUrl(url: String)
|
||||
= url.let {
|
||||
@@ -165,15 +142,3 @@ open class PervEdenTitle(var metadata: PervEdenGalleryMetadata? = null,
|
||||
|
||||
override fun toString() = "PervEdenTitle(metadata=$metadata, title=$title)"
|
||||
}
|
||||
|
||||
enum class PervEdenLang(val id: Long) {
|
||||
//DO NOT RENAME THESE TO CAPITAL LETTERS! The enum names are used to build URLs
|
||||
en(PERV_EDEN_EN_SOURCE_ID),
|
||||
it(PERV_EDEN_IT_SOURCE_ID);
|
||||
|
||||
companion object {
|
||||
fun source(id: Long)
|
||||
= PervEdenLang.values().find { it.id == id }
|
||||
?: throw IllegalArgumentException("Unknown source ID: $id!")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package exh.metadata.sql.mappers
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.database.Cursor
|
||||
import com.pushtorefresh.storio.sqlite.SQLiteTypeMapping
|
||||
import com.pushtorefresh.storio.sqlite.operations.delete.DefaultDeleteResolver
|
||||
import com.pushtorefresh.storio.sqlite.operations.get.DefaultGetResolver
|
||||
import com.pushtorefresh.storio.sqlite.operations.put.DefaultPutResolver
|
||||
import com.pushtorefresh.storio.sqlite.queries.DeleteQuery
|
||||
import com.pushtorefresh.storio.sqlite.queries.InsertQuery
|
||||
import com.pushtorefresh.storio.sqlite.queries.UpdateQuery
|
||||
import exh.metadata.sql.models.SearchMetadata
|
||||
import exh.metadata.sql.tables.SearchMetadataTable.COL_EXTRA
|
||||
import exh.metadata.sql.tables.SearchMetadataTable.COL_EXTRA_VERSION
|
||||
import exh.metadata.sql.tables.SearchMetadataTable.COL_INDEXED_EXTRA
|
||||
import exh.metadata.sql.tables.SearchMetadataTable.COL_MANGA_ID
|
||||
import exh.metadata.sql.tables.SearchMetadataTable.COL_UPLOADER
|
||||
import exh.metadata.sql.tables.SearchMetadataTable.TABLE
|
||||
|
||||
class SearchMetadataTypeMapping : SQLiteTypeMapping<SearchMetadata>(
|
||||
SearchMetadataPutResolver(),
|
||||
SearchMetadataGetResolver(),
|
||||
SearchMetadataDeleteResolver()
|
||||
)
|
||||
|
||||
class SearchMetadataPutResolver : DefaultPutResolver<SearchMetadata>() {
|
||||
|
||||
override fun mapToInsertQuery(obj: SearchMetadata) = InsertQuery.builder()
|
||||
.table(TABLE)
|
||||
.build()
|
||||
|
||||
override fun mapToUpdateQuery(obj: SearchMetadata) = UpdateQuery.builder()
|
||||
.table(TABLE)
|
||||
.where("$COL_MANGA_ID = ?")
|
||||
.whereArgs(obj.mangaId)
|
||||
.build()
|
||||
|
||||
override fun mapToContentValues(obj: SearchMetadata) = ContentValues(5).apply {
|
||||
put(COL_MANGA_ID, obj.mangaId)
|
||||
put(COL_UPLOADER, obj.uploader)
|
||||
put(COL_EXTRA, obj.extra)
|
||||
put(COL_INDEXED_EXTRA, obj.indexedExtra)
|
||||
put(COL_EXTRA_VERSION, obj.extraVersion)
|
||||
}
|
||||
}
|
||||
|
||||
class SearchMetadataGetResolver : DefaultGetResolver<SearchMetadata>() {
|
||||
|
||||
override fun mapFromCursor(cursor: Cursor): SearchMetadata = SearchMetadata(
|
||||
mangaId = cursor.getLong(cursor.getColumnIndex(COL_MANGA_ID)),
|
||||
uploader = cursor.getString(cursor.getColumnIndex(COL_UPLOADER)),
|
||||
extra = cursor.getString(cursor.getColumnIndex(COL_EXTRA)),
|
||||
indexedExtra = cursor.getString(cursor.getColumnIndex(COL_INDEXED_EXTRA)),
|
||||
extraVersion = cursor.getInt(cursor.getColumnIndex(COL_EXTRA_VERSION))
|
||||
)
|
||||
}
|
||||
|
||||
class SearchMetadataDeleteResolver : DefaultDeleteResolver<SearchMetadata>() {
|
||||
|
||||
override fun mapToDeleteQuery(obj: SearchMetadata) = DeleteQuery.builder()
|
||||
.table(TABLE)
|
||||
.where("$COL_MANGA_ID = ?")
|
||||
.whereArgs(obj.mangaId)
|
||||
.build()
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package exh.metadata.sql.mappers
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.database.Cursor
|
||||
import com.pushtorefresh.storio.sqlite.SQLiteTypeMapping
|
||||
import com.pushtorefresh.storio.sqlite.operations.delete.DefaultDeleteResolver
|
||||
import com.pushtorefresh.storio.sqlite.operations.get.DefaultGetResolver
|
||||
import com.pushtorefresh.storio.sqlite.operations.put.DefaultPutResolver
|
||||
import com.pushtorefresh.storio.sqlite.queries.DeleteQuery
|
||||
import com.pushtorefresh.storio.sqlite.queries.InsertQuery
|
||||
import com.pushtorefresh.storio.sqlite.queries.UpdateQuery
|
||||
import exh.metadata.sql.models.SearchTag
|
||||
import exh.metadata.sql.tables.SearchTagTable.COL_ID
|
||||
import exh.metadata.sql.tables.SearchTagTable.COL_MANGA_ID
|
||||
import exh.metadata.sql.tables.SearchTagTable.COL_NAME
|
||||
import exh.metadata.sql.tables.SearchTagTable.COL_NAMESPACE
|
||||
import exh.metadata.sql.tables.SearchTagTable.COL_TYPE
|
||||
import exh.metadata.sql.tables.SearchTagTable.TABLE
|
||||
|
||||
class SearchTagTypeMapping : SQLiteTypeMapping<SearchTag>(
|
||||
SearchTagPutResolver(),
|
||||
SearchTagGetResolver(),
|
||||
SearchTagDeleteResolver()
|
||||
)
|
||||
|
||||
class SearchTagPutResolver : DefaultPutResolver<SearchTag>() {
|
||||
|
||||
override fun mapToInsertQuery(obj: SearchTag) = InsertQuery.builder()
|
||||
.table(TABLE)
|
||||
.build()
|
||||
|
||||
override fun mapToUpdateQuery(obj: SearchTag) = UpdateQuery.builder()
|
||||
.table(TABLE)
|
||||
.where("$COL_ID = ?")
|
||||
.whereArgs(obj.id)
|
||||
.build()
|
||||
|
||||
override fun mapToContentValues(obj: SearchTag) = ContentValues(5).apply {
|
||||
put(COL_ID, obj.id)
|
||||
put(COL_MANGA_ID, obj.mangaId)
|
||||
put(COL_NAMESPACE, obj.namespace)
|
||||
put(COL_NAME, obj.name)
|
||||
put(COL_TYPE, obj.type)
|
||||
}
|
||||
}
|
||||
|
||||
class SearchTagGetResolver : DefaultGetResolver<SearchTag>() {
|
||||
|
||||
override fun mapFromCursor(cursor: Cursor): SearchTag = SearchTag(
|
||||
id = cursor.getLong(cursor.getColumnIndex(COL_ID)),
|
||||
mangaId = cursor.getLong(cursor.getColumnIndex(COL_MANGA_ID)),
|
||||
namespace = cursor.getString(cursor.getColumnIndex(COL_NAMESPACE)),
|
||||
name = cursor.getString(cursor.getColumnIndex(COL_NAME)),
|
||||
type = cursor.getInt(cursor.getColumnIndex(COL_TYPE))
|
||||
)
|
||||
}
|
||||
|
||||
class SearchTagDeleteResolver : DefaultDeleteResolver<SearchTag>() {
|
||||
|
||||
override fun mapToDeleteQuery(obj: SearchTag) = DeleteQuery.builder()
|
||||
.table(TABLE)
|
||||
.where("$COL_ID = ?")
|
||||
.whereArgs(obj.id)
|
||||
.build()
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package exh.metadata.sql.mappers
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.database.Cursor
|
||||
import com.pushtorefresh.storio.sqlite.SQLiteTypeMapping
|
||||
import com.pushtorefresh.storio.sqlite.operations.delete.DefaultDeleteResolver
|
||||
import com.pushtorefresh.storio.sqlite.operations.get.DefaultGetResolver
|
||||
import com.pushtorefresh.storio.sqlite.operations.put.DefaultPutResolver
|
||||
import com.pushtorefresh.storio.sqlite.queries.DeleteQuery
|
||||
import com.pushtorefresh.storio.sqlite.queries.InsertQuery
|
||||
import com.pushtorefresh.storio.sqlite.queries.UpdateQuery
|
||||
import exh.metadata.sql.models.SearchTitle
|
||||
import exh.metadata.sql.tables.SearchTitleTable.COL_ID
|
||||
import exh.metadata.sql.tables.SearchTitleTable.COL_MANGA_ID
|
||||
import exh.metadata.sql.tables.SearchTitleTable.COL_TITLE
|
||||
import exh.metadata.sql.tables.SearchTitleTable.COL_TYPE
|
||||
import exh.metadata.sql.tables.SearchTitleTable.TABLE
|
||||
|
||||
class SearchTitleTypeMapping : SQLiteTypeMapping<SearchTitle>(
|
||||
SearchTitlePutResolver(),
|
||||
SearchTitleGetResolver(),
|
||||
SearchTitleDeleteResolver()
|
||||
)
|
||||
|
||||
class SearchTitlePutResolver : DefaultPutResolver<SearchTitle>() {
|
||||
|
||||
override fun mapToInsertQuery(obj: SearchTitle) = InsertQuery.builder()
|
||||
.table(TABLE)
|
||||
.build()
|
||||
|
||||
override fun mapToUpdateQuery(obj: SearchTitle) = UpdateQuery.builder()
|
||||
.table(TABLE)
|
||||
.where("$COL_ID = ?")
|
||||
.whereArgs(obj.id)
|
||||
.build()
|
||||
|
||||
override fun mapToContentValues(obj: SearchTitle) = ContentValues(4).apply {
|
||||
put(COL_ID, obj.id)
|
||||
put(COL_MANGA_ID, obj.mangaId)
|
||||
put(COL_TITLE, obj.title)
|
||||
put(COL_TYPE, obj.type)
|
||||
}
|
||||
}
|
||||
|
||||
class SearchTitleGetResolver : DefaultGetResolver<SearchTitle>() {
|
||||
|
||||
override fun mapFromCursor(cursor: Cursor): SearchTitle = SearchTitle(
|
||||
id = cursor.getLong(cursor.getColumnIndex(COL_ID)),
|
||||
mangaId = cursor.getLong(cursor.getColumnIndex(COL_MANGA_ID)),
|
||||
title = cursor.getString(cursor.getColumnIndex(COL_TITLE)),
|
||||
type = cursor.getInt(cursor.getColumnIndex(COL_TYPE))
|
||||
)
|
||||
}
|
||||
|
||||
class SearchTitleDeleteResolver : DefaultDeleteResolver<SearchTitle>() {
|
||||
|
||||
override fun mapToDeleteQuery(obj: SearchTitle) = DeleteQuery.builder()
|
||||
.table(TABLE)
|
||||
.where("$COL_ID = ?")
|
||||
.whereArgs(obj.id)
|
||||
.build()
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package exh.metadata.sql.models
|
||||
|
||||
data class SearchMetadata(
|
||||
// Manga ID this gallery is linked to
|
||||
val mangaId: Long,
|
||||
|
||||
// Gallery uploader
|
||||
val uploader: String?,
|
||||
|
||||
// Extra data attached to this metadata, in JSON format
|
||||
val extra: String,
|
||||
|
||||
// Indexed extra data attached to this metadata
|
||||
val indexedExtra: String?,
|
||||
|
||||
// The version of this metadata's extra. Used to track changes to the 'extra' field's schema
|
||||
val extraVersion: Int
|
||||
) {
|
||||
// Transient information attached to this piece of metadata, useful for caching
|
||||
var transientCache: Map<String, Any>? = null
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package exh.metadata.sql.models
|
||||
|
||||
data class SearchTag(
|
||||
// Tag identifier, unique
|
||||
val id: Long?,
|
||||
|
||||
// Metadata this tag is attached to
|
||||
val mangaId: Long,
|
||||
|
||||
// Tag namespace
|
||||
val namespace: String?,
|
||||
|
||||
// Tag name
|
||||
val name: String,
|
||||
|
||||
// Tag type
|
||||
val type: Int
|
||||
)
|
||||
@@ -0,0 +1,15 @@
|
||||
package exh.metadata.sql.models
|
||||
|
||||
data class SearchTitle(
|
||||
// Title identifier, unique
|
||||
val id: Long?,
|
||||
|
||||
// Metadata this title is attached to
|
||||
val mangaId: Long,
|
||||
|
||||
// Title
|
||||
val title: String,
|
||||
|
||||
// Title type, useful for distinguishing between main/alt titles
|
||||
val type: Int
|
||||
)
|
||||
@@ -0,0 +1,36 @@
|
||||
package exh.metadata.sql.queries
|
||||
|
||||
import com.pushtorefresh.storio.sqlite.queries.DeleteQuery
|
||||
import com.pushtorefresh.storio.sqlite.queries.Query
|
||||
import eu.kanade.tachiyomi.data.database.DbProvider
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import exh.metadata.sql.models.SearchMetadata
|
||||
import exh.metadata.sql.tables.SearchMetadataTable
|
||||
|
||||
interface SearchMetadataQueries : DbProvider {
|
||||
|
||||
fun getSearchMetadataForManga(mangaId: Long) = db.get()
|
||||
.`object`(SearchMetadata::class.java)
|
||||
.withQuery(Query.builder()
|
||||
.table(SearchMetadataTable.TABLE)
|
||||
.where("${SearchMetadataTable.COL_MANGA_ID} = ?")
|
||||
.whereArgs(mangaId)
|
||||
.build())
|
||||
.prepare()
|
||||
|
||||
fun getSearchMetadata() = db.get()
|
||||
.listOfObjects(SearchMetadata::class.java)
|
||||
.withQuery(Query.builder()
|
||||
.table(SearchMetadataTable.TABLE)
|
||||
.build())
|
||||
.prepare()
|
||||
|
||||
fun insertSearchMetadata(metadata: SearchMetadata) = db.put().`object`(metadata).prepare()
|
||||
|
||||
fun deleteSearchMetadata(metadata: SearchMetadata) = db.delete().`object`(metadata).prepare()
|
||||
|
||||
fun deleteAllSearchMetadata() = db.delete().byQuery(DeleteQuery.builder()
|
||||
.table(SearchMetadataTable.TABLE)
|
||||
.build())
|
||||
.prepare()
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package exh.metadata.sql.queries
|
||||
|
||||
import com.pushtorefresh.storio.sqlite.queries.DeleteQuery
|
||||
import com.pushtorefresh.storio.sqlite.queries.Query
|
||||
import eu.kanade.tachiyomi.data.database.DbProvider
|
||||
import eu.kanade.tachiyomi.data.database.inTransaction
|
||||
import exh.metadata.sql.models.SearchTag
|
||||
import exh.metadata.sql.tables.SearchTagTable
|
||||
|
||||
interface SearchTagQueries : DbProvider {
|
||||
fun getSearchTagsForManga(mangaId: Long) = db.get()
|
||||
.listOfObjects(SearchTag::class.java)
|
||||
.withQuery(Query.builder()
|
||||
.table(SearchTagTable.TABLE)
|
||||
.where("${SearchTagTable.COL_MANGA_ID} = ?")
|
||||
.whereArgs(mangaId)
|
||||
.build())
|
||||
.prepare()
|
||||
|
||||
fun deleteSearchTagsForManga(mangaId: Long) = db.delete()
|
||||
.byQuery(DeleteQuery.builder()
|
||||
.table(SearchTagTable.TABLE)
|
||||
.where("${SearchTagTable.COL_MANGA_ID} = ?")
|
||||
.whereArgs(mangaId)
|
||||
.build())
|
||||
.prepare()
|
||||
|
||||
fun insertSearchTag(searchTag: SearchTag) = db.put().`object`(searchTag).prepare()
|
||||
|
||||
fun insertSearchTags(searchTags: List<SearchTag>) = db.put().objects(searchTags).prepare()
|
||||
|
||||
fun deleteSearchTag(searchTag: SearchTag) = db.delete().`object`(searchTag).prepare()
|
||||
|
||||
fun deleteAllSearchTags() = db.delete().byQuery(DeleteQuery.builder()
|
||||
.table(SearchTagTable.TABLE)
|
||||
.build())
|
||||
.prepare()
|
||||
|
||||
fun setSearchTagsForManga(mangaId: Long, tags: List<SearchTag>) {
|
||||
db.inTransaction {
|
||||
deleteSearchTagsForManga(mangaId).executeAsBlocking()
|
||||
insertSearchTags(tags).executeAsBlocking()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package exh.metadata.sql.queries
|
||||
|
||||
import com.pushtorefresh.storio.sqlite.queries.DeleteQuery
|
||||
import com.pushtorefresh.storio.sqlite.queries.Query
|
||||
import eu.kanade.tachiyomi.data.database.DbProvider
|
||||
import eu.kanade.tachiyomi.data.database.inTransaction
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import exh.metadata.sql.models.SearchMetadata
|
||||
import exh.metadata.sql.models.SearchTitle
|
||||
import exh.metadata.sql.tables.SearchTitleTable
|
||||
|
||||
interface SearchTitleQueries : DbProvider {
|
||||
fun getSearchTitlesForManga(mangaId: Long) = db.get()
|
||||
.listOfObjects(SearchTitle::class.java)
|
||||
.withQuery(Query.builder()
|
||||
.table(SearchTitleTable.TABLE)
|
||||
.where("${SearchTitleTable.COL_MANGA_ID} = ?")
|
||||
.whereArgs(mangaId)
|
||||
.build())
|
||||
.prepare()
|
||||
|
||||
fun deleteSearchTitlesForManga(mangaId: Long) = db.delete()
|
||||
.byQuery(DeleteQuery.builder()
|
||||
.table(SearchTitleTable.TABLE)
|
||||
.where("${SearchTitleTable.COL_MANGA_ID} = ?")
|
||||
.whereArgs(mangaId)
|
||||
.build())
|
||||
.prepare()
|
||||
|
||||
fun insertSearchTitle(searchTitle: SearchTitle) = db.put().`object`(searchTitle).prepare()
|
||||
|
||||
fun insertSearchTitles(searchTitles: List<SearchTitle>) = db.put().objects(searchTitles).prepare()
|
||||
|
||||
fun deleteSearchTitle(searchTitle: SearchTitle) = db.delete().`object`(searchTitle).prepare()
|
||||
|
||||
fun deleteAllSearchTitle() = db.delete().byQuery(DeleteQuery.builder()
|
||||
.table(SearchTitleTable.TABLE)
|
||||
.build())
|
||||
.prepare()
|
||||
|
||||
fun setSearchTitlesForManga(mangaId: Long, titles: List<SearchTitle>) {
|
||||
db.inTransaction {
|
||||
deleteSearchTitlesForManga(mangaId).executeAsBlocking()
|
||||
insertSearchTitles(titles).executeAsBlocking()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package exh.metadata.sql.tables
|
||||
|
||||
import eu.kanade.tachiyomi.data.database.tables.MangaTable
|
||||
|
||||
object SearchMetadataTable {
|
||||
const val TABLE = "search_metadata"
|
||||
|
||||
const val COL_MANGA_ID = "manga_id"
|
||||
|
||||
const val COL_UPLOADER = "uploader"
|
||||
|
||||
const val COL_EXTRA = "extra"
|
||||
|
||||
const val COL_INDEXED_EXTRA = "indexed_extra"
|
||||
|
||||
const val COL_EXTRA_VERSION = "extra_version"
|
||||
|
||||
// Insane foreign, primary key to avoid touch manga table
|
||||
val createTableQuery: String
|
||||
get() = """CREATE TABLE $TABLE(
|
||||
$COL_MANGA_ID INTEGER NOT NULL PRIMARY KEY,
|
||||
$COL_UPLOADER TEXT,
|
||||
$COL_EXTRA TEXT NOT NULL,
|
||||
$COL_INDEXED_EXTRA TEXT,
|
||||
$COL_EXTRA_VERSION INT NOT NULL,
|
||||
FOREIGN KEY($COL_MANGA_ID) REFERENCES ${MangaTable.TABLE} (${MangaTable.COL_ID})
|
||||
ON DELETE CASCADE
|
||||
)"""
|
||||
|
||||
val createUploaderIndexQuery: String
|
||||
get() = "CREATE INDEX ${TABLE}_${COL_UPLOADER}_index ON $TABLE($COL_UPLOADER)"
|
||||
|
||||
val createIndexedExtraIndexQuery: String
|
||||
get() = "CREATE INDEX ${TABLE}_${COL_INDEXED_EXTRA}_index ON $TABLE($COL_INDEXED_EXTRA)"
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package exh.metadata.sql.tables
|
||||
|
||||
import eu.kanade.tachiyomi.data.database.tables.MangaTable
|
||||
|
||||
object SearchTagTable {
|
||||
const val TABLE = "search_tags"
|
||||
|
||||
const val COL_ID = "_id"
|
||||
|
||||
const val COL_MANGA_ID = "manga_id"
|
||||
|
||||
const val COL_NAMESPACE = "namespace"
|
||||
|
||||
const val COL_NAME = "name"
|
||||
|
||||
const val COL_TYPE = "type"
|
||||
|
||||
val createTableQuery: String
|
||||
get() = """CREATE TABLE $TABLE(
|
||||
$COL_ID INTEGER NOT NULL PRIMARY KEY,
|
||||
$COL_MANGA_ID INTEGER NOT NULL,
|
||||
$COL_NAMESPACE TEXT,
|
||||
$COL_NAME TEXT NOT NULL,
|
||||
$COL_TYPE INT NOT NULL,
|
||||
FOREIGN KEY($COL_MANGA_ID) REFERENCES ${MangaTable.TABLE} (${MangaTable.COL_ID})
|
||||
ON DELETE CASCADE
|
||||
)"""
|
||||
|
||||
val createMangaIdIndexQuery: String
|
||||
get() = "CREATE INDEX ${TABLE}_${COL_MANGA_ID}_index ON $TABLE($COL_MANGA_ID)"
|
||||
|
||||
val createNamespaceNameIndexQuery: String
|
||||
get() = "CREATE INDEX ${TABLE}_${COL_NAMESPACE}_${COL_NAME}_index ON $TABLE($COL_NAMESPACE, $COL_NAME)"
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package exh.metadata.sql.tables
|
||||
|
||||
import eu.kanade.tachiyomi.data.database.tables.MangaTable
|
||||
|
||||
object SearchTitleTable {
|
||||
const val TABLE = "search_titles"
|
||||
|
||||
const val COL_ID = "_id"
|
||||
|
||||
const val COL_MANGA_ID = "manga_id"
|
||||
|
||||
const val COL_TITLE = "title"
|
||||
|
||||
const val COL_TYPE = "type"
|
||||
|
||||
val createTableQuery: String
|
||||
get() = """CREATE TABLE $TABLE(
|
||||
$COL_ID INTEGER NOT NULL PRIMARY KEY,
|
||||
$COL_MANGA_ID INTEGER NOT NULL,
|
||||
$COL_TITLE TEXT NOT NULL,
|
||||
$COL_TYPE INT NOT NULL,
|
||||
FOREIGN KEY($COL_MANGA_ID) REFERENCES ${MangaTable.TABLE} (${MangaTable.COL_ID})
|
||||
ON DELETE CASCADE
|
||||
)"""
|
||||
|
||||
val createMangaIdIndexQuery: String
|
||||
get() = "CREATE INDEX ${TABLE}_${COL_MANGA_ID}_index ON $TABLE($COL_MANGA_ID)"
|
||||
|
||||
val createTitleIndexQuery: String
|
||||
get() = "CREATE INDEX ${TABLE}_${COL_TITLE}_index ON $TABLE($COL_TITLE)"
|
||||
}
|
||||
@@ -1,3 +1,3 @@
|
||||
package exh.search
|
||||
|
||||
class MultiWildcard : TextComponent()
|
||||
class MultiWildcard(rawText: String) : TextComponent(rawText)
|
||||
|
||||
@@ -1,95 +1,122 @@
|
||||
package exh.search
|
||||
|
||||
import exh.metadata.models.SearchableGalleryMetadata
|
||||
import exh.metadata.models.Tag
|
||||
import io.realm.Case
|
||||
import io.realm.RealmQuery
|
||||
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 <T : SearchableGalleryMetadata> filterResults(rQuery: RealmQuery<T>,
|
||||
query: List<QueryComponent>,
|
||||
titleFields: List<String>):
|
||||
RealmQuery<T> {
|
||||
var queryEmpty = true
|
||||
|
||||
fun matchTagList(namespace: String?,
|
||||
component: Text?,
|
||||
excluded: Boolean) {
|
||||
when {
|
||||
excluded -> rQuery.not()
|
||||
queryEmpty -> queryEmpty = false
|
||||
else -> rQuery.or()
|
||||
}
|
||||
|
||||
rQuery.beginGroup()
|
||||
//Match namespace if specified
|
||||
namespace?.let {
|
||||
rQuery.equalTo("${SearchableGalleryMetadata::tags.name}.${Tag::namespace.name}",
|
||||
it,
|
||||
Case.INSENSITIVE)
|
||||
}
|
||||
//Match tag name if specified
|
||||
component?.let {
|
||||
rQuery.beginGroup()
|
||||
val q = if (!it.exact)
|
||||
fun textToSubQueries(namespace: String?,
|
||||
component: Text?): Pair<String, List<String>>? {
|
||||
val maybeLenientComponent = component?.let {
|
||||
if (!it.exact)
|
||||
it.asLenientTagQueries()
|
||||
else
|
||||
listOf(it.asQuery())
|
||||
q.forEachIndexed { index, s ->
|
||||
if(index > 0)
|
||||
rQuery.or()
|
||||
|
||||
rQuery.like("${SearchableGalleryMetadata::tags.name}.${Tag::name.name}", s, Case.INSENSITIVE)
|
||||
}
|
||||
rQuery.endGroup()
|
||||
}
|
||||
rQuery.endGroup()
|
||||
}
|
||||
val componentTagQuery = maybeLenientComponent?.let {
|
||||
val params = mutableListOf<String>()
|
||||
it.map { q ->
|
||||
params += q
|
||||
"${SearchTagTable.TABLE}.${SearchTagTable.COL_NAME} LIKE ?"
|
||||
}.joinToString(separator = " OR ", prefix = "(", postfix = ")") to params
|
||||
}
|
||||
return if(namespace != null) {
|
||||
var query = """
|
||||
(SELECT ${SearchTagTable.COL_MANGA_ID} AS $COL_MANGA_ID FROM ${SearchTagTable.TABLE}
|
||||
WHERE ${SearchTagTable.COL_NAMESPACE} IS NOT NULL
|
||||
AND ${SearchTagTable.COL_NAMESPACE} LIKE ?
|
||||
""".trimIndent()
|
||||
val params = mutableListOf(escapeLike(namespace))
|
||||
if(componentTagQuery != null) {
|
||||
query += "\n AND ${componentTagQuery.first}"
|
||||
params += componentTagQuery.second
|
||||
}
|
||||
|
||||
for(component in query) {
|
||||
if(component is Text) {
|
||||
if(component.excluded)
|
||||
rQuery.not()
|
||||
"$query)" to params
|
||||
} else if(component != null) {
|
||||
// Match title + tags
|
||||
val tagQuery = """
|
||||
SELECT ${SearchTagTable.COL_MANGA_ID} AS $COL_MANGA_ID FROM ${SearchTagTable.TABLE}
|
||||
WHERE ${componentTagQuery!!.first}
|
||||
""".trimIndent() to componentTagQuery.second
|
||||
|
||||
rQuery.beginGroup()
|
||||
val titleQuery = """
|
||||
SELECT ${SearchTitleTable.COL_MANGA_ID} AS $COL_MANGA_ID FROM ${SearchTitleTable.TABLE}
|
||||
WHERE ${SearchTitleTable.COL_TITLE} LIKE ?
|
||||
""".trimIndent() to listOf(component.asLenientTitleQuery())
|
||||
|
||||
//Match title
|
||||
titleFields.forEachIndexed { index, s ->
|
||||
queryEmpty = false
|
||||
if(index > 0)
|
||||
rQuery.or()
|
||||
"(${tagQuery.first} UNION ${titleQuery.first})".trimIndent() to
|
||||
(tagQuery.second + titleQuery.second)
|
||||
} else null
|
||||
}
|
||||
|
||||
rQuery.like(s, component.asLenientTitleQuery(), Case.INSENSITIVE)
|
||||
}
|
||||
fun queryToSql(q: List<QueryComponent>): Pair<String, List<String>> {
|
||||
val wheres = mutableListOf<String>()
|
||||
val whereParams = mutableListOf<String>()
|
||||
|
||||
//Match tags
|
||||
matchTagList(null, component, false) //We already deal with exclusions here
|
||||
rQuery.endGroup()
|
||||
val include = mutableListOf<Pair<String, List<String>>>()
|
||||
val exclude = mutableListOf<Pair<String, List<String>>>()
|
||||
|
||||
for(component in q) {
|
||||
val query = if(component is Text) {
|
||||
textToSubQueries(null, component)
|
||||
} else if(component is Namespace) {
|
||||
if(component.namespace == "uploader") {
|
||||
queryEmpty = false
|
||||
//Match uploader
|
||||
rQuery.equalTo(SearchableGalleryMetadata::uploader.name,
|
||||
component.tag!!.rawTextOnly(),
|
||||
Case.INSENSITIVE)
|
||||
wheres += "meta.${SearchMetadataTable.COL_UPLOADER} LIKE ?"
|
||||
whereParams += component.tag!!.rawTextEscapedForLike()
|
||||
null
|
||||
} else {
|
||||
if(component.tag!!.components.size > 0) {
|
||||
//Match namespace + tags
|
||||
matchTagList(component.namespace, component.tag!!, component.tag!!.excluded)
|
||||
textToSubQueries(component.namespace, component.tag)
|
||||
} else {
|
||||
//Perform namespace search
|
||||
matchTagList(component.namespace, null, component.excluded)
|
||||
textToSubQueries(component.namespace, null)
|
||||
}
|
||||
}
|
||||
} else error("Unknown query component!")
|
||||
|
||||
if(query != null) {
|
||||
(if(component.excluded) exclude else include) += query
|
||||
}
|
||||
}
|
||||
return rQuery
|
||||
|
||||
val completeParams = mutableListOf<String>()
|
||||
var baseQuery = """
|
||||
SELECT ${SearchMetadataTable.COL_MANGA_ID}
|
||||
FROM ${SearchMetadataTable.TABLE} meta
|
||||
""".trimIndent()
|
||||
|
||||
include.forEachIndexed { index, pair ->
|
||||
baseQuery += "\n" + ("""
|
||||
INNER JOIN ${pair.first} i$index
|
||||
ON i$index.$COL_MANGA_ID = meta.${SearchMetadataTable.COL_MANGA_ID}
|
||||
""".trimIndent())
|
||||
completeParams += pair.second
|
||||
}
|
||||
|
||||
|
||||
exclude.forEach {
|
||||
wheres += """
|
||||
(meta.${SearchMetadataTable.COL_MANGA_ID} NOT IN ${it.first})
|
||||
""".trimIndent()
|
||||
whereParams += it.second
|
||||
}
|
||||
if(wheres.isNotEmpty()) {
|
||||
completeParams += whereParams
|
||||
baseQuery += "\nWHERE\n"
|
||||
baseQuery += wheres.joinToString("\nAND\n")
|
||||
}
|
||||
baseQuery += "\nORDER BY ${SearchMetadataTable.COL_MANGA_ID}"
|
||||
|
||||
return baseQuery to completeParams
|
||||
}
|
||||
|
||||
fun parseQuery(query: String) = queryCache.getOrPut(query, {
|
||||
fun parseQuery(query: String) = queryCache.getOrPut(query) {
|
||||
val res = mutableListOf<QueryComponent>()
|
||||
|
||||
var inQuotes = false
|
||||
@@ -130,10 +157,10 @@ class SearchEngine {
|
||||
inQuotes = !inQuotes
|
||||
} else if(char == '?' || char == '_') {
|
||||
flushText()
|
||||
queuedText.add(SingleWildcard())
|
||||
queuedText.add(SingleWildcard(char.toString()))
|
||||
} else if(char == '*' || char == '%') {
|
||||
flushText()
|
||||
queuedText.add(MultiWildcard())
|
||||
queuedText.add(MultiWildcard(char.toString()))
|
||||
} else if(char == '-') {
|
||||
nextIsExcluded = true
|
||||
} else if(char == '$') {
|
||||
@@ -163,5 +190,16 @@ class SearchEngine {
|
||||
flushAll()
|
||||
|
||||
res
|
||||
})
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val COL_MANGA_ID = "cmid"
|
||||
|
||||
fun escapeLike(string: String): String {
|
||||
return string.replace("\\", "\\\\")
|
||||
.replace("_", "\\_")
|
||||
.replace("%", "\\%")
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
package exh.search
|
||||
|
||||
class SingleWildcard : TextComponent()
|
||||
class SingleWildcard(rawText: String) : TextComponent(rawText)
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
package exh.search
|
||||
|
||||
class StringTextComponent(val value: String) : TextComponent()
|
||||
class StringTextComponent(val value: String) : TextComponent(value)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package exh.search
|
||||
|
||||
import exh.plusAssign
|
||||
import exh.search.SearchEngine.Companion.escapeLike
|
||||
|
||||
class Text: QueryComponent() {
|
||||
val components = mutableListOf<TextComponent>()
|
||||
@@ -19,7 +20,7 @@ class Text: QueryComponent() {
|
||||
|
||||
fun asLenientTitleQuery(): String {
|
||||
if(lenientTitleQuery == null) {
|
||||
lenientTitleQuery = StringBuilder("*").append(rBaseBuilder()).append("*").toString()
|
||||
lenientTitleQuery = StringBuilder("%").append(rBaseBuilder()).append("%").toString()
|
||||
}
|
||||
return lenientTitleQuery!!
|
||||
}
|
||||
@@ -28,7 +29,7 @@ class Text: QueryComponent() {
|
||||
if(lenientTagQueries == null) {
|
||||
lenientTagQueries = listOf(
|
||||
//Match beginning of tag
|
||||
rBaseBuilder().append("*").toString(),
|
||||
rBaseBuilder().append("%").toString(),
|
||||
//Tag word matcher (that matches multiple words)
|
||||
//Can't make it match a single word in Realm :(
|
||||
StringBuilder(" ").append(rBaseBuilder()).append(" ").toString(),
|
||||
@@ -43,9 +44,9 @@ class Text: QueryComponent() {
|
||||
val builder = StringBuilder()
|
||||
for(component in components) {
|
||||
when(component) {
|
||||
is StringTextComponent -> builder += component.value
|
||||
is SingleWildcard -> builder += "?"
|
||||
is MultiWildcard -> builder += "*"
|
||||
is StringTextComponent -> builder += escapeLike(component.value)
|
||||
is SingleWildcard -> builder += "_"
|
||||
is MultiWildcard -> builder += "%"
|
||||
}
|
||||
}
|
||||
return builder
|
||||
@@ -55,10 +56,9 @@ class Text: QueryComponent() {
|
||||
rawText!!
|
||||
else {
|
||||
rawText = components
|
||||
.filter { it is StringTextComponent }
|
||||
.joinToString(separator = "", transform = {
|
||||
(it as StringTextComponent).value
|
||||
})
|
||||
.joinToString(separator = "", transform = { it.rawText })
|
||||
rawText!!
|
||||
}
|
||||
|
||||
fun rawTextEscapedForLike() = escapeLike(rawTextOnly())
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
package exh.search
|
||||
|
||||
open class TextComponent
|
||||
open class TextComponent(val rawText: String)
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
package exh.source
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.*
|
||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import rx.Observable
|
||||
import java.lang.RuntimeException
|
||||
|
||||
abstract class DelegatedHttpSource(val delegate: HttpSource): HttpSource() {
|
||||
/**
|
||||
* Returns the request for the popular manga given the page.
|
||||
*
|
||||
* @param page the page number to retrieve.
|
||||
*/
|
||||
override fun popularMangaRequest(page: Int)
|
||||
= throw UnsupportedOperationException("Should never be called!")
|
||||
|
||||
/**
|
||||
* Parses the response from the site and returns a [MangasPage] object.
|
||||
*
|
||||
* @param response the response from the site.
|
||||
*/
|
||||
override fun popularMangaParse(response: Response)
|
||||
= throw UnsupportedOperationException("Should never be called!")
|
||||
|
||||
/**
|
||||
* 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 never be called!")
|
||||
|
||||
/**
|
||||
* Parses the response from the site and returns a [MangasPage] object.
|
||||
*
|
||||
* @param response the response from the site.
|
||||
*/
|
||||
override fun searchMangaParse(response: Response)
|
||||
= throw UnsupportedOperationException("Should never be called!")
|
||||
|
||||
/**
|
||||
* Returns the request for latest manga given the page.
|
||||
*
|
||||
* @param page the page number to retrieve.
|
||||
*/
|
||||
override fun latestUpdatesRequest(page: Int)
|
||||
= throw UnsupportedOperationException("Should never be called!")
|
||||
|
||||
/**
|
||||
* Parses the response from the site and returns a [MangasPage] object.
|
||||
*
|
||||
* @param response the response from the site.
|
||||
*/
|
||||
override fun latestUpdatesParse(response: Response)
|
||||
= throw UnsupportedOperationException("Should never be called!")
|
||||
|
||||
/**
|
||||
* 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)
|
||||
= throw UnsupportedOperationException("Should never be called!")
|
||||
|
||||
/**
|
||||
* Parses the response from the site and returns a list of chapters.
|
||||
*
|
||||
* @param response the response from the site.
|
||||
*/
|
||||
override fun chapterListParse(response: Response)
|
||||
= throw UnsupportedOperationException("Should never be called!")
|
||||
|
||||
/**
|
||||
* Parses the response from the site and returns a list of pages.
|
||||
*
|
||||
* @param response the response from the site.
|
||||
*/
|
||||
override fun pageListParse(response: Response)
|
||||
= throw UnsupportedOperationException("Should never be called!")
|
||||
|
||||
/**
|
||||
* 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)
|
||||
= throw UnsupportedOperationException("Should never be called!")
|
||||
|
||||
/**
|
||||
* Base url of the website without the trailing slash, like: http://mysite.com
|
||||
*/
|
||||
override val baseUrl = delegate.baseUrl
|
||||
|
||||
/**
|
||||
* Whether the source has support for latest updates.
|
||||
*/
|
||||
override val supportsLatest = delegate.supportsLatest
|
||||
/**
|
||||
* Name of the source.
|
||||
*/
|
||||
final override val name = delegate.name
|
||||
|
||||
// ===> OPTIONAL FIELDS
|
||||
|
||||
/**
|
||||
* Id of the source. By default it uses a generated id using the first 16 characters (64 bits)
|
||||
* of the MD5 of the string: sourcename/language/versionId
|
||||
* Note the generated id sets the sign bit to 0.
|
||||
*/
|
||||
override val id = delegate.id
|
||||
/**
|
||||
* Default network client for doing requests.
|
||||
*/
|
||||
override val client = delegate.client
|
||||
|
||||
/**
|
||||
* Visible name of the source.
|
||||
*/
|
||||
override fun toString() = delegate.toString()
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
override fun fetchPopularManga(page: Int): Observable<MangasPage> {
|
||||
ensureDelegateCompatible()
|
||||
return delegate.fetchPopularManga(page)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
ensureDelegateCompatible()
|
||||
return delegate.fetchSearchManga(page, query, filters)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an observable containing a page with a list of latest manga updates.
|
||||
*
|
||||
* @param page the page number to retrieve.
|
||||
*/
|
||||
override fun fetchLatestUpdates(page: Int): Observable<MangasPage> {
|
||||
ensureDelegateCompatible()
|
||||
return delegate.fetchLatestUpdates(page)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
ensureDelegateCompatible()
|
||||
return delegate.fetchMangaDetails(manga)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the request for the details of a manga. Override only if it's needed to change the
|
||||
* url, send different headers or request method like POST.
|
||||
*
|
||||
* @param manga the manga to be updated.
|
||||
*/
|
||||
override fun mangaDetailsRequest(manga: SManga): Request {
|
||||
ensureDelegateCompatible()
|
||||
return delegate.mangaDetailsRequest(manga)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an observable with the updated chapter list for a manga. Normally it's not needed to
|
||||
* override this method. If a manga is licensed an empty chapter list observable is returned
|
||||
*
|
||||
* @param manga the manga to look for chapters.
|
||||
*/
|
||||
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
|
||||
ensureDelegateCompatible()
|
||||
return delegate.fetchChapterList(manga)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an observable with the page list for a chapter.
|
||||
*
|
||||
* @param chapter the chapter whose page list has to be fetched.
|
||||
*/
|
||||
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
|
||||
ensureDelegateCompatible()
|
||||
return delegate.fetchPageList(chapter)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an observable with the page containing the source url of the image. If there's any
|
||||
* error, it will return null instead of throwing an exception.
|
||||
*
|
||||
* @param page the page whose source image has to be fetched.
|
||||
*/
|
||||
override fun fetchImageUrl(page: Page): Observable<String> {
|
||||
ensureDelegateCompatible()
|
||||
return delegate.fetchImageUrl(page)
|
||||
}
|
||||
|
||||
/**
|
||||
* Called before inserting a new chapter into database. Use it if you need to override chapter
|
||||
* fields, like the title or the chapter number. Do not change anything to [manga].
|
||||
*
|
||||
* @param chapter the chapter to be added.
|
||||
* @param manga the manga of the chapter.
|
||||
*/
|
||||
override fun prepareNewChapter(chapter: SChapter, manga: SManga) {
|
||||
ensureDelegateCompatible()
|
||||
return delegate.prepareNewChapter(chapter, manga)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the list of filters for the source.
|
||||
*/
|
||||
override fun getFilterList() = delegate.getFilterList()
|
||||
|
||||
private fun ensureDelegateCompatible() {
|
||||
if(versionId != delegate.versionId
|
||||
|| lang != delegate.lang) {
|
||||
throw IncompatibleDelegateException("Delegate source is not compatible (versionId: $versionId <=> ${delegate.versionId}, lang: $lang <=> ${delegate.lang})!")
|
||||
}
|
||||
}
|
||||
|
||||
class IncompatibleDelegateException(message: String) : RuntimeException(message)
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package exh.source
|
||||
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
||||
import eu.kanade.tachiyomi.source.model.*
|
||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
import okhttp3.Response
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
class EnhancedHttpSource(val originalSource: HttpSource,
|
||||
val enchancedSource: HttpSource): HttpSource() {
|
||||
private val prefs: PreferencesHelper by injectLazy()
|
||||
|
||||
/**
|
||||
* Returns the request for the popular manga given the page.
|
||||
*
|
||||
* @param page the page number to retrieve.
|
||||
*/
|
||||
override fun popularMangaRequest(page: Int)
|
||||
= throw UnsupportedOperationException("Should never be called!")
|
||||
|
||||
/**
|
||||
* Parses the response from the site and returns a [MangasPage] object.
|
||||
*
|
||||
* @param response the response from the site.
|
||||
*/
|
||||
override fun popularMangaParse(response: Response)
|
||||
= throw UnsupportedOperationException("Should never be called!")
|
||||
|
||||
/**
|
||||
* 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 never be called!")
|
||||
|
||||
/**
|
||||
* Parses the response from the site and returns a [MangasPage] object.
|
||||
*
|
||||
* @param response the response from the site.
|
||||
*/
|
||||
override fun searchMangaParse(response: Response)
|
||||
= throw UnsupportedOperationException("Should never be called!")
|
||||
|
||||
/**
|
||||
* Returns the request for latest manga given the page.
|
||||
*
|
||||
* @param page the page number to retrieve.
|
||||
*/
|
||||
override fun latestUpdatesRequest(page: Int)
|
||||
= throw UnsupportedOperationException("Should never be called!")
|
||||
|
||||
/**
|
||||
* Parses the response from the site and returns a [MangasPage] object.
|
||||
*
|
||||
* @param response the response from the site.
|
||||
*/
|
||||
override fun latestUpdatesParse(response: Response)
|
||||
= throw UnsupportedOperationException("Should never be called!")
|
||||
|
||||
/**
|
||||
* 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)
|
||||
= throw UnsupportedOperationException("Should never be called!")
|
||||
|
||||
/**
|
||||
* Parses the response from the site and returns a list of chapters.
|
||||
*
|
||||
* @param response the response from the site.
|
||||
*/
|
||||
override fun chapterListParse(response: Response)
|
||||
= throw UnsupportedOperationException("Should never be called!")
|
||||
|
||||
/**
|
||||
* Parses the response from the site and returns a list of pages.
|
||||
*
|
||||
* @param response the response from the site.
|
||||
*/
|
||||
override fun pageListParse(response: Response)
|
||||
= throw UnsupportedOperationException("Should never be called!")
|
||||
|
||||
/**
|
||||
* 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)
|
||||
= throw UnsupportedOperationException("Should never be called!")
|
||||
|
||||
/**
|
||||
* Base url of the website without the trailing slash, like: http://mysite.com
|
||||
*/
|
||||
override val baseUrl = source().baseUrl
|
||||
|
||||
/**
|
||||
* Whether the source has support for latest updates.
|
||||
*/
|
||||
override val supportsLatest = source().supportsLatest
|
||||
/**
|
||||
* Name of the source.
|
||||
*/
|
||||
override val name = source().name
|
||||
|
||||
/**
|
||||
* An ISO 639-1 compliant language code (two letters in lower case).
|
||||
*/
|
||||
override val lang = source().lang
|
||||
|
||||
// ===> OPTIONAL FIELDS
|
||||
|
||||
/**
|
||||
* Id of the source. By default it uses a generated id using the first 16 characters (64 bits)
|
||||
* of the MD5 of the string: sourcename/language/versionId
|
||||
* Note the generated id sets the sign bit to 0.
|
||||
*/
|
||||
override val id = source().id
|
||||
/**
|
||||
* Default network client for doing requests.
|
||||
*/
|
||||
override val client = source().client
|
||||
|
||||
/**
|
||||
* Visible name of the source.
|
||||
*/
|
||||
override fun toString() = source().toString()
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
override fun fetchPopularManga(page: Int) = source().fetchPopularManga(page)
|
||||
|
||||
/**
|
||||
* 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)
|
||||
= source().fetchSearchManga(page, query, filters)
|
||||
|
||||
/**
|
||||
* Returns an observable containing a page with a list of latest manga updates.
|
||||
*
|
||||
* @param page the page number to retrieve.
|
||||
*/
|
||||
override fun fetchLatestUpdates(page: Int) = source().fetchLatestUpdates(page)
|
||||
|
||||
/**
|
||||
* 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) = source().fetchMangaDetails(manga)
|
||||
|
||||
/**
|
||||
* Returns the request for the details of a manga. Override only if it's needed to change the
|
||||
* url, send different headers or request method like POST.
|
||||
*
|
||||
* @param manga the manga to be updated.
|
||||
*/
|
||||
override fun mangaDetailsRequest(manga: SManga) = source().mangaDetailsRequest(manga)
|
||||
|
||||
/**
|
||||
* Returns an observable with the updated chapter list for a manga. Normally it's not needed to
|
||||
* override this method. If a manga is licensed an empty chapter list observable is returned
|
||||
*
|
||||
* @param manga the manga to look for chapters.
|
||||
*/
|
||||
override fun fetchChapterList(manga: SManga) = source().fetchChapterList(manga)
|
||||
|
||||
/**
|
||||
* Returns an observable with the page list for a chapter.
|
||||
*
|
||||
* @param chapter the chapter whose page list has to be fetched.
|
||||
*/
|
||||
override fun fetchPageList(chapter: SChapter) = source().fetchPageList(chapter)
|
||||
|
||||
/**
|
||||
* Returns an observable with the page containing the source url of the image. If there's any
|
||||
* error, it will return null instead of throwing an exception.
|
||||
*
|
||||
* @param page the page whose source image has to be fetched.
|
||||
*/
|
||||
override fun fetchImageUrl(page: Page) = source().fetchImageUrl(page)
|
||||
|
||||
/**
|
||||
* Called before inserting a new chapter into database. Use it if you need to override chapter
|
||||
* fields, like the title or the chapter number. Do not change anything to [manga].
|
||||
*
|
||||
* @param chapter the chapter to be added.
|
||||
* @param manga the manga of the chapter.
|
||||
*/
|
||||
override fun prepareNewChapter(chapter: SChapter, manga: SManga)
|
||||
= source().prepareNewChapter(chapter, manga)
|
||||
|
||||
/**
|
||||
* Returns the list of filters for the source.
|
||||
*/
|
||||
override fun getFilterList() = source().getFilterList()
|
||||
|
||||
private fun source(): HttpSource {
|
||||
return if(prefs.eh_delegateSources().getOrDefault()) {
|
||||
enchancedSource
|
||||
} else {
|
||||
originalSource
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -92,10 +92,10 @@ object Entry {
|
||||
override val key = "tl"
|
||||
}
|
||||
|
||||
//Locked to list mode as that's what the parser and toplists use
|
||||
//Locked to extended mode as that's what the parser and toplists use
|
||||
class DisplayMode: ConfigItem {
|
||||
override val key = "dm"
|
||||
override val value = "0"
|
||||
override val value = "2"
|
||||
}
|
||||
|
||||
enum class SearchResultsCount(override val value: String): ConfigItem {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package exh.ui.captcha
|
||||
|
||||
import android.os.Build
|
||||
import android.support.annotation.RequiresApi
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebResourceResponse
|
||||
import android.webkit.WebView
|
||||
import eu.kanade.tachiyomi.util.asJsoup
|
||||
import exh.ui.captcha.SolveCaptchaActivity.Companion.CROSS_WINDOW_SCRIPT_INNER
|
||||
import org.jsoup.nodes.DataNode
|
||||
import org.jsoup.nodes.Element
|
||||
import java.nio.charset.Charset
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
|
||||
class AutoSolvingWebViewClient(activity: SolveCaptchaActivity,
|
||||
source: CaptchaCompletionVerifier,
|
||||
injectScript: String?)
|
||||
: BasicWebViewClient(activity, source, injectScript) {
|
||||
|
||||
override fun shouldInterceptRequest(view: WebView, request: WebResourceRequest): WebResourceResponse? {
|
||||
// Inject our custom script into the recaptcha iframes
|
||||
val lastPathSegment = request.url.pathSegments.lastOrNull()
|
||||
if(lastPathSegment == "anchor" || lastPathSegment == "bframe") {
|
||||
val oReq = request.toOkHttpRequest()
|
||||
val response = activity.httpClient.newCall(oReq).execute()
|
||||
val doc = response.asJsoup()
|
||||
doc.body().appendChild(Element("script").appendChild(DataNode(CROSS_WINDOW_SCRIPT_INNER)))
|
||||
return WebResourceResponse(
|
||||
"text/html",
|
||||
"UTF-8",
|
||||
doc.toString().byteInputStream(Charset.forName("UTF-8")).buffered()
|
||||
)
|
||||
}
|
||||
return super.shouldInterceptRequest(view, request)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package exh.ui.captcha
|
||||
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
|
||||
open class BasicWebViewClient(protected val activity: SolveCaptchaActivity,
|
||||
protected val source: CaptchaCompletionVerifier,
|
||||
private val injectScript: String?) : WebViewClient() {
|
||||
override fun onPageFinished(view: WebView, url: String) {
|
||||
super.onPageFinished(view, url)
|
||||
|
||||
if(source.verifyNoCaptcha(url)) {
|
||||
activity.finish()
|
||||
} else {
|
||||
if(injectScript != null) view.loadUrl("javascript:(function() {$injectScript})();")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,25 +4,48 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.support.annotation.RequiresApi
|
||||
import android.support.v7.app.AppCompatActivity
|
||||
import android.webkit.CookieManager
|
||||
import android.webkit.CookieSyncManager
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import eu.kanade.tachiyomi.R
|
||||
import android.webkit.*
|
||||
import com.github.salomonbrys.kotson.get
|
||||
import com.github.salomonbrys.kotson.string
|
||||
import com.google.gson.JsonParser
|
||||
import eu.kanade.tachiyomi.network.asObservableSuccess
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.source.SourceManager
|
||||
import kotlinx.android.synthetic.main.eh_activity_captcha.*
|
||||
import okhttp3.*
|
||||
import rx.Single
|
||||
import rx.schedulers.Schedulers
|
||||
import timber.log.Timber
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.net.URL
|
||||
import java.util.*
|
||||
import android.view.MotionEvent
|
||||
import android.os.SystemClock
|
||||
import com.afollestad.materialdialogs.MaterialDialog
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
||||
import exh.util.melt
|
||||
import rx.Observable
|
||||
|
||||
class SolveCaptchaActivity : AppCompatActivity() {
|
||||
private val sourceManager: SourceManager by injectLazy()
|
||||
private val preferencesHelper: PreferencesHelper by injectLazy()
|
||||
|
||||
val httpClient = OkHttpClient()
|
||||
private val jsonParser = JsonParser()
|
||||
|
||||
private var currentLoopId: String? = null
|
||||
private var validateCurrentLoopId: String? = null
|
||||
private var strictValidationStartTime: Long? = null
|
||||
|
||||
lateinit var credentialsObservable: Observable<String>
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
setContentView(R.layout.eh_activity_captcha)
|
||||
setContentView(eu.kanade.tachiyomi.R.layout.eh_activity_captcha)
|
||||
|
||||
val sourceId = intent.getLongExtra(SOURCE_ID_EXTRA, -1)
|
||||
val source = if(sourceId != -1L)
|
||||
@@ -59,18 +82,56 @@ class SolveCaptchaActivity : AppCompatActivity() {
|
||||
webview.settings.javaScriptEnabled = true
|
||||
webview.settings.domStorageEnabled = true
|
||||
|
||||
webview.webViewClient = object : WebViewClient() {
|
||||
override fun onPageFinished(view: WebView, url: String) {
|
||||
super.onPageFinished(view, url)
|
||||
var loadedInners = 0
|
||||
|
||||
if(source.verify(url)) {
|
||||
finish()
|
||||
} else {
|
||||
view.loadUrl("javascript:(function() {$script})();")
|
||||
webview.webChromeClient = object : WebChromeClient() {
|
||||
override fun onJsAlert(view: WebView?, url: String?, message: String, result: JsResult): Boolean {
|
||||
if(message.startsWith("exh-")) {
|
||||
loadedInners++
|
||||
// Wait for both inner scripts to be loaded
|
||||
if(loadedInners >= 2) {
|
||||
// Attempt to autosolve captcha
|
||||
if(preferencesHelper.eh_autoSolveCaptchas().getOrDefault()
|
||||
&& Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||
webview.post {
|
||||
// 10 seconds to auto-solve captcha
|
||||
strictValidationStartTime = System.currentTimeMillis() + 1000 * 10
|
||||
beginSolveLoop()
|
||||
beginValidateCaptchaLoop()
|
||||
webview.evaluateJavascript(SOLVE_UI_SCRIPT_HIDE) {
|
||||
webview.evaluateJavascript(SOLVE_UI_SCRIPT_SHOW, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
result.confirm()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
webview.webViewClient = if (preferencesHelper.eh_autoSolveCaptchas().getOrDefault()
|
||||
&& Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||
// Fetch auto-solve credentials early for speed
|
||||
credentialsObservable = httpClient.newCall(Request.Builder()
|
||||
// Rob demo credentials
|
||||
.url("https://speech-to-text-demo.ng.bluemix.net/api/v1/credentials")
|
||||
.build())
|
||||
.asObservableSuccess()
|
||||
.subscribeOn(Schedulers.io())
|
||||
.map {
|
||||
val json = jsonParser.parse(it.body()!!.string())
|
||||
it.close()
|
||||
json["token"].string
|
||||
}.melt()
|
||||
|
||||
webview.addJavascriptInterface(this@SolveCaptchaActivity, "exh")
|
||||
AutoSolvingWebViewClient(this, source, script)
|
||||
} else {
|
||||
BasicWebViewClient(this, source, script)
|
||||
}
|
||||
|
||||
webview.loadUrl(url)
|
||||
}
|
||||
|
||||
@@ -91,22 +152,458 @@ class SolveCaptchaActivity : AppCompatActivity() {
|
||||
return true
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.KITKAT)
|
||||
fun captchaSolveFail() {
|
||||
currentLoopId = null
|
||||
validateCurrentLoopId = null
|
||||
Timber.e(IllegalStateException("Captcha solve failure!"))
|
||||
runOnUiThread {
|
||||
webview.evaluateJavascript(SOLVE_UI_SCRIPT_HIDE, null)
|
||||
MaterialDialog.Builder(this)
|
||||
.title("Captcha solve failure")
|
||||
.content("Failed to auto-solve the captcha!")
|
||||
.cancelable(true)
|
||||
.canceledOnTouchOutside(true)
|
||||
.positiveText("Ok")
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
|
||||
@JavascriptInterface
|
||||
fun callback(result: String?, loopId: String, stage: Int) {
|
||||
if(loopId != currentLoopId) return
|
||||
|
||||
when(stage) {
|
||||
STAGE_CHECKBOX -> {
|
||||
if(result!!.toBoolean()) {
|
||||
webview.postDelayed({
|
||||
getAudioButtonLocation(loopId)
|
||||
}, 250)
|
||||
} else {
|
||||
webview.postDelayed({
|
||||
doStageCheckbox(loopId)
|
||||
}, 250)
|
||||
}
|
||||
}
|
||||
STAGE_GET_AUDIO_BTN_LOCATION -> {
|
||||
if(result != null) {
|
||||
val splitResult = result.split(" ").map { it.toFloat() }
|
||||
val origX = splitResult[0]
|
||||
val origY = splitResult[1]
|
||||
val iw = splitResult[2]
|
||||
val ih = splitResult[3]
|
||||
val x = webview.x + origX / iw * webview.width
|
||||
val y = webview.y + origY / ih * webview.height
|
||||
Timber.d("Found audio button coords: %f %f", x, y)
|
||||
simulateClick(x + 50, y + 50)
|
||||
webview.post {
|
||||
doStageDownloadAudio(loopId)
|
||||
}
|
||||
} else {
|
||||
webview.postDelayed({
|
||||
getAudioButtonLocation(loopId)
|
||||
}, 250)
|
||||
}
|
||||
}
|
||||
STAGE_DOWNLOAD_AUDIO -> {
|
||||
if(result != null) {
|
||||
Timber.d("Got audio URL: $result")
|
||||
performRecognize(result)
|
||||
.observeOn(Schedulers.io())
|
||||
.subscribe ({
|
||||
Timber.d("Got audio transcript: $it")
|
||||
webview.post {
|
||||
typeResult(loopId, it!!
|
||||
.replace(TRANSCRIPT_CLEANER_REGEX, "")
|
||||
.replace(SPACE_DEDUPE_REGEX, " ")
|
||||
.trim())
|
||||
}
|
||||
}, {
|
||||
captchaSolveFail()
|
||||
})
|
||||
} else {
|
||||
webview.postDelayed({
|
||||
doStageDownloadAudio(loopId)
|
||||
}, 250)
|
||||
}
|
||||
}
|
||||
STAGE_TYPE_RESULT -> {
|
||||
if(result!!.toBoolean()) {
|
||||
// Fail if captcha still not solved after 1.5s
|
||||
strictValidationStartTime = System.currentTimeMillis() + 1500
|
||||
} else {
|
||||
captchaSolveFail()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun performRecognize(url: String): Single<String> {
|
||||
return credentialsObservable.flatMap { token ->
|
||||
httpClient.newCall(Request.Builder()
|
||||
.url(url)
|
||||
.build()).asObservableSuccess().map {
|
||||
token to it
|
||||
}
|
||||
}.flatMap { (token, response) ->
|
||||
val audioFile = response.body()!!.bytes()
|
||||
|
||||
httpClient.newCall(Request.Builder()
|
||||
.url(HttpUrl.parse("https://stream.watsonplatform.net/speech-to-text/api/v1/recognize")!!
|
||||
.newBuilder()
|
||||
.addQueryParameter("watson-token", token)
|
||||
.build())
|
||||
.post(MultipartBody.Builder()
|
||||
.setType(MultipartBody.FORM)
|
||||
.addFormDataPart("jsonDescription", RECOGNIZE_JSON)
|
||||
.addFormDataPart("audio.mp3",
|
||||
"audio.mp3",
|
||||
RequestBody.create(MediaType.parse("audio/mp3"), audioFile))
|
||||
.build())
|
||||
.build()).asObservableSuccess()
|
||||
}.map { response ->
|
||||
jsonParser.parse(response.body()!!.string())["results"][0]["alternatives"][0]["transcript"].string.trim()
|
||||
}.toSingle()
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
|
||||
fun doStageCheckbox(loopId: String) {
|
||||
if(loopId != currentLoopId) return
|
||||
|
||||
webview.evaluateJavascript("""
|
||||
(function() {
|
||||
$CROSS_WINDOW_SCRIPT_OUTER
|
||||
|
||||
let exh_cframe = document.querySelector('iframe[role=presentation][name|=a]');
|
||||
|
||||
if(exh_cframe != null) {
|
||||
cwmExec(exh_cframe, `
|
||||
let exh_cb = document.getElementsByClassName('recaptcha-checkbox-checkmark')[0];
|
||||
if(exh_cb != null) {
|
||||
exh_cb.click();
|
||||
return "true";
|
||||
} else {
|
||||
return "false";
|
||||
}
|
||||
`, function(result) {
|
||||
exh.callback(result, '$loopId', $STAGE_CHECKBOX);
|
||||
});
|
||||
} else {
|
||||
exh.callback("false", '$loopId', $STAGE_CHECKBOX);
|
||||
}
|
||||
})();
|
||||
""".trimIndent().replace("\n", ""), null)
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
|
||||
fun getAudioButtonLocation(loopId: String) {
|
||||
webview.evaluateJavascript("""
|
||||
(function() {
|
||||
$CROSS_WINDOW_SCRIPT_OUTER
|
||||
|
||||
let exh_bframe = document.querySelector("iframe[title='recaptcha challenge'][name|=c]");
|
||||
|
||||
if(exh_bframe != null) {
|
||||
let bfb = exh_bframe.getBoundingClientRect();
|
||||
let iw = window.innerWidth;
|
||||
let ih = window.innerHeight;
|
||||
if(bfb.left < 0 || bfb.top < 0) {
|
||||
exh.callback(null, '$loopId', $STAGE_GET_AUDIO_BTN_LOCATION);
|
||||
} else {
|
||||
cwmExec(exh_bframe, ` let exh_ab = document.getElementById("recaptcha-audio-button");
|
||||
if(exh_ab != null) {
|
||||
let bounds = exh_ab.getBoundingClientRect();
|
||||
return (${'$'}{bfb.left} + bounds.left) + " " + (${'$'}{bfb.top} + bounds.top) + " " + ${'$'}{iw} + " " + ${'$'}{ih};
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
`, function(result) {
|
||||
exh.callback(result, '$loopId', $STAGE_GET_AUDIO_BTN_LOCATION);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
exh.callback(null, '$loopId', $STAGE_GET_AUDIO_BTN_LOCATION);
|
||||
}
|
||||
})();
|
||||
""".trimIndent().replace("\n", ""), null)
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
|
||||
fun doStageDownloadAudio(loopId: String) {
|
||||
webview.evaluateJavascript("""
|
||||
(function() {
|
||||
$CROSS_WINDOW_SCRIPT_OUTER
|
||||
|
||||
let exh_bframe = document.querySelector("iframe[title='recaptcha challenge'][name|=c]");
|
||||
|
||||
if(exh_bframe != null) {
|
||||
cwmExec(exh_bframe, `
|
||||
let exh_as = document.getElementById("audio-source");
|
||||
if(exh_as != null) {
|
||||
return exh_as.src;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
`, function(result) {
|
||||
exh.callback(result, '$loopId', $STAGE_DOWNLOAD_AUDIO);
|
||||
});
|
||||
} else {
|
||||
exh.callback(null, '$loopId', $STAGE_DOWNLOAD_AUDIO);
|
||||
}
|
||||
})();
|
||||
""".trimIndent().replace("\n", ""), null)
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
|
||||
fun typeResult(loopId: String, result: String) {
|
||||
webview.evaluateJavascript("""
|
||||
(function() {
|
||||
$CROSS_WINDOW_SCRIPT_OUTER
|
||||
|
||||
let exh_bframe = document.querySelector("iframe[title='recaptcha challenge'][name|=c]");
|
||||
|
||||
if(exh_bframe != null) {
|
||||
cwmExec(exh_bframe, `
|
||||
let exh_as = document.getElementById("audio-response");
|
||||
let exh_vb = document.getElementById("recaptcha-verify-button");
|
||||
if(exh_as != null && exh_vb != null) {
|
||||
exh_as.value = "$result";
|
||||
exh_vb.click();
|
||||
return "true";
|
||||
} else {
|
||||
return "false";
|
||||
}
|
||||
`, function(result) {
|
||||
exh.callback(result, '$loopId', $STAGE_TYPE_RESULT);
|
||||
});
|
||||
} else {
|
||||
exh.callback("false", '$loopId', $STAGE_TYPE_RESULT);
|
||||
}
|
||||
})();
|
||||
""".trimIndent().replace("\n", ""), null)
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
|
||||
fun beginSolveLoop() {
|
||||
val loopId = UUID.randomUUID().toString()
|
||||
currentLoopId = loopId
|
||||
doStageCheckbox(loopId)
|
||||
}
|
||||
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
|
||||
@JavascriptInterface
|
||||
fun validateCaptchaCallback(result: Boolean, loopId: String) {
|
||||
if(loopId != validateCurrentLoopId) return
|
||||
|
||||
if(result) {
|
||||
Timber.d("Captcha solved!")
|
||||
webview.post {
|
||||
webview.evaluateJavascript(SOLVE_UI_SCRIPT_HIDE, null)
|
||||
}
|
||||
val asbtn = intent.getStringExtra(ASBTN_EXTRA)
|
||||
if(asbtn != null) {
|
||||
webview.post {
|
||||
webview.evaluateJavascript("(function() {document.querySelector('$asbtn').click();})();", null)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val savedStrictValidationStartTime = strictValidationStartTime
|
||||
if(savedStrictValidationStartTime != null
|
||||
&& System.currentTimeMillis() > savedStrictValidationStartTime) {
|
||||
captchaSolveFail()
|
||||
} else {
|
||||
webview.postDelayed({
|
||||
runValidateCaptcha(loopId)
|
||||
}, 250)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
|
||||
fun runValidateCaptcha(loopId: String) {
|
||||
if(loopId != validateCurrentLoopId) return
|
||||
|
||||
webview.evaluateJavascript("""
|
||||
(function() {
|
||||
$CROSS_WINDOW_SCRIPT_OUTER
|
||||
|
||||
let exh_cframe = document.querySelector('iframe[role=presentation][name|=a]');
|
||||
|
||||
if(exh_cframe != null) {
|
||||
cwmExec(exh_cframe, `
|
||||
let exh_cb = document.querySelector(".recaptcha-checkbox[aria-checked=true]");
|
||||
if(exh_cb != null) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
`, function(result) {
|
||||
exh.validateCaptchaCallback(result, '$loopId');
|
||||
});
|
||||
} else {
|
||||
exh.validateCaptchaCallback(false, '$loopId');
|
||||
}
|
||||
})();
|
||||
""".trimIndent().replace("\n", ""), null)
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
|
||||
fun beginValidateCaptchaLoop() {
|
||||
val loopId = UUID.randomUUID().toString()
|
||||
validateCurrentLoopId = loopId
|
||||
runValidateCaptcha(loopId)
|
||||
}
|
||||
|
||||
private fun simulateClick(x: Float, y: Float) {
|
||||
val downTime = SystemClock.uptimeMillis()
|
||||
val eventTime = SystemClock.uptimeMillis()
|
||||
val properties = arrayOfNulls<MotionEvent.PointerProperties>(1)
|
||||
val pp1 = MotionEvent.PointerProperties().apply {
|
||||
id = 0
|
||||
toolType = MotionEvent.TOOL_TYPE_FINGER
|
||||
}
|
||||
properties[0] = pp1
|
||||
val pointerCoords = arrayOfNulls<MotionEvent.PointerCoords>(1)
|
||||
val pc1 = MotionEvent.PointerCoords().apply {
|
||||
this.x = x
|
||||
this.y = y
|
||||
pressure = 1f
|
||||
size = 1f
|
||||
}
|
||||
pointerCoords[0] = pc1
|
||||
var motionEvent = MotionEvent.obtain(downTime, eventTime, MotionEvent.ACTION_DOWN, 1, properties, pointerCoords, 0, 0, 1f, 1f, 0, 0, 0, 0)
|
||||
dispatchTouchEvent(motionEvent)
|
||||
motionEvent.recycle()
|
||||
motionEvent = MotionEvent.obtain(downTime, eventTime, MotionEvent.ACTION_UP, 1, properties, pointerCoords, 0, 0, 1f, 1f, 0, 0, 0, 0)
|
||||
dispatchTouchEvent(motionEvent)
|
||||
motionEvent.recycle()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val SOURCE_ID_EXTRA = "source_id_extra"
|
||||
const val COOKIES_EXTRA = "cookies_extra"
|
||||
const val SCRIPT_EXTRA = "script_extra"
|
||||
const val URL_EXTRA = "url_extra"
|
||||
const val ASBTN_EXTRA = "asbtn_extra"
|
||||
|
||||
const val STAGE_CHECKBOX = 0
|
||||
const val STAGE_GET_AUDIO_BTN_LOCATION = 1
|
||||
const val STAGE_DOWNLOAD_AUDIO = 2
|
||||
const val STAGE_TYPE_RESULT = 3
|
||||
|
||||
val CROSS_WINDOW_SCRIPT_OUTER = """
|
||||
function cwmExec(element, code, cb) {
|
||||
console.log(">>> [CWM-Outer] Running: " + code);
|
||||
let runId = Math.random();
|
||||
if(cb != null) {
|
||||
let listener;
|
||||
listener = function(event) {
|
||||
if(typeof event.data === "string" && event.data.startsWith("exh-")) {
|
||||
let response = JSON.parse(event.data.substring(4));
|
||||
if(response.id === runId) {
|
||||
cb(response.result);
|
||||
window.removeEventListener('message', listener);
|
||||
console.log(">>> [CWM-Outer] Finished: " + response.id + " ==> " + response.result);
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener('message', listener, false);
|
||||
}
|
||||
let runRequest = { id: runId, code: code };
|
||||
element.contentWindow.postMessage("exh-" + JSON.stringify(runRequest), "*");
|
||||
}
|
||||
""".trimIndent().replace("\n", "")
|
||||
|
||||
val CROSS_WINDOW_SCRIPT_INNER = """
|
||||
window.addEventListener('message', function(event) {
|
||||
if(typeof event.data === "string" && event.data.startsWith("exh-")) {
|
||||
let request = JSON.parse(event.data.substring(4));
|
||||
console.log(">>> [CWM-Inner] Incoming: " + request.id);
|
||||
let result = eval("(function() {" + request.code + "})();");
|
||||
let response = { id: request.id, result: result };
|
||||
console.log(">>> [CWM-Inner] Outgoing: " + response.id + " ==> " + response.result);
|
||||
event.source.postMessage("exh-" + JSON.stringify(response), event.origin);
|
||||
}
|
||||
}, false);
|
||||
console.log(">>> [CWM-Inner] Loaded!");
|
||||
alert("exh-");
|
||||
""".trimIndent()
|
||||
|
||||
val SOLVE_UI_SCRIPT_SHOW = """
|
||||
(function() {
|
||||
let exh_overlay = document.createElement("div");
|
||||
exh_overlay.id = "exh_overlay";
|
||||
exh_overlay.style.zIndex = 2000000001;
|
||||
exh_overlay.style.backgroundColor = "rgba(0, 0, 0, 0.8)";
|
||||
exh_overlay.style.position = "fixed";
|
||||
exh_overlay.style.top = 0;
|
||||
exh_overlay.style.left = 0;
|
||||
exh_overlay.style.width = "100%";
|
||||
exh_overlay.style.height = "100%";
|
||||
exh_overlay.style.pointerEvents = "none";
|
||||
document.body.appendChild(exh_overlay);
|
||||
let exh_otext = document.createElement("div");
|
||||
exh_otext.id = "exh_otext";
|
||||
exh_otext.style.zIndex = 2000000002;
|
||||
exh_otext.style.position = "fixed";
|
||||
exh_otext.style.top = "50%";
|
||||
exh_otext.style.left = 0;
|
||||
exh_otext.style.transform = "translateY(-50%)";
|
||||
exh_otext.style.color = "white";
|
||||
exh_otext.style.fontSize = "25pt";
|
||||
exh_otext.style.pointerEvents = "none";
|
||||
exh_otext.style.width = "100%";
|
||||
exh_otext.style.textAlign = "center";
|
||||
exh_otext.textContent = "Solving captcha..."
|
||||
document.body.appendChild(exh_otext);
|
||||
})();
|
||||
""".trimIndent()
|
||||
|
||||
val SOLVE_UI_SCRIPT_HIDE = """
|
||||
(function() {
|
||||
let exh_overlay = document.getElementById("exh_overlay");
|
||||
let exh_otext = document.getElementById("exh_otext");
|
||||
if(exh_overlay != null) exh_overlay.remove();
|
||||
if(exh_otext != null) exh_otext.remove();
|
||||
})();
|
||||
""".trimIndent()
|
||||
|
||||
val RECOGNIZE_JSON = """
|
||||
{
|
||||
"part_content_type": "audio/mp3",
|
||||
"keywords": [],
|
||||
"profanity_filter": false,
|
||||
"max_alternatives": 1,
|
||||
"speaker_labels": false,
|
||||
"firstReadyInSession": false,
|
||||
"preserveAdaptation": false,
|
||||
"timestamps": false,
|
||||
"inactivity_timeout": 30,
|
||||
"word_confidence": false,
|
||||
"audioMetrics": false,
|
||||
"latticeGeneration": true,
|
||||
"customGrammarWords": [],
|
||||
"action": "recognize"
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val TRANSCRIPT_CLEANER_REGEX = Regex("[^0-9a-zA-Z_ -]")
|
||||
val SPACE_DEDUPE_REGEX = Regex(" +")
|
||||
|
||||
fun launch(context: Context,
|
||||
source: CaptchaCompletionVerifier,
|
||||
cookies: Map<String, String>,
|
||||
script: String,
|
||||
url: String) {
|
||||
url: String,
|
||||
autoSolveSubmitBtnSelector: String? = null) {
|
||||
val intent = Intent(context, SolveCaptchaActivity::class.java).apply {
|
||||
putExtra(SOURCE_ID_EXTRA, source.id)
|
||||
putExtra(COOKIES_EXTRA, HashMap(cookies))
|
||||
putExtra(SCRIPT_EXTRA, script)
|
||||
putExtra(URL_EXTRA, url)
|
||||
putExtra(ASBTN_EXTRA, autoSolveSubmitBtnSelector)
|
||||
}
|
||||
|
||||
context.startActivity(intent)
|
||||
@@ -115,6 +612,6 @@ class SolveCaptchaActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
interface CaptchaCompletionVerifier : Source {
|
||||
fun verify(url: String): Boolean
|
||||
fun verifyNoCaptcha(url: String): Boolean
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package exh.ui.captcha
|
||||
|
||||
import android.os.Build
|
||||
import android.support.annotation.RequiresApi
|
||||
import android.webkit.WebResourceRequest
|
||||
import okhttp3.Request
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
|
||||
fun WebResourceRequest.toOkHttpRequest(): Request {
|
||||
val request = Request.Builder()
|
||||
.url(url.toString())
|
||||
.method(method, null)
|
||||
|
||||
requestHeaders.entries.forEach { (t, u) ->
|
||||
request.addHeader(t, u)
|
||||
}
|
||||
|
||||
return request.build()
|
||||
}
|
||||
@@ -11,8 +11,6 @@ import eu.kanade.tachiyomi.data.preference.getOrDefault
|
||||
import eu.kanade.tachiyomi.source.SourceManager
|
||||
import exh.isExSource
|
||||
import exh.isLewdSource
|
||||
import exh.metadata.queryMetadataFromManga
|
||||
import exh.util.defRealm
|
||||
import timber.log.Timber
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import kotlin.concurrent.thread
|
||||
@@ -29,54 +27,71 @@ class MetadataFetchDialog {
|
||||
//Too lazy to actually deal with orientation changes
|
||||
context.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_NOSENSOR
|
||||
|
||||
var running = true
|
||||
|
||||
val progressDialog = MaterialDialog.Builder(context)
|
||||
.title("Fetching library metadata")
|
||||
.content("Preparing library")
|
||||
.progress(false, 0, true)
|
||||
.negativeText("Stop")
|
||||
.onNegative { dialog, which ->
|
||||
running = false
|
||||
dialog.dismiss()
|
||||
notifyMigrationStopped(context)
|
||||
}
|
||||
.cancelable(false)
|
||||
.canceledOnTouchOutside(false)
|
||||
.show()
|
||||
|
||||
thread {
|
||||
defRealm { realm ->
|
||||
db.deleteMangasNotInLibrary().executeAsBlocking()
|
||||
val libraryMangas = db.getLibraryMangas().executeAsBlocking()
|
||||
.filter { isLewdSource(it.source) }
|
||||
.distinctBy { it.id }
|
||||
|
||||
val libraryMangas = db.getLibraryMangas()
|
||||
.executeAsBlocking()
|
||||
.filter {
|
||||
isLewdSource(it.source)
|
||||
&& realm.queryMetadataFromManga(it).findFirst() == null
|
||||
context.runOnUiThread {
|
||||
progressDialog.maxProgress = libraryMangas.size
|
||||
}
|
||||
|
||||
val mangaWithMissingMetadata = libraryMangas
|
||||
.filterIndexed { index, libraryManga ->
|
||||
if(index % 100 == 0) {
|
||||
context.runOnUiThread {
|
||||
progressDialog.setContent("[Stage 1/2] Scanning for missing metadata...")
|
||||
progressDialog.setProgress(index + 1)
|
||||
}
|
||||
}
|
||||
|
||||
context.runOnUiThread {
|
||||
progressDialog.maxProgress = libraryMangas.size
|
||||
}
|
||||
|
||||
//Actual metadata fetch code
|
||||
libraryMangas.forEachIndexed { i, manga ->
|
||||
context.runOnUiThread {
|
||||
progressDialog.setContent("Processing: ${manga.title}")
|
||||
progressDialog.setProgress(i + 1)
|
||||
db.getSearchMetadataForManga(libraryManga.id!!).executeAsBlocking() == null
|
||||
}
|
||||
try {
|
||||
val source = sourceManager.get(manga.source)
|
||||
source?.let {
|
||||
manga.copyFrom(it.fetchMangaDetails(manga).toBlocking().first())
|
||||
realm.queryMetadataFromManga(manga).findFirst()?.copyTo(manga)
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
Timber.e(t, "Could not migrate manga!")
|
||||
}
|
||||
}
|
||||
.toList()
|
||||
|
||||
context.runOnUiThread {
|
||||
progressDialog.maxProgress = mangaWithMissingMetadata.size
|
||||
}
|
||||
|
||||
//Actual metadata fetch code
|
||||
for((i, manga) in mangaWithMissingMetadata.withIndex()) {
|
||||
if(!running) break
|
||||
context.runOnUiThread {
|
||||
progressDialog.dismiss()
|
||||
|
||||
//Enable orientation changes again
|
||||
context.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_NOSENSOR
|
||||
|
||||
displayMigrationComplete(context)
|
||||
progressDialog.setContent("[Stage 2/2] Processing: ${manga.title}")
|
||||
progressDialog.setProgress(i + 1)
|
||||
}
|
||||
try {
|
||||
val source = sourceManager.get(manga.source)
|
||||
source?.let {
|
||||
manga.copyFrom(it.fetchMangaDetails(manga).toBlocking().first())
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
Timber.e(t, "Could not migrate manga!")
|
||||
}
|
||||
}
|
||||
|
||||
context.runOnUiThread {
|
||||
progressDialog.dismiss()
|
||||
|
||||
//Enable orientation changes again
|
||||
context.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_NOSENSOR
|
||||
|
||||
if(running) displayMigrationComplete(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -85,7 +100,9 @@ class MetadataFetchDialog {
|
||||
var extra = ""
|
||||
db.getLibraryMangas().asRxSingle().subscribe {
|
||||
if(!explicit && it.none { isLewdSource(it.source) }) {
|
||||
//Do not open dialog on startup if no manga
|
||||
// Do not open dialog on startup if no manga
|
||||
// Also do not check again
|
||||
preferenceHelper.migrateLibraryAsked().set(true)
|
||||
} else {
|
||||
//Not logged in but have ExHentai galleries
|
||||
if (!preferenceHelper.enableExhentai().getOrDefault()) {
|
||||
@@ -97,13 +114,14 @@ class MetadataFetchDialog {
|
||||
MaterialDialog.Builder(activity)
|
||||
.title("Fetch library metadata")
|
||||
.content(Html.fromHtml("You need to fetch your library's metadata before tag searching in the library will function.<br><br>" +
|
||||
"This process may take a long time depending on your library size and will also use up a significant amount of internet bandwidth.<br><br>" +
|
||||
"This process may take a long time depending on your library size and will also use up a significant amount of internet bandwidth but can be stopped and started whenever you wish.<br><br>" +
|
||||
extra +
|
||||
"This process can be done later if required."))
|
||||
.positiveText("Migrate")
|
||||
.negativeText("Later")
|
||||
.onPositive { _, _ -> show(activity) }
|
||||
.onNegative({ _, _ -> adviseMigrationLater(activity) })
|
||||
.onNegative { _, _ -> adviseMigrationLater(activity) }
|
||||
.onAny { _, _ -> preferenceHelper.migrateLibraryAsked().set(true) }
|
||||
.cancelable(false)
|
||||
.canceledOnTouchOutside(false)
|
||||
.show()
|
||||
@@ -124,6 +142,17 @@ class MetadataFetchDialog {
|
||||
.show()
|
||||
}
|
||||
|
||||
fun notifyMigrationStopped(activity: Activity) {
|
||||
MaterialDialog.Builder(activity)
|
||||
.title("Metadata fetch stopped")
|
||||
.content("Library metadata fetch has been stopped.\n\n" +
|
||||
"You can continue this operation later by going to: Settings > Advanced > Migrate library metadata")
|
||||
.positiveText("Ok")
|
||||
.cancelable(true)
|
||||
.canceledOnTouchOutside(true)
|
||||
.show()
|
||||
}
|
||||
|
||||
fun displayMigrationComplete(activity: Activity) {
|
||||
MaterialDialog.Builder(activity)
|
||||
.title("Migration complete")
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
package exh.ui.migration
|
||||
|
||||
import eu.kanade.tachiyomi.data.database.DatabaseHelper
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
||||
import exh.isExSource
|
||||
import exh.isLewdSource
|
||||
import exh.metadata.models.ExGalleryMetadata
|
||||
import exh.util.realmTrans
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
class UrlMigrator {
|
||||
private val db: DatabaseHelper by injectLazy()
|
||||
|
||||
private val prefs: PreferencesHelper by injectLazy()
|
||||
|
||||
fun perform() {
|
||||
db.inTransaction {
|
||||
val dbMangas = db.getMangas()
|
||||
.executeAsBlocking()
|
||||
|
||||
//Find all EX mangas
|
||||
val qualifyingMangas = dbMangas.asSequence().filter {
|
||||
isLewdSource(it.source)
|
||||
}
|
||||
|
||||
val possibleDups = mutableListOf<Manga>()
|
||||
val badMangas = mutableListOf<Manga>()
|
||||
|
||||
qualifyingMangas.forEach {
|
||||
if(it.url.startsWith("g/")) //Missing slash at front so we are bad
|
||||
badMangas.add(it)
|
||||
else
|
||||
possibleDups.add(it)
|
||||
}
|
||||
|
||||
//Sort possible dups so we can use binary search on it
|
||||
possibleDups.sortBy { it.url }
|
||||
|
||||
realmTrans { realm ->
|
||||
badMangas.forEach { manga ->
|
||||
//Build fixed URL
|
||||
val urlWithSlash = "/" + manga.url
|
||||
//Fix metadata if required
|
||||
val metadata = ExGalleryMetadata.UrlQuery(manga.url, isExSource(manga.source))
|
||||
.query(realm)
|
||||
.findFirst()
|
||||
metadata?.url?.let {
|
||||
if (it.startsWith("g/")) { //Check if metadata URL has no slash
|
||||
metadata.url = urlWithSlash //Fix it
|
||||
}
|
||||
}
|
||||
//If we have a dup (with the fixed url), use the dup instead
|
||||
val possibleDup = possibleDups.binarySearchBy(urlWithSlash, selector = { it.url })
|
||||
if (possibleDup >= 0) {
|
||||
//Make sure it is favorited if we are
|
||||
if (manga.favorite) {
|
||||
val dup = possibleDups[possibleDup]
|
||||
dup.favorite = true
|
||||
db.insertManga(dup).executeAsBlocking() //Update DB with changes
|
||||
}
|
||||
//Delete ourself (but the dup is still there)
|
||||
db.deleteManga(manga).executeAsBlocking()
|
||||
return@forEach
|
||||
}
|
||||
//No dup, correct URL and reinsert ourselves
|
||||
manga.url = urlWithSlash
|
||||
db.insertManga(manga).executeAsBlocking()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun tryMigration() {
|
||||
if(!prefs.hasPerformedURLMigration().getOrDefault()) {
|
||||
perform()
|
||||
prefs.hasPerformedURLMigration().set(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
package exh.ui.webview;
|
||||
|
||||
import android.content.Context;
|
||||
import android.support.v4.view.MotionEventCompat;
|
||||
import android.support.v4.view.NestedScrollingChild;
|
||||
import android.support.v4.view.NestedScrollingChildHelper;
|
||||
import android.support.v4.view.ViewCompat;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.MotionEvent;
|
||||
import android.webkit.WebView;
|
||||
|
||||
public class NestedWebView extends WebView implements NestedScrollingChild {
|
||||
private int mLastY;
|
||||
private final int[] mScrollOffset = new int[2];
|
||||
private final int[] mScrollConsumed = new int[2];
|
||||
private int mNestedOffsetY;
|
||||
private NestedScrollingChildHelper mChildHelper;
|
||||
|
||||
public NestedWebView(Context context) {
|
||||
this(context, null);
|
||||
}
|
||||
|
||||
public NestedWebView(Context context, AttributeSet attrs) {
|
||||
this(context, attrs, android.R.attr.webViewStyle);
|
||||
}
|
||||
|
||||
public NestedWebView(Context context, AttributeSet attrs, int defStyleAttr) {
|
||||
super(context, attrs, defStyleAttr);
|
||||
mChildHelper = new NestedScrollingChildHelper(this);
|
||||
setNestedScrollingEnabled(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onTouchEvent(MotionEvent ev) {
|
||||
boolean returnValue = false;
|
||||
|
||||
MotionEvent event = MotionEvent.obtain(ev);
|
||||
final int action = MotionEventCompat.getActionMasked(event);
|
||||
if (action == MotionEvent.ACTION_DOWN) {
|
||||
mNestedOffsetY = 0;
|
||||
}
|
||||
int eventY = (int) event.getY();
|
||||
event.offsetLocation(0, mNestedOffsetY);
|
||||
switch (action) {
|
||||
case MotionEvent.ACTION_MOVE:
|
||||
int deltaY = mLastY - eventY;
|
||||
// NestedPreScroll
|
||||
if (dispatchNestedPreScroll(0, deltaY, mScrollConsumed, mScrollOffset)) {
|
||||
deltaY -= mScrollConsumed[1];
|
||||
mLastY = eventY - mScrollOffset[1];
|
||||
event.offsetLocation(0, -mScrollOffset[1]);
|
||||
mNestedOffsetY += mScrollOffset[1];
|
||||
}
|
||||
returnValue = super.onTouchEvent(event);
|
||||
|
||||
// NestedScroll
|
||||
if (dispatchNestedScroll(0, mScrollOffset[1], 0, deltaY, mScrollOffset)) {
|
||||
event.offsetLocation(0, mScrollOffset[1]);
|
||||
mNestedOffsetY += mScrollOffset[1];
|
||||
mLastY -= mScrollOffset[1];
|
||||
}
|
||||
break;
|
||||
case MotionEvent.ACTION_DOWN:
|
||||
returnValue = super.onTouchEvent(event);
|
||||
mLastY = eventY;
|
||||
// start NestedScroll
|
||||
startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL);
|
||||
break;
|
||||
case MotionEvent.ACTION_UP:
|
||||
case MotionEvent.ACTION_CANCEL:
|
||||
returnValue = super.onTouchEvent(event);
|
||||
// end NestedScroll
|
||||
stopNestedScroll();
|
||||
break;
|
||||
}
|
||||
return returnValue;
|
||||
}
|
||||
|
||||
// Nested Scroll implements
|
||||
@Override
|
||||
public void setNestedScrollingEnabled(boolean enabled) {
|
||||
mChildHelper.setNestedScrollingEnabled(enabled);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isNestedScrollingEnabled() {
|
||||
return mChildHelper.isNestedScrollingEnabled();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean startNestedScroll(int axes) {
|
||||
return mChildHelper.startNestedScroll(axes);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stopNestedScroll() {
|
||||
mChildHelper.stopNestedScroll();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasNestedScrollingParent() {
|
||||
return mChildHelper.hasNestedScrollingParent();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed,
|
||||
int[] offsetInWindow) {
|
||||
return mChildHelper.dispatchNestedScroll(dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, offsetInWindow);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean dispatchNestedPreScroll(int dx, int dy, int[] consumed, int[] offsetInWindow) {
|
||||
return mChildHelper.dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed) {
|
||||
return mChildHelper.dispatchNestedFling(velocityX, velocityY, consumed);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean dispatchNestedPreFling(float velocityX, float velocityY) {
|
||||
return mChildHelper.dispatchNestedPreFling(velocityX, velocityY);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -57,6 +57,10 @@ class WebViewActivity : BaseActivity() {
|
||||
webview.settings.javaScriptEnabled = true
|
||||
webview.settings.domStorageEnabled = true
|
||||
webview.settings.databaseEnabled = true
|
||||
webview.settings.useWideViewPort = true
|
||||
webview.settings.loadWithOverviewMode = true
|
||||
webview.settings.builtInZoomControls = true
|
||||
webview.settings.displayZoomControls = false
|
||||
webview.webViewClient = object : WebViewClient() {
|
||||
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
|
||||
super.onPageStarted(view, url, favicon)
|
||||
@@ -134,7 +138,6 @@ class WebViewActivity : BaseActivity() {
|
||||
|
||||
override fun onPrepareOptionsMenu(menu: Menu?): Boolean {
|
||||
menu?.findItem(R.id.action_forward)?.isEnabled = webview.canGoForward()
|
||||
menu?.findItem(R.id.action_desktop_site)?.isChecked = isDesktop
|
||||
|
||||
return super.onPrepareOptionsMenu(menu)
|
||||
}
|
||||
@@ -156,26 +159,6 @@ class WebViewActivity : BaseActivity() {
|
||||
android.R.id.home -> finish()
|
||||
R.id.action_refresh -> webview.reload()
|
||||
R.id.action_forward -> webview.goForward()
|
||||
R.id.action_desktop_site -> {
|
||||
isDesktop = !item.isChecked
|
||||
item.isChecked = isDesktop
|
||||
|
||||
(if(isDesktop) {
|
||||
mobileUserAgent?.replace("\\([^(]*(Mobile|Android)[^)]*\\)"
|
||||
.toRegex(RegexOption.IGNORE_CASE), "")
|
||||
?.replace("Mobile", "", true)
|
||||
?.replace("Android", "", true)
|
||||
} else {
|
||||
mobileUserAgent
|
||||
})?.let {
|
||||
webview.settings.userAgentString = it
|
||||
}
|
||||
|
||||
webview.settings.useWideViewPort = isDesktop
|
||||
webview.settings.loadWithOverviewMode = isDesktop
|
||||
|
||||
webview.reload()
|
||||
}
|
||||
R.id.action_open_in_browser ->
|
||||
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(webview.url)))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package exh.util
|
||||
|
||||
import rx.Observable
|
||||
import rx.Single
|
||||
import rx.subjects.ReplaySubject
|
||||
|
||||
/**
|
||||
* Transform a cold single to a hot single
|
||||
*
|
||||
* Note: Behaves like a ReplaySubject
|
||||
* All generated items are buffered in memory!
|
||||
*/
|
||||
fun <T> Single<T>.melt(): Single<T> {
|
||||
return toObservable().melt().toSingle()
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform a cold observable to a hot observable
|
||||
*
|
||||
* Note: Behaves like a ReplaySubject
|
||||
* All generated items are buffered in memory!
|
||||
*/
|
||||
fun <T> Observable<T>.melt(): Observable<T> {
|
||||
val rs = ReplaySubject.create<T>()
|
||||
subscribe(rs)
|
||||
return rs
|
||||
}
|
||||
Reference in New Issue
Block a user