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:
NerdNumber9
2019-04-06 07:35:36 -04:00
parent 5fbe1a8614
commit 603fd84753
97 changed files with 4833 additions and 1998 deletions

View File

@@ -8,6 +8,8 @@ import java.io.File
object Migrations {
// TODO NATIVE TACHIYOMI MIGRATIONS ARE FUCKED UP DUE TO DIFFERING VERSION NUMBERS
/**
* Performs a migration when the application is updated.
*

View File

@@ -6,18 +6,28 @@ import com.pushtorefresh.storio.sqlite.impl.DefaultStorIOSQLite
import eu.kanade.tachiyomi.data.database.mappers.*
import eu.kanade.tachiyomi.data.database.models.*
import eu.kanade.tachiyomi.data.database.queries.*
import exh.metadata.sql.mappers.SearchMetadataTypeMapping
import exh.metadata.sql.mappers.SearchTagTypeMapping
import exh.metadata.sql.mappers.SearchTitleTypeMapping
import exh.metadata.sql.models.SearchMetadata
import exh.metadata.sql.models.SearchTag
import exh.metadata.sql.models.SearchTitle
import exh.metadata.sql.queries.SearchMetadataQueries
import exh.metadata.sql.queries.SearchTagQueries
import exh.metadata.sql.queries.SearchTitleQueries
import io.requery.android.database.sqlite.RequerySQLiteOpenHelperFactory
/**
* This class provides operations to manage the database through its interfaces.
*/
open class DatabaseHelper(context: Context)
: MangaQueries, ChapterQueries, TrackQueries, CategoryQueries, MangaCategoryQueries, HistoryQueries {
: MangaQueries, ChapterQueries, TrackQueries, CategoryQueries, MangaCategoryQueries, HistoryQueries,
/* EXH --> */ SearchMetadataQueries, SearchTagQueries, SearchTitleQueries /* EXH <-- */
{
private val configuration = SupportSQLiteOpenHelper.Configuration.builder(context)
.name(DbOpenCallback.DATABASE_NAME)
.callback(DbOpenCallback())
.build()
.name(DbOpenCallback.DATABASE_NAME)
.callback(DbOpenCallback())
.build()
override val db = DefaultStorIOSQLite.builder()
.sqliteOpenHelper(RequerySQLiteOpenHelperFactory().create(configuration))
@@ -27,6 +37,11 @@ open class DatabaseHelper(context: Context)
.addTypeMapping(Category::class.java, CategoryTypeMapping())
.addTypeMapping(MangaCategory::class.java, MangaCategoryTypeMapping())
.addTypeMapping(History::class.java, HistoryTypeMapping())
// EXH -->
.addTypeMapping(SearchMetadata::class.java, SearchMetadataTypeMapping())
.addTypeMapping(SearchTag::class.java, SearchTagTypeMapping())
.addTypeMapping(SearchTitle::class.java, SearchTitleTypeMapping())
// EXH <--
.build()
inline fun inTransaction(block: () -> Unit) = db.inTransaction(block)

View File

@@ -2,10 +2,10 @@ package eu.kanade.tachiyomi.data.database
import android.arch.persistence.db.SupportSQLiteDatabase
import android.arch.persistence.db.SupportSQLiteOpenHelper
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper
import eu.kanade.tachiyomi.data.database.tables.*
import exh.metadata.sql.tables.SearchMetadataTable
import exh.metadata.sql.tables.SearchTagTable
import exh.metadata.sql.tables.SearchTitleTable
class DbOpenCallback : SupportSQLiteOpenHelper.Callback(DATABASE_VERSION) {
@@ -18,7 +18,7 @@ class DbOpenCallback : SupportSQLiteOpenHelper.Callback(DATABASE_VERSION) {
/**
* Version of the database.
*/
const val DATABASE_VERSION = 8
const val DATABASE_VERSION = 9 // [EXH]
}
override fun onCreate(db: SupportSQLiteDatabase) = with(db) {
@@ -28,6 +28,11 @@ class DbOpenCallback : SupportSQLiteOpenHelper.Callback(DATABASE_VERSION) {
execSQL(CategoryTable.createTableQuery)
execSQL(MangaCategoryTable.createTableQuery)
execSQL(HistoryTable.createTableQuery)
// EXH -->
execSQL(SearchMetadataTable.createTableQuery)
execSQL(SearchTagTable.createTableQuery)
execSQL(SearchTitleTable.createTableQuery)
// EXH <--
// DB indexes
execSQL(MangaTable.createUrlIndexQuery)
@@ -35,6 +40,14 @@ class DbOpenCallback : SupportSQLiteOpenHelper.Callback(DATABASE_VERSION) {
execSQL(ChapterTable.createMangaIdIndexQuery)
execSQL(ChapterTable.createUnreadChaptersIndexQuery)
execSQL(HistoryTable.createChapterIdIndexQuery)
// EXH -->
db.execSQL(SearchMetadataTable.createUploaderIndexQuery)
db.execSQL(SearchMetadataTable.createIndexedExtraIndexQuery)
db.execSQL(SearchTagTable.createMangaIdIndexQuery)
db.execSQL(SearchTagTable.createNamespaceNameIndexQuery)
db.execSQL(SearchTitleTable.createMangaIdIndexQuery)
db.execSQL(SearchTitleTable.createTitleIndexQuery)
// EXH <--
}
override fun onUpgrade(db: SupportSQLiteDatabase, oldVersion: Int, newVersion: Int) {
@@ -67,6 +80,21 @@ class DbOpenCallback : SupportSQLiteOpenHelper.Callback(DATABASE_VERSION) {
db.execSQL(MangaTable.createLibraryIndexQuery)
db.execSQL(ChapterTable.createUnreadChaptersIndexQuery)
}
// EXH -->
if (oldVersion < 9) {
db.execSQL(SearchMetadataTable.createTableQuery)
db.execSQL(SearchTagTable.createTableQuery)
db.execSQL(SearchTitleTable.createTableQuery)
db.execSQL(SearchMetadataTable.createUploaderIndexQuery)
db.execSQL(SearchMetadataTable.createIndexedExtraIndexQuery)
db.execSQL(SearchTagTable.createMangaIdIndexQuery)
db.execSQL(SearchTagTable.createNamespaceNameIndexQuery)
db.execSQL(SearchTitleTable.createMangaIdIndexQuery)
db.execSQL(SearchTitleTable.createTitleIndexQuery)
}
// Remember to increment any Tachiyomi database upgrades after this
// EXH <--
}
override fun onConfigure(db: SupportSQLiteDatabase) {

View File

@@ -0,0 +1,34 @@
package eu.kanade.tachiyomi.data.database.resolvers
import android.content.ContentValues
import com.pushtorefresh.storio.sqlite.StorIOSQLite
import com.pushtorefresh.storio.sqlite.operations.put.PutResolver
import com.pushtorefresh.storio.sqlite.operations.put.PutResult
import com.pushtorefresh.storio.sqlite.queries.UpdateQuery
import eu.kanade.tachiyomi.data.database.inTransactionReturn
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.database.tables.MangaTable
// [EXH]
class MangaUrlPutResolver : PutResolver<Manga>() {
override fun performPut(db: StorIOSQLite, manga: Manga) = db.inTransactionReturn {
val updateQuery = mapToUpdateQuery(manga)
val contentValues = mapToContentValues(manga)
val numberOfRowsUpdated = db.lowLevel().update(updateQuery, contentValues)
PutResult.newUpdateResult(numberOfRowsUpdated, updateQuery.table())
}
fun mapToUpdateQuery(manga: Manga) = UpdateQuery.builder()
.table(MangaTable.TABLE)
.where("${MangaTable.COL_ID} = ?")
.whereArgs(manga.id)
.build()
fun mapToContentValues(manga: Manga) = ContentValues(1).apply {
put(MangaTable.COL_URL, manga.url)
}
}

View File

@@ -157,15 +157,7 @@ object PreferenceKeys {
const val eh_ts_aspNetCookie = "eh_ts_aspNetCookie"
const val eh_showSettingsUploadWarning = "eh_showSettingsUploadWarning1"
const val eh_hl_earlyRefresh = "eh_hl_early_refresh"
const val eh_hl_refreshFrequency = "eh_hl_refresh_frequency"
const val eh_hl_lastRefresh = "eh_hl_last_refresh"
const val eh_hl_lastRealmIndex = "eh_hl_lastRealmIndex"
const val eh_showSettingsUploadWarning = "eh_showSettingsUploadWarning2"
const val eh_expandFilters = "eh_expand_filters"
@@ -182,4 +174,8 @@ object PreferenceKeys {
const val eh_preserveReadingPosition = "eh_preserve_reading_position"
const val eh_incogWebview = "eh_incognito_webview"
const val eh_autoSolveCaptchas = "eh_autosolve_captchas"
const val eh_delegateSources = "eh_delegate_sources"
}

View File

@@ -189,16 +189,16 @@ class PreferencesHelper(val context: Context) {
fun thumbnailRows() = rxPrefs.getString("ex_thumb_rows", "tr_2")
fun migrateLibraryAsked2() = rxPrefs.getBoolean("ex_migrate_library2", false)
fun migrateLibraryAsked() = rxPrefs.getBoolean("ex_migrate_library3", false)
fun migrationStatus() = rxPrefs.getInteger("migration_status", MigrationStatus.NOT_INITIALIZED)
fun hasPerformedURLMigration() = rxPrefs.getBoolean("performed_url_migration", false)
//EH Cookies
fun memberIdVal() = rxPrefs.getString("eh_ipb_member_id", null)
fun passHashVal() = rxPrefs.getString("eh_ipb_pass_hash", null)
fun igneousVal() = rxPrefs.getString("eh_igneous", null)
fun memberIdVal() = rxPrefs.getString("eh_ipb_member_id", "")
fun passHashVal() = rxPrefs.getString("eh_ipb_pass_hash", "")
fun igneousVal() = rxPrefs.getString("eh_igneous", "")
fun eh_ehSettingsProfile() = rxPrefs.getInteger(Keys.eh_ehSettingsProfile, -1)
fun eh_exhSettingsProfile() = rxPrefs.getInteger(Keys.eh_exhSettingsProfile, -1)
fun eh_settingsKey() = rxPrefs.getString(Keys.eh_settingsKey, "")
@@ -228,16 +228,6 @@ class PreferencesHelper(val context: Context) {
fun eh_showSettingsUploadWarning() = rxPrefs.getBoolean(Keys.eh_showSettingsUploadWarning, true)
// Default is 24h, refresh daily
fun eh_hl_earlyRefresh() = rxPrefs.getBoolean(Keys.eh_hl_earlyRefresh, false)
fun eh_hl_refreshFrequency() = rxPrefs.getString(Keys.eh_hl_refreshFrequency, "24")
fun eh_hl_lastRefresh() = rxPrefs.getLong(Keys.eh_hl_lastRefresh, 0L)
fun eh_hl_lastRealmIndex() = rxPrefs.getInteger(Keys.eh_hl_lastRealmIndex, -1)
// <-- EH
fun eh_expandFilters() = rxPrefs.getBoolean(Keys.eh_expandFilters, false)
fun eh_readerThreads() = rxPrefs.getInteger(Keys.eh_readerThreads, 2)
@@ -253,4 +243,12 @@ class PreferencesHelper(val context: Context) {
fun eh_incogWebview() = rxPrefs.getBoolean(Keys.eh_incogWebview, false)
fun eh_askCategoryOnLongPress() = rxPrefs.getBoolean(Keys.eh_askCategoryOnLongPress, false)
fun eh_autoSolveCaptchas() = rxPrefs.getBoolean(Keys.eh_autoSolveCaptchas, false)
fun eh_delegateSources() = rxPrefs.getBoolean(Keys.eh_delegateSources, true)
fun eh_lastVersionCode() = rxPrefs.getInteger("eh_last_version_code", 0)
fun eh_savedSearches() = rxPrefs.getString("eh_saved_searches", "")
}

View File

@@ -22,8 +22,12 @@ import exh.EH_SOURCE_ID
import exh.EXH_SOURCE_ID
import exh.PERV_EDEN_EN_SOURCE_ID
import exh.PERV_EDEN_IT_SOURCE_ID
import exh.metadata.models.PervEdenLang
import exh.metadata.metadata.PervEdenLang
import exh.source.DelegatedHttpSource
import exh.source.EnhancedHttpSource
import timber.log.Timber
import uy.kohesive.injekt.injectLazy
import kotlin.reflect.KClass
open class SourceManager(private val context: Context) {
@@ -66,8 +70,17 @@ open class SourceManager(private val context: Context) {
fun getCatalogueSources() = sourcesMap.values.filterIsInstance<CatalogueSource>()
internal fun registerSource(source: Source, overwrite: Boolean = false) {
val sourceQName = source::class.qualifiedName
val delegate = DELEGATED_SOURCES[sourceQName]
val newSource = if(source is HttpSource && delegate != null) {
Timber.d("[EXH] Delegating source: %s -> %s!", sourceQName, delegate.newSourceClass.qualifiedName)
EnhancedHttpSource(
source,
delegate.newSourceClass.constructors.find { it.parameters.size == 1 }!!.call(source)
)
} else source
if (overwrite || !sourcesMap.containsKey(source.id)) {
sourcesMap[source.id] = source
sourcesMap[source.id] = newSource
}
}
@@ -99,9 +112,8 @@ open class SourceManager(private val context: Context) {
exSrcs += PervEden(PERV_EDEN_EN_SOURCE_ID, PervEdenLang.en)
exSrcs += PervEden(PERV_EDEN_IT_SOURCE_ID, PervEdenLang.it)
exSrcs += NHentai(context)
exSrcs += HentaiCafe()
exSrcs += Tsumino(context)
exSrcs += Hitomi(context)
exSrcs += Hitomi()
return exSrcs
}
@@ -130,4 +142,20 @@ open class SourceManager(private val context: Context) {
return Exception(context.getString(R.string.source_not_installed, id.toString()))
}
}
companion object {
val DELEGATED_SOURCES = listOf(
DelegatedSource(
"Hentai Cafe",
260868874183818481,
"eu.kanade.tachiyomi.extension.all.foolslide.HentaiCafe",
HentaiCafe::class
)
).associateBy { it.originalSourcePackageName }
data class DelegatedSource(val sourceName: String,
val sourceId: Long,
val originalSourcePackageName: String,
val newSourceClass: KClass<out DelegatedHttpSource>)
}
}

View File

@@ -1,54 +1,102 @@
package eu.kanade.tachiyomi.source.online
import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import exh.metadata.models.GalleryQuery
import exh.metadata.models.SearchableGalleryMetadata
import exh.util.createUUIDObj
import exh.util.defRealm
import exh.util.realmTrans
import rx.Observable
import exh.metadata.metadata.base.RaisedSearchMetadata
import exh.metadata.metadata.base.getFlatMetadataForManga
import exh.metadata.metadata.base.insertFlatMetadata
import rx.Completable
import rx.Single
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import kotlin.reflect.KClass
/**
* LEWD!
*/
interface LewdSource<M : SearchableGalleryMetadata, I> : CatalogueSource {
fun queryAll(): GalleryQuery<M>
interface LewdSource<M : RaisedSearchMetadata, I> : CatalogueSource {
val db: DatabaseHelper get() = Injekt.get()
fun queryFromUrl(url: String): GalleryQuery<M>
/**
* The class of the metadata used by this source
*/
val metaClass: KClass<M>
val metaParser: M.(I) -> Unit
/**
* Parse the supplied input into the supplied metadata object
*/
fun parseIntoMetadata(metadata: M, input: I)
fun parseToManga(query: GalleryQuery<M>, input: I): SManga
= realmTrans { realm ->
val meta = realm.copyFromRealm(query.query(realm).findFirst()
?: realm.createUUIDObj(queryAll().clazz.java))
/**
* Use reflection to create a new instance of metadata
*/
private fun newMetaInstance() = metaClass.constructors.find {
it.parameters.isEmpty()
}?.call() ?: error("Could not find no-args constructor for meta class: ${metaClass.qualifiedName}!")
metaParser(meta, input)
realm.copyToRealmOrUpdate(meta)
SManga.create().apply {
meta.copyTo(this)
}
}
fun lazyLoadMeta(query: GalleryQuery<M>, parserInput: Observable<I>): Observable<M> {
return defRealm { realm ->
val possibleOutput = query.query(realm).findFirst()
if(possibleOutput == null)
parserInput.map {
realmTrans { realm ->
val meta = realm.createUUIDObj(queryAll().clazz.java)
metaParser(meta, it)
realm.copyFromRealm(meta)
/**
* Parses metadata from the input and then copies it into the manga
*
* Will also save the metadata to the DB if possible
*/
fun parseToManga(manga: SManga, input: I): Completable {
val mangaId = (manga as? Manga)?.id
val metaObservable = if(mangaId != null) {
db.getFlatMetadataForManga(mangaId).asRxSingle()
.map {
if(it != null) it.raise(metaClass)
else newMetaInstance()
}
}
else
Observable.just(realm.copyFromRealm(possibleOutput))
} else {
Single.just(newMetaInstance())
}
return metaObservable.map {
parseIntoMetadata(it, input)
it.copyTo(manga)
it
}.flatMapCompletable {
if(mangaId != null) {
it.mangaId = mangaId
db.insertFlatMetadata(it.flatten())
} else Completable.complete()
}
}
/**
* Try to first get the metadata from the DB. If the metadata is not in the DB, calls the input
* producer and parses the metadata from the input
*
* If the metadata needs to be parsed from the input producer, the resulting parsed metadata will
* also be saved to the DB.
*/
fun getOrLoadMetadata(mangaId: Long?, inputProducer: () -> Single<I>): Single<M> {
val metaObservable = if(mangaId != null) {
db.getFlatMetadataForManga(mangaId).asRxSingle()
.map {
it?.raise(metaClass)
}
} else Single.just(null)
return metaObservable.flatMap { existingMeta ->
if(existingMeta == null) {
inputProducer().flatMap { input ->
val newMeta = newMetaInstance()
parseIntoMetadata(newMeta, input)
val newMetaSingle = Single.just(newMeta)
if(mangaId != null) {
newMeta.mangaId = mangaId
db.insertFlatMetadata(newMeta.flatten()).andThen(newMetaSingle)
} else newMetaSingle
}
} else Single.just(existingMeta)
}
}
val SManga.id get() = (this as? Manga)?.id
val SChapter.mangaId get() = (this as? Chapter)?.manga_id
}

View File

@@ -12,8 +12,12 @@ import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.source.online.LewdSource
import eu.kanade.tachiyomi.util.asJsoup
import exh.metadata.EX_DATE_FORMAT
import exh.metadata.metadata.EHentaiSearchMetadata
import exh.metadata.metadata.EHentaiSearchMetadata.Companion.EH_GENRE_NAMESPACE
import exh.metadata.metadata.EHentaiSearchMetadata.Companion.TAG_TYPE_LIGHT
import exh.metadata.metadata.EHentaiSearchMetadata.Companion.TAG_TYPE_NORMAL
import exh.metadata.metadata.base.RaisedSearchMetadata.Companion.TAG_TYPE_VIRTUAL
import exh.metadata.models.ExGalleryMetadata
import exh.metadata.models.Tag
import exh.metadata.nullIfBlank
import exh.metadata.parseHumanReadableByteCount
import exh.ui.login.LoginController
@@ -28,10 +32,12 @@ import rx.Observable
import uy.kohesive.injekt.injectLazy
import java.net.URLEncoder
import java.util.*
import exh.metadata.metadata.base.RaisedTag
class EHentai(override val id: Long,
val exh: Boolean,
val context: Context) : HttpSource(), LewdSource<ExGalleryMetadata, Response> {
val context: Context) : HttpSource(), LewdSource<EHentaiSearchMetadata, Response> {
override val metaClass = EHentaiSearchMetadata::class
val schema: String
get() = if(prefs.secureEXH().getOrDefault())
@@ -60,26 +66,29 @@ class EHentai(override val id: Long,
fun extendedGenericMangaParse(doc: Document)
= with(doc) {
//Parse mangas
val parsedMangas = select(".gtr0,.gtr1").map {
// Parse mangas (supports compact + extended layout)
val parsedMangas = select(".itg > tbody > tr").filter {
// Do not parse header and ads
it.selectFirst("th") == null && it.selectFirst(".itd") == null
}.map {
val thumbnailElement = it.selectFirst(".gl1e img, .gl2c .glthumb img")
val column2 = it.selectFirst(".gl3e, .gl2c")
val linkElement = it.selectFirst(".gl3c > a, .gl2e > div > a")
val favElement = column2.children().find { it.attr("style").startsWith("border-color") }
ParsedManga(
fav = parseFavoritesStyle(it.select(".itd .it3 > .i[id]").first()?.attr("style")),
fav = FAVORITES_BORDER_HEX_COLORS.indexOf(
favElement?.attr("style")?.substring(14, 17)
),
manga = Manga.create(id).apply {
//Get title
it.select(".itd .it5 a").first()?.apply {
title = text()
url = ExGalleryMetadata.normalizeUrl(attr("href"))
}
title = thumbnailElement.attr("title")
url = EHentaiSearchMetadata.normalizeUrl(linkElement.attr("href"))
//Get image
it.select(".itd .it2").first()?.apply {
children().first()?.let {
thumbnail_url = it.attr("src")
} ?: let {
text().split("~").apply {
thumbnail_url = "http://${this[1]}/${this[2]}"
}
}
}
thumbnail_url = thumbnailElement.attr("src")
// TODO Parse genre + uploader + tags
})
}
@@ -97,14 +106,6 @@ class EHentai(override val id: Long,
Pair(parsedMangas, hasNextPage)
}
fun parseFavoritesStyle(style: String?): Int {
val offset = style?.substringAfterLast("background-position:0px ")
?.removeSuffix("px; cursor:pointer")
?.toIntOrNull() ?: return -1
return (offset + 2)/-19
}
/**
* Parse a list of galleries
*/
@@ -158,17 +159,17 @@ class EHentai(override val id: Long,
override fun popularMangaRequest(page: Int) = if(exh)
latestUpdatesRequest(page)
else
exGet("$baseUrl/toplist.php?tl=15", page)
exGet("$baseUrl/toplist.php?tl=15&p=${page - 1}", null) // Custom page logic for toplists
//Support direct URL importing
override fun fetchSearchManga(page: Int, query: String, filters: FilterList) =
urlImportFetchSearchManga(query, {
urlImportFetchSearchManga(query) {
searchMangaRequestObservable(page, query, filters).flatMap {
client.newCall(it).asObservableSuccess()
} .map { response ->
searchMangaParse(response)
}
})
}
private fun searchMangaRequestObservable(page: Int, query: String, filters: FilterList): Observable<Request> {
val uri = Uri.parse("$baseUrl$QUERY_PREFIX").buildUpon()
@@ -229,82 +230,112 @@ class EHentai(override val id: Long,
}!!
/**
* Parse gallery page to metadata model
* 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 mangaDetailsParse(response: Response): SManga {
return parseToManga(queryFromUrl(response.request().url().toString()), response)
override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
return client.newCall(mangaDetailsRequest(manga))
.asObservableSuccess()
.flatMap {
parseToManga(manga, it).andThen(Observable.just(manga.apply {
initialized = true
}))
}
}
override val metaParser: ExGalleryMetadata.(Response) -> Unit = { response ->
with(response.asJsoup()) {
url = response.request().url().encodedPath()!!
gId = ExGalleryMetadata.galleryId(url!!)
gToken = ExGalleryMetadata.galleryToken(url!!)
/**
* Parse gallery page to metadata model
*/
override fun mangaDetailsParse(response: Response) = throw UnsupportedOperationException()
exh = this@EHentai.exh
title = select("#gn").text().nullIfBlank()?.trim()
override fun parseIntoMetadata(metadata: EHentaiSearchMetadata, input: Response) {
with(metadata) {
with(input.asJsoup()) {
val url = input.request().url().encodedPath()!!
gId = ExGalleryMetadata.galleryId(url)
gToken = ExGalleryMetadata.galleryToken(url)
altTitle = select("#gj").text().nullIfBlank()?.trim()
exh = this@EHentai.exh
title = select("#gn").text().nullIfBlank()?.trim()
thumbnailUrl = select("#gd1 div").attr("style").nullIfBlank()?.let {
it.substring(it.indexOf('(') + 1 until it.lastIndexOf(')'))
}
genre = select(".ic").parents().attr("href").nullIfBlank()?.trim()?.substringAfterLast('/')
altTitle = select("#gj").text().nullIfBlank()?.trim()
uploader = select("#gdn").text().nullIfBlank()?.trim()
//Parse the table
select("#gdd tr").forEach {
it.select(".gdt1")
.text()
thumbnailUrl = select("#gd1 div").attr("style").nullIfBlank()?.let {
it.substring(it.indexOf('(') + 1 until it.lastIndexOf(')'))
}
genre = select(".cs")
.attr("onclick")
.nullIfBlank()
?.trim()
?.let { left ->
it.select(".gdt2")
.text()
.nullIfBlank()
?.trim()
?.let { right ->
ignore {
when (left.removeSuffix(":")
.toLowerCase()) {
"posted" -> datePosted = EX_DATE_FORMAT.parse(right).time
"visible" -> visible = right.nullIfBlank()
"language" -> {
language = right.removeSuffix(TR_SUFFIX).trim().nullIfBlank()
translated = right.endsWith(TR_SUFFIX, true)
}
"file size" -> size = parseHumanReadableByteCount(right)?.toLong()
"length" -> length = right.removeSuffix("pages").trim().nullIfBlank()?.toInt()
"favorited" -> favorites = right.removeSuffix("times").trim().nullIfBlank()?.toInt()
}
}
}
?.substringAfterLast('/')
?.removeSuffix("'")
uploader = select("#gdn").text().nullIfBlank()?.trim()
//Parse the table
select("#gdd tr").forEach {
val left = it.select(".gdt1").text().nullIfBlank()?.trim()
val rightElement = it.selectFirst(".gdt2")
val right = rightElement.text().nullIfBlank()?.trim()
if(left != null && right != null) {
ignore {
when (left.removeSuffix(":")
.toLowerCase()) {
"posted" -> datePosted = EX_DATE_FORMAT.parse(right).time
// Example gallery with parent: https://e-hentai.org/g/1390451/7f181c2426/
"parent" -> parent = if (!right.equals("None", true)) {
rightElement.child(0).attr("href")
} else null
"visible" -> visible = right.nullIfBlank()
"language" -> {
language = right.removeSuffix(TR_SUFFIX).trim().nullIfBlank()
translated = right.endsWith(TR_SUFFIX, true)
}
"file size" -> size = parseHumanReadableByteCount(right)?.toLong()
"length" -> length = right.removeSuffix("pages").trim().nullIfBlank()?.toInt()
"favorited" -> favorites = right.removeSuffix("times").trim().nullIfBlank()?.toInt()
}
}
}
}
}
//Parse ratings
ignore {
averageRating = select("#rating_label")
.text()
.removePrefix("Average:")
.trim()
.nullIfBlank()
?.toDouble()
ratingCount = select("#rating_count")
.text()
.trim()
.nullIfBlank()
?.toInt()
}
//Parse ratings
ignore {
averageRating = select("#rating_label")
.text()
.removePrefix("Average:")
.trim()
.nullIfBlank()
?.toDouble()
ratingCount = select("#rating_count")
.text()
.trim()
.nullIfBlank()
?.toInt()
}
//Parse tags
tags.clear()
select("#taglist tr").forEach {
val namespace = it.select(".tc").text().removeSuffix(":")
tags.addAll(it.select("div").map {
Tag(namespace, it.text().trim(), it.hasClass("gtl"))
})
//Parse tags
tags.clear()
select("#taglist tr").forEach {
val namespace = it.select(".tc").text().removeSuffix(":")
tags.addAll(it.select("div").map { element ->
RaisedTag(
namespace,
element.text().trim(),
if(element.hasClass("gtl"))
TAG_TYPE_LIGHT
else
TAG_TYPE_NORMAL
)
})
}
// Add genre as virtual tag
genre?.let {
tags.add(RaisedTag(EH_GENRE_NAMESPACE, it, TAG_TYPE_VIRTUAL))
}
}
}
}
@@ -392,8 +423,11 @@ class EHentai(override val id: Long,
cookies["hath_perks"] = hathPerksCookie
}
//Session-less list display mode (for users without ExHentai)
cookies["sl"] = "dm_0"
// Session-less extended display mode (for users without ExHentai)
cookies["sl"] = "dm_2"
// Ignore all content warnings
cookies["nw"] = "1"
return cookies
}
@@ -431,23 +465,26 @@ class EHentai(override val id: Long,
ReverseFilter()
)
class GenreOption(name: String, val genreId: String): Filter.CheckBox(name, false), UriFilter {
class GenreOption(name: String, val genreId: Int): Filter.CheckBox(name, false)
class GenreGroup : Filter.Group<GenreOption>("Genres", listOf(
GenreOption("Dōjinshi", 2),
GenreOption("Manga", 4),
GenreOption("Artist CG", 8),
GenreOption("Game CG", 16),
GenreOption("Western", 512),
GenreOption("Non-H", 256),
GenreOption("Image Set", 32),
GenreOption("Cosplay", 64),
GenreOption("Asian Porn", 128),
GenreOption("Misc", 1)
)), UriFilter {
override fun addToUri(builder: Uri.Builder) {
builder.appendQueryParameter("f_" + genreId, if(state) "1" else "0")
val bits = state.fold(0) { acc, genre ->
if(!genre.state) acc + genre.genreId else acc
}
builder.appendQueryParameter("f_cats", bits.toString())
}
}
class GenreGroup : UriGroup<GenreOption>("Genres", listOf(
GenreOption("Dōjinshi", "doujinshi"),
GenreOption("Manga", "manga"),
GenreOption("Artist CG", "artistcg"),
GenreOption("Game CG", "gamecg"),
GenreOption("Western", "western"),
GenreOption("Non-H", "non-h"),
GenreOption("Image Set", "imageset"),
GenreOption("Cosplay", "cosplay"),
GenreOption("Asian Porn", "asianporn"),
GenreOption("Misc", "misc")
))
class AdvancedOption(name: String, val param: String, defValue: Boolean = false): Filter.CheckBox(name, defValue), UriFilter {
override fun addToUri(builder: Uri.Builder) {
@@ -486,13 +523,23 @@ class EHentai(override val id: Long,
else
"E-Hentai"
override fun queryAll() = ExGalleryMetadata.EmptyQuery()
override fun queryFromUrl(url: String) = ExGalleryMetadata.UrlQuery(url, exh)
companion object {
val QUERY_PREFIX = "?f_apply=Apply+Filter"
val TR_SUFFIX = "TR"
val REVERSE_PARAM = "TEH_REVERSE"
private const val QUERY_PREFIX = "?f_apply=Apply+Filter"
private const val TR_SUFFIX = "TR"
private const val REVERSE_PARAM = "TEH_REVERSE"
private val FAVORITES_BORDER_HEX_COLORS = listOf(
"000",
"f00",
"fa0",
"dd0",
"080",
"9f4",
"4bf",
"00f",
"508",
"e8e"
)
fun buildCookies(cookies: Map<String, String>)
= cookies.entries.joinToString(separator = "; ") {

View File

@@ -5,7 +5,6 @@ import android.net.Uri
import com.github.salomonbrys.kotson.*
import com.google.gson.JsonElement
import com.google.gson.JsonNull
import com.google.gson.JsonObject
import com.google.gson.JsonParser
import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.R
@@ -14,55 +13,93 @@ import eu.kanade.tachiyomi.network.asObservableSuccess
import eu.kanade.tachiyomi.source.model.*
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.source.online.LewdSource
import eu.kanade.tachiyomi.util.asJsoup
import exh.NHENTAI_SOURCE_ID
import exh.metadata.metadata.NHentaiSearchMetadata
import exh.metadata.metadata.NHentaiSearchMetadata.Companion.TAG_TYPE_DEFAULT
import exh.metadata.metadata.base.RaisedTag
import exh.metadata.models.NHentaiMetadata
import exh.metadata.models.PageImageType
import exh.metadata.models.Tag
import exh.util.*
import okhttp3.Request
import okhttp3.Response
import rx.Observable
import timber.log.Timber
/**
* NHentai source
*/
class NHentai(context: Context) : HttpSource(), LewdSource<NHentaiMetadata, JsonObject> {
class NHentai(context: Context) : HttpSource(), LewdSource<NHentaiSearchMetadata, Response> {
override val metaClass = NHentaiSearchMetadata::class
override fun fetchPopularManga(page: Int): Observable<MangasPage> {
//TODO There is currently no way to get the most popular mangas
//TODO Instead, we delegate this to the latest updates thing to avoid confusing users with an empty screen
return fetchLatestUpdates(page)
}
override fun popularMangaRequest(page: Int): Request {
TODO("Currently unavailable!")
}
override fun popularMangaRequest(page: Int) = throw UnsupportedOperationException()
override fun popularMangaParse(response: Response): MangasPage {
TODO("Currently unavailable!")
}
override fun popularMangaParse(response: Response) = throw UnsupportedOperationException()
//Support direct URL importing
override fun fetchSearchManga(page: Int, query: String, filters: FilterList) =
urlImportFetchSearchManga(query, {
super.fetchSearchManga(page, query, filters)
})
urlImportFetchSearchManga(query) {
searchMangaRequestObservable(page, query, filters).flatMap {
client.newCall(it).asObservableSuccess()
} .map { response ->
searchMangaParse(response)
}
}
private fun searchMangaRequestObservable(page: Int, query: String, filters: FilterList): Observable<Request> {
val uri = if(query.isNotBlank()) {
Uri.parse("$baseUrl/search/").buildUpon().apply {
appendQueryParameter("q", query)
}
} else {
Uri.parse(baseUrl).buildUpon()
}
val sortFilter = filters.filterIsInstance<SortFilter>().firstOrNull()?.state
?: defaultSortFilterSelection()
if(sortFilter.index == 1) {
if(query.isBlank()) error("You must specify a search query if you wish to sort by popularity!")
uri.appendQueryParameter("sort", "popular")
}
if(sortFilter.ascending) {
return client.newCall(nhGet(uri.toString()))
.asObservableSuccess()
.map {
val doc = it.asJsoup()
val lastPage = doc.selectFirst(".last")
?.attr("href")
?.substringAfterLast('=')
?.toIntOrNull() ?: 1
val thisPage = lastPage - (page - 1)
uri.appendQueryParameter(REVERSE_PARAM, (thisPage > 1).toString())
uri.appendQueryParameter("page", thisPage.toString())
nhGet(uri.toString(), page)
}
}
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
//Currently we have no filters
//TODO Filter builder
val uri = Uri.parse("$baseUrl/api/galleries/search").buildUpon()
uri.appendQueryParameter("query", query)
uri.appendQueryParameter("page", page.toString())
return nhGet(uri.toString(), page)
return Observable.just(nhGet(uri.toString(), page))
}
override fun searchMangaRequest(page: Int, query: String, filters: FilterList)
= throw UnsupportedOperationException()
override fun searchMangaParse(response: Response)
= parseResultPage(response)
override fun latestUpdatesRequest(page: Int): Request {
val uri = Uri.parse("$baseUrl/api/galleries/all").buildUpon()
val uri = Uri.parse(baseUrl).buildUpon()
uri.appendQueryParameter("page", page.toString())
return nhGet(uri.toString(), page)
}
@@ -70,124 +107,122 @@ class NHentai(context: Context) : HttpSource(), LewdSource<NHentaiMetadata, Json
override fun latestUpdatesParse(response: Response)
= parseResultPage(response)
override fun mangaDetailsParse(response: Response): SManga {
val obj = jsonParser.parse(response.body()!!.string()).asJsonObject
return parseToManga(NHentaiMetadata.Query(obj["id"].long), obj)
}
override fun mangaDetailsParse(response: Response) = throw UnsupportedOperationException()
//Used so we can use a different URL for fetching manga details and opening the details in the browser
/**
* Returns an observable with the updated details for a manga. Normally it's not needed to
* override this method.
*
* @param manga the manga to be updated.
*/
override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
return client.newCall(urlToDetailsRequest(manga.url))
return client.newCall(mangaDetailsRequest(manga))
.asObservableSuccess()
.map { response ->
mangaDetailsParse(response).apply { initialized = true }
.flatMap {
parseToManga(manga, it).andThen(Observable.just(manga.apply {
initialized = true
}))
}
}
override fun mangaDetailsRequest(manga: SManga)
= nhGet(manga.url)
= nhGet(baseUrl + manga.url)
fun urlToDetailsRequest(url: String)
= nhGet(baseUrl + "/api/gallery/" + url.split("/").last { it.isNotBlank() })
fun parseResultPage(response: Response): MangasPage {
val res = jsonParser.parse(response.body()!!.string()).asJsonObject
val doc = response.asJsoup()
val error = res.get("error")
if(error == null) {
val results = res.getAsJsonArray("result")?.map {
val obj = it.asJsonObject
parseToManga(NHentaiMetadata.Query(obj["id"].long), obj)
// TODO Parse lang + tags
val mangas = doc.select(".gallery > a").map {
SManga.create().apply {
url = it.attr("href")
title = it.selectFirst(".caption").text()
// last() is a hack to ignore the lazy-loader placeholder image on the front page
thumbnail_url = it.select("img").last().attr("src")
// In some pages, the thumbnail url does not include the protocol
if(!thumbnail_url!!.startsWith("https:")) thumbnail_url = "https:$thumbnail_url"
}
val numPages = res.get("num_pages")?.int
if(results != null && numPages != null)
return MangasPage(results, numPages > response.request().tag() as Int)
}
val hasNextPage = if(!response.request().url().queryParameterNames().contains(REVERSE_PARAM)) {
doc.selectFirst(".next") != null
} else {
Timber.w("An error occurred while performing the search: $error")
response.request().url().queryParameter(REVERSE_PARAM)!!.toBoolean()
}
return MangasPage(emptyList(), false)
return MangasPage(mangas, hasNextPage)
}
override val metaParser: NHentaiMetadata.(JsonObject) -> Unit = { obj ->
nhId = obj["id"].asLong
override fun parseIntoMetadata(metadata: NHentaiSearchMetadata, input: Response) {
val json = GALLERY_JSON_REGEX.find(input.body()!!.string())!!.groupValues[1]
val obj = jsonParser.parse(json).asJsonObject
uploadDate = obj["upload_date"].nullLong
with(metadata) {
nhId = obj["id"].asLong
favoritesCount = obj["num_favorites"].nullLong
uploadDate = obj["upload_date"].nullLong
mediaId = obj["media_id"].nullString
favoritesCount = obj["num_favorites"].nullLong
obj["title"].nullObj?.let { it ->
japaneseTitle = it["japanese"].nullString
shortTitle = it["pretty"].nullString
englishTitle = it["english"].nullString
}
mediaId = obj["media_id"].nullString
obj["images"].nullObj?.let {
coverImageType = it["cover"]?.get("t").nullString
it["pages"].nullArray?.mapNotNull {
it?.asJsonObject?.get("t").nullString
}?.map {
PageImageType(it)
}?.let {
pageImageTypes.clear()
pageImageTypes.addAll(it)
obj["title"].nullObj?.let { title ->
japaneseTitle = title["japanese"].nullString
shortTitle = title["pretty"].nullString
englishTitle = title["english"].nullString
}
thumbnailImageType = it["thumbnail"]?.get("t").nullString
}
scanlator = obj["scanlator"].nullString
obj["tags"]?.asJsonArray?.map {
val asObj = it.asJsonObject
Pair(asObj["type"].nullString, asObj["name"].nullString)
}?.apply {
tags.clear()
}?.forEach {
if(it.first != null && it.second != null)
tags.add(Tag(it.first!!, it.second!!, false))
}
}
fun lazyLoadMetadata(url: String) =
defRealm { realm ->
val meta = NHentaiMetadata.UrlQuery(url).query(realm).findFirst()
if(meta == null) {
client.newCall(urlToDetailsRequest(url))
.asObservableSuccess()
.map {
realmTrans { realm ->
realm.copyFromRealm(realm.createUUIDObj(queryAll().clazz.java).apply {
metaParser(this,
jsonParser.parse(it.body()!!.string()).asJsonObject)
})
}
}
.first()
} else {
Observable.just(realm.copyFromRealm(meta))
obj["images"].nullObj?.let {
coverImageType = it["cover"]?.get("t").nullString
it["pages"].nullArray?.mapNotNull {
it?.asJsonObject?.get("t").nullString
}?.let {
pageImageTypes = it
}
thumbnailImageType = it["thumbnail"]?.get("t").nullString
}
scanlator = obj["scanlator"].nullString
obj["tags"]?.asJsonArray?.map {
val asObj = it.asJsonObject
Pair(asObj["type"].nullString, asObj["name"].nullString)
}?.apply {
tags.clear()
}?.forEach {
if (it.first != null && it.second != null)
tags.add(RaisedTag(it.first!!, it.second!!, TAG_TYPE_DEFAULT))
}
}
}
fun getOrLoadMetadata(mangaId: Long?, nhId: Long) = getOrLoadMetadata(mangaId) {
client.newCall(nhGet(baseUrl + NHentaiSearchMetadata.nhIdToPath(nhId)))
.asObservableSuccess()
.toSingle()
}
override fun fetchChapterList(manga: SManga)
= lazyLoadMetadata(manga.url).map {
listOf(SChapter.create().apply {
url = manga.url
name = "Chapter"
date_upload = ((it.uploadDate ?: 0) * 1000)
chapter_number = 1f
})
}!!
= Observable.just(listOf(SChapter.create().apply {
url = manga.url
name = "Chapter"
chapter_number = 1f
}))
override fun fetchPageList(chapter: SChapter)
= lazyLoadMetadata(chapter.url).map { metadata ->
= getOrLoadMetadata(chapter.mangaId, NHentaiSearchMetadata.nhUrlToId(chapter.url)).map { metadata ->
if(metadata.mediaId == null) emptyList()
else
metadata.pageImageTypes.mapIndexed { index, s ->
val imageUrl = imageUrlFromType(metadata.mediaId!!, index + 1, s.type!!)
val imageUrl = imageUrlFromType(metadata.mediaId!!, index + 1, s)
Page(index, imageUrl!!, imageUrl)
}
}!!
}.toObservable()
override fun fetchImageUrl(page: Page) = Observable.just(page.imageUrl!!)!!
@@ -207,6 +242,14 @@ class NHentai(context: Context) : HttpSource(), LewdSource<NHentaiMetadata, Json
throw NotImplementedError("Unused method called!")
}
override fun getFilterList() = FilterList(SortFilter())
class SortFilter : Filter.Sort(
"Sort",
arrayOf("Date", "Popular"),
defaultSortFilterSelection()
)
val appName by lazy {
context.getString(R.string.app_name)!!
}
@@ -226,14 +269,16 @@ class NHentai(context: Context) : HttpSource(), LewdSource<NHentaiMetadata, Json
override val name = "nhentai"
override val baseUrl = NHentaiMetadata.BASE_URL
override val baseUrl = NHentaiSearchMetadata.BASE_URL
override val supportsLatest = true
override fun queryAll() = NHentaiMetadata.EmptyQuery()
override fun queryFromUrl(url: String) = NHentaiMetadata.UrlQuery(url)
companion object {
private val GALLERY_JSON_REGEX = Regex("new N.gallery\\((.*)\\);")
private const val REVERSE_PARAM = "TEH_REVERSE"
private fun defaultSortFilterSelection() = Filter.Sort.Selection(0, false)
val jsonParser by lazy {
JsonParser()
}

View File

@@ -2,16 +2,18 @@ package eu.kanade.tachiyomi.source.online.all
import android.net.Uri
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.asObservableSuccess
import eu.kanade.tachiyomi.source.model.*
import eu.kanade.tachiyomi.source.online.LewdSource
import eu.kanade.tachiyomi.source.online.ParsedHttpSource
import eu.kanade.tachiyomi.util.ChapterRecognition
import eu.kanade.tachiyomi.util.asJsoup
import exh.metadata.EMULATED_TAG_NAMESPACE
import exh.metadata.metadata.PervEdenLang
import exh.metadata.metadata.PervEdenSearchMetadata
import exh.metadata.metadata.PervEdenSearchMetadata.Companion.TAG_TYPE_DEFAULT
import exh.metadata.metadata.base.RaisedSearchMetadata.Companion.TAG_TYPE_VIRTUAL
import exh.metadata.metadata.base.RaisedTag
import exh.metadata.models.PervEdenGalleryMetadata
import exh.metadata.models.PervEdenLang
import exh.metadata.models.PervEdenTitle
import exh.metadata.models.Tag
import exh.util.UriFilter
import exh.util.UriGroup
import exh.util.urlImportFetchSearchManga
@@ -20,11 +22,17 @@ import okhttp3.Response
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import org.jsoup.nodes.TextNode
import rx.Observable
import java.text.SimpleDateFormat
import java.util.*
// TODO Transform into delegated source
class PervEden(override val id: Long, val pvLang: PervEdenLang) : ParsedHttpSource(),
LewdSource<PervEdenGalleryMetadata, Document> {
LewdSource<PervEdenSearchMetadata, Document> {
/**
* The class of the metadata used by this source
*/
override val metaClass = PervEdenSearchMetadata::class
override val supportsLatest = true
override val name = "Perv Eden"
@@ -48,9 +56,9 @@ class PervEden(override val id: Long, val pvLang: PervEdenLang) : ParsedHttpSour
//Support direct URL importing
override fun fetchSearchManga(page: Int, query: String, filters: FilterList) =
urlImportFetchSearchManga(query, {
urlImportFetchSearchManga(query) {
super.fetchSearchManga(page, query, filters)
})
}
override fun searchMangaSelector() = "#mangaList > tbody > tr"
@@ -79,7 +87,7 @@ class PervEden(override val id: Long, val pvLang: PervEdenLang) : ParsedHttpSour
val titleElement = header.child(0)
manga.url = titleElement.attr("href")
manga.title = titleElement.text().trim()
manga.thumbnail_url = "http:" + titleElement.getElementsByClass("mangaImage").first().attr("tmpsrc")
manga.thumbnail_url = "https:" + header.parent().selectFirst(".mangaImage img").attr("tmpsrc")
return manga
}
@@ -107,67 +115,90 @@ class PervEden(override val id: Long, val pvLang: PervEdenLang) : ParsedHttpSour
throw NotImplementedError("Unused method called!")
}
override val metaParser: PervEdenGalleryMetadata.(Document) -> Unit = { document ->
url = Uri.parse(document.location()).path
/**
* Returns an observable with the updated details for a manga. Normally it's not needed to
* override this method.
*
* @param manga the manga to be updated.
*/
override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
return client.newCall(mangaDetailsRequest(manga))
.asObservableSuccess()
.flatMap {
parseToManga(manga, it.asJsoup()).andThen(Observable.just(manga.apply {
initialized = true
}))
}
}
pvId = PervEdenGalleryMetadata.pvIdFromUrl(url!!)
/**
* Parse the supplied input into the supplied metadata object
*/
override fun parseIntoMetadata(metadata: PervEdenSearchMetadata, input: Document) {
with(metadata) {
url = Uri.parse(input.location()).path
lang = this@PervEden.lang
pvId = PervEdenGalleryMetadata.pvIdFromUrl(url!!)
title = document.getElementsByClass("manga-title").first()?.text()
lang = this@PervEden.lang
thumbnailUrl = "http:" + document.getElementsByClass("mangaImage2").first()?.child(0)?.attr("src")
title = input.getElementsByClass("manga-title").first()?.text()
val rightBoxElement = document.select(".rightBox:not(.info)").first()
thumbnailUrl = "http:" + input.getElementsByClass("mangaImage2").first()?.child(0)?.attr("src")
altTitles.clear()
tags.clear()
var inStatus: String? = null
rightBoxElement.childNodes().forEach {
if(it is Element && it.tagName().toLowerCase() == "h4") {
inStatus = it.text().trim()
} else {
when(inStatus) {
"Alternative name(s)" -> {
if(it is TextNode) {
val text = it.text().trim()
if(!text.isBlank())
altTitles.add(PervEdenTitle(this, text))
val rightBoxElement = input.select(".rightBox:not(.info)").first()
val newAltTitles = mutableListOf<String>()
tags.clear()
var inStatus: String? = null
rightBoxElement.childNodes().forEach {
if(it is Element && it.tagName().toLowerCase() == "h4") {
inStatus = it.text().trim()
} else {
when(inStatus) {
"Alternative name(s)" -> {
if(it is TextNode) {
val text = it.text().trim()
if(!text.isBlank())
newAltTitles += text
}
}
}
"Artist" -> {
if(it is Element && it.tagName() == "a") {
artist = it.text()
tags.add(Tag("artist", it.text().toLowerCase(), false))
"Artist" -> {
if(it is Element && it.tagName() == "a") {
artist = it.text()
tags += RaisedTag("artist", it.text().toLowerCase(), TAG_TYPE_VIRTUAL)
}
}
}
"Genres" -> {
if(it is Element && it.tagName() == "a")
tags.add(Tag(EMULATED_TAG_NAMESPACE, it.text().toLowerCase(), false))
}
"Type" -> {
if(it is TextNode) {
val text = it.text().trim()
if(!text.isBlank())
type = text
"Genres" -> {
if(it is Element && it.tagName() == "a")
tags += RaisedTag(null, it.text().toLowerCase(), TAG_TYPE_DEFAULT)
}
}
"Status" -> {
if(it is TextNode) {
val text = it.text().trim()
if(!text.isBlank())
status = text
"Type" -> {
if(it is TextNode) {
val text = it.text().trim()
if(!text.isBlank())
type = text
}
}
"Status" -> {
if(it is TextNode) {
val text = it.text().trim()
if(!text.isBlank())
status = text
}
}
}
}
}
}
rating = document.getElementById("rating-score")?.attr("value")?.toFloat()
altTitles = newAltTitles
rating = input.getElementById("rating-score")?.attr("value")?.toFloat()
}
}
override fun mangaDetailsParse(document: Document): SManga
= parseToManga(queryFromUrl(document.location()), document)
= throw UnsupportedOperationException()
override fun latestUpdatesRequest(page: Int): Request {
val num = when (lang) {
@@ -206,9 +237,6 @@ class PervEden(override val id: Long, val pvLang: PervEdenLang) : ParsedHttpSour
override fun imageUrlParse(document: Document)
= "http:" + document.getElementById("mainImg").attr("src")!!
override fun queryAll() = PervEdenGalleryMetadata.EmptyQuery()
override fun queryFromUrl(url: String) = PervEdenGalleryMetadata.UrlQuery(url, PervEdenLang.source(id))
override fun getFilterList() = FilterList (
AuthorFilter(),
ArtistFilter(),

View File

@@ -1,204 +1,91 @@
package eu.kanade.tachiyomi.source.online.english
import android.net.Uri
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.asObservableSuccess
import eu.kanade.tachiyomi.source.model.*
import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.source.online.LewdSource
import eu.kanade.tachiyomi.source.online.ParsedHttpSource
import eu.kanade.tachiyomi.util.asJsoup
import exh.HENTAI_CAFE_SOURCE_ID
import exh.metadata.EMULATED_TAG_NAMESPACE
import exh.metadata.models.HentaiCafeMetadata
import exh.metadata.models.HentaiCafeMetadata.Companion.BASE_URL
import exh.metadata.models.Tag
import exh.metadata.metadata.HentaiCafeSearchMetadata
import exh.metadata.metadata.HentaiCafeSearchMetadata.Companion.TAG_TYPE_DEFAULT
import exh.metadata.metadata.base.RaisedSearchMetadata.Companion.TAG_TYPE_VIRTUAL
import exh.metadata.metadata.base.RaisedTag
import exh.source.DelegatedHttpSource
import exh.util.urlImportFetchSearchManga
import okhttp3.Request
import okhttp3.HttpUrl
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import rx.Observable
class HentaiCafe : ParsedHttpSource(), LewdSource<HentaiCafeMetadata, Document> {
override val id = HENTAI_CAFE_SOURCE_ID
class HentaiCafe(delegate: HttpSource) : DelegatedHttpSource(delegate),
LewdSource<HentaiCafeSearchMetadata, Document> {
/**
* An ISO 639-1 compliant language code (two letters in lower case).
*/
override val lang = "en"
/**
* The class of the metadata used by this source
*/
override val metaClass = HentaiCafeSearchMetadata::class
override val supportsLatest = true
override fun queryAll() = HentaiCafeMetadata.EmptyQuery()
override fun queryFromUrl(url: String) = HentaiCafeMetadata.UrlQuery(url)
override val name = "Hentai Cafe"
override val baseUrl = "https://hentai.cafe"
// Defer popular manga -> latest updates
override fun popularMangaSelector() = throw UnsupportedOperationException("Unused method called!")
override fun popularMangaFromElement(element: Element) = throw UnsupportedOperationException("Unused method called!")
override fun popularMangaNextPageSelector() = throw UnsupportedOperationException("Unused method called!")
override fun popularMangaRequest(page: Int) = throw UnsupportedOperationException("Unused method called!")
override fun fetchPopularManga(page: Int) = fetchLatestUpdates(page)
//Support direct URL importing
override fun fetchSearchManga(page: Int, query: String, filters: FilterList) =
urlImportFetchSearchManga(query, {
urlImportFetchSearchManga(query) {
super.fetchSearchManga(page, query, filters)
})
override fun searchMangaSelector() = "article.post:not(#post-0)"
override fun searchMangaFromElement(element: Element): SManga {
val thumb = element.select(".entry-thumb > img")
val title = element.select(".entry-title > a")
return SManga.create().apply {
setUrlWithoutDomain(title.attr("href"))
this.title = title.text()
thumbnail_url = thumb.attr("src")
}
}
override fun searchMangaNextPageSelector() = ".x-pagination > ul > li:last-child > a.prev-next"
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
val url = if(query.isNotBlank()) {
//Filter by query
"$baseUrl/page/$page/?s=${Uri.encode(query)}"
} else if(filters.filterIsInstance<ShowBooksOnlyFilter>().any { it.state }) {
//Filter by book
"$baseUrl/category/book/page/$page/"
} else {
//Filter by tag
val tagFilter = filters.filterIsInstance<TagFilter>().first()
if(tagFilter.state == 0) throw IllegalArgumentException("No filters active, no query active! What to filter?")
val tag = tagFilter.values[tagFilter.state]
"$baseUrl/tag/${tag.id}/page/$page/"
}
return GET(url)
}
override fun latestUpdatesSelector() = searchMangaSelector()
override fun latestUpdatesFromElement(element: Element) = searchMangaFromElement(element)
override fun latestUpdatesNextPageSelector() = searchMangaNextPageSelector()
override fun latestUpdatesRequest(page: Int) = GET("$BASE_URL/page/$page/")
override fun mangaDetailsParse(document: Document): SManga {
return parseToManga(queryFromUrl(document.location()), document)
}
override fun chapterListSelector() = throw UnsupportedOperationException("Unused method called!")
override fun chapterFromElement(element: Element) = throw UnsupportedOperationException("Unused method called!")
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
return lazyLoadMeta(queryFromUrl(manga.url),
client.newCall(mangaDetailsRequest(manga)).asObservableSuccess().map { it.asJsoup() }
).map {
listOf(SChapter.create().apply {
url = "/manga/read/${it.readerId}/en/0/1/"
name = "Chapter"
chapter_number = 1f
})
}
}
override fun pageListParse(document: Document): List<Page> {
val pageItems = document.select(".dropdown > li > a")
return pageItems.mapIndexed { index, element ->
Page(index, element.attr("href"))
}
}
override fun imageUrlParse(document: Document)
= document.select("#page img").attr("src")
override val metaParser: HentaiCafeMetadata.(Document) -> Unit = {
val content = it.getElementsByClass("content")
val eTitle = content.select("h3")
url = Uri.decode(it.location())
title = eTitle.text()
thumbnailUrl = content.select("img").attr("src")
tags.clear()
val eDetails = content.select("p > a[rel=tag]")
eDetails.forEach {
val href = it.attr("href")
val parsed = Uri.parse(href)
val firstPath = parsed.pathSegments.first()
when(firstPath) {
"tag" -> tags.add(Tag(EMULATED_TAG_NAMESPACE, it.text(), false))
"artist" -> {
artist = it.text()
tags.add(Tag("artist", it.text(), false))
}
}
override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
return client.newCall(mangaDetailsRequest(manga))
.asObservableSuccess()
.flatMap {
parseToManga(manga, it.asJsoup()).andThen(Observable.just(manga.apply {
initialized = true
}))
}
}
/**
* Parse the supplied input into the supplied metadata object
*/
override fun parseIntoMetadata(metadata: HentaiCafeSearchMetadata, input: Document) {
with(metadata) {
url = input.location()
title = input.select(".entry-title").text()
val contentElement = input.select(".entry-content").first()
thumbnailUrl = contentElement.child(0).child(0).attr("src")
fun filterableTagsOfType(type: String) = contentElement.select("a")
.filter { "$baseUrl/$type/" in it.attr("href") }
.map { it.text() }
tags.clear()
tags += filterableTagsOfType("tag").map {
RaisedTag(null, it, TAG_TYPE_DEFAULT)
}
val artists = filterableTagsOfType("artist")
artist = artists.joinToString()
tags += artists.map {
RaisedTag("artist", it, TAG_TYPE_VIRTUAL)
}
readerId = HttpUrl.parse(input.select("[title=Read]").attr("href"))!!.pathSegments()[2]
}
readerId = Uri.parse(content.select("a[title=Read]").attr("href")).pathSegments[2]
}
override fun getFilterList() = FilterList(
TagFilter(),
ShowBooksOnlyFilter()
)
class ShowBooksOnlyFilter : Filter.CheckBox("Show books only")
class TagFilter : Filter.Select<HCTag>("Filter by tag", listOf(
"???" to "None",
"ahegao" to "Ahegao",
"anal" to "Anal",
"big-ass" to "Big ass",
"big-breast" to "Big Breast",
"bondage" to "Bondage",
"cheating" to "Cheating",
"chubby" to "Chubby",
"condom" to "Condom",
"cosplay" to "Cosplay",
"cunnilingus" to "Cunnilingus",
"dark-skin" to "Dark skin",
"defloration" to "Defloration",
"exhibitionism" to "Exhibitionism",
"fellatio" to "Fellatio",
"femdom" to "Femdom",
"flat-chest" to "Flat chest",
"full-color" to "Full color",
"glasses" to "Glasses",
"group" to "Group",
"hairy" to "Hairy",
"handjob" to "Handjob",
"harem" to "Harem",
"housewife" to "Housewife",
"incest" to "Incest",
"large-breast" to "Large Breast",
"lingerie" to "Lingerie",
"loli" to "Loli",
"masturbation" to "Masturbation",
"nakadashi" to "Nakadashi",
"netorare" to "Netorare",
"office-lady" to "Office Lady",
"osananajimi" to "Osananajimi",
"paizuri" to "Paizuri",
"pettanko" to "Pettanko",
"rape" to "Rape",
"schoolgirl" to "Schoolgirl",
"sex-toys" to "Sex Toys",
"shota" to "Shota",
"stocking" to "Stocking",
"swimsuit" to "Swimsuit",
"teacher" to "Teacher",
"tsundere" to "Tsundere",
"uncensored" to "uncensored",
"x-ray" to "X-ray"
).map { HCTag(it.first, it.second) }.toTypedArray()
)
class HCTag(val id: String, val displayName: String) {
override fun toString() = displayName
}
override fun fetchChapterList(manga: SManga) = getOrLoadMetadata(manga.id) {
client.newCall(mangaDetailsRequest(manga))
.asObservableSuccess()
.map { it.asJsoup() }
.toSingle()
}.map {
listOf(
SChapter.create().apply {
setUrlWithoutDomain("/manga/read/${it.readerId}/en/0/1/")
name = "Chapter"
chapter_number = 0.0f
}
)
}.toObservable()
}

View File

@@ -18,10 +18,11 @@ import eu.kanade.tachiyomi.util.toast
import exh.TSUMINO_SOURCE_ID
import exh.ui.captcha.CaptchaCompletionVerifier
import exh.ui.captcha.SolveCaptchaActivity
import exh.metadata.EMULATED_TAG_NAMESPACE
import exh.metadata.models.Tag
import exh.metadata.models.TsuminoMetadata
import exh.metadata.models.TsuminoMetadata.Companion.BASE_URL
import exh.metadata.metadata.TsuminoSearchMetadata
import exh.metadata.metadata.TsuminoSearchMetadata.Companion.BASE_URL
import exh.metadata.metadata.TsuminoSearchMetadata.Companion.TAG_TYPE_DEFAULT
import exh.metadata.metadata.base.RaisedSearchMetadata.Companion.TAG_TYPE_VIRTUAL
import exh.metadata.metadata.base.RaisedTag
import exh.util.urlImportFetchSearchManga
import okhttp3.*
import org.jsoup.nodes.Document
@@ -32,7 +33,9 @@ import uy.kohesive.injekt.injectLazy
import java.text.SimpleDateFormat
import java.util.*
class Tsumino(private val context: Context): ParsedHttpSource(), LewdSource<TsuminoMetadata, Document>, CaptchaCompletionVerifier {
class Tsumino(private val context: Context): ParsedHttpSource(), LewdSource<TsuminoSearchMetadata, Document>, CaptchaCompletionVerifier {
override val metaClass = TsuminoSearchMetadata::class
private val preferences: PreferencesHelper by injectLazy()
override val id = TSUMINO_SOURCE_ID
@@ -41,77 +44,76 @@ class Tsumino(private val context: Context): ParsedHttpSource(), LewdSource<Tsum
override val supportsLatest = true
override val name = "Tsumino"
override fun queryAll() = TsuminoMetadata.EmptyQuery()
override fun queryFromUrl(url: String) = TsuminoMetadata.UrlQuery(url)
override val baseUrl = BASE_URL
override val metaParser: TsuminoMetadata.(Document) -> Unit = {
url = it.location()
tags.clear()
it.getElementById("Title")?.text()?.let {
title = it.trim()
}
it.getElementById("Artist")?.children()?.first()?.text()?.trim()?.let {
tags.add(Tag("artist", it, false))
artist = it
}
it.getElementById("Uploader")?.children()?.first()?.text()?.trim()?.let {
tags.add(Tag("uploader", it, false))
uploader = it
}
it.getElementById("Uploaded")?.text()?.let {
uploadDate = TM_DATE_FORMAT.parse(it.trim()).time
}
it.getElementById("Pages")?.text()?.let {
length = it.trim().toIntOrNull()
}
it.getElementById("Rating")?.text()?.let {
ratingString = it.trim()
}
it.getElementById("Category")?.children()?.first()?.text()?.let {
category = it.trim()
tags.add(Tag("genre", it, false))
}
it.getElementById("Collection")?.children()?.first()?.text()?.let {
collection = it.trim()
}
it.getElementById("Group")?.children()?.first()?.text()?.let {
group = it.trim()
tags.add(Tag("group", it, false))
}
parody.clear()
it.getElementById("Parody")?.children()?.forEach {
val entry = it.text().trim()
parody.add(entry)
tags.add(Tag("parody", entry, false))
}
character.clear()
it.getElementById("Character")?.children()?.forEach {
val entry = it.text().trim()
character.add(entry)
tags.add(Tag("character", entry, false))
}
it.getElementById("Tag")?.children()?.let {
tags.addAll(it.map {
Tag(EMULATED_TAG_NAMESPACE, it.text().trim(), false)
})
override fun parseIntoMetadata(metadata: TsuminoSearchMetadata, input: Document) {
with(metadata) {
tmId = TsuminoSearchMetadata.tmIdFromUrl(input.location()).toInt()
tags.clear()
input.getElementById("Title")?.text()?.let {
title = it.trim()
}
input.getElementById("Artist")?.children()?.first()?.text()?.trim()?.let {
tags.add(RaisedTag("artist", it, TAG_TYPE_VIRTUAL))
artist = it
}
input.getElementById("Uploader")?.children()?.first()?.text()?.trim()?.let {
uploader = it
}
input.getElementById("Uploaded")?.text()?.let {
uploadDate = TM_DATE_FORMAT.parse(it.trim()).time
}
input.getElementById("Pages")?.text()?.let {
length = it.trim().toIntOrNull()
}
input.getElementById("Rating")?.text()?.let {
ratingString = it.trim()
}
input.getElementById("Category")?.children()?.first()?.text()?.let {
category = it.trim()
tags.add(RaisedTag("genre", it, TAG_TYPE_VIRTUAL))
}
input.getElementById("Collection")?.children()?.first()?.text()?.let {
collection = it.trim()
}
input.getElementById("Group")?.children()?.first()?.text()?.let {
group = it.trim()
tags.add(RaisedTag("group", it, TAG_TYPE_VIRTUAL))
}
val newParody = mutableListOf<String>()
input.getElementById("Parody")?.children()?.forEach {
val entry = it.text().trim()
newParody.add(entry)
tags.add(RaisedTag("parody", entry, TAG_TYPE_VIRTUAL))
}
parody = newParody
val newCharacter = mutableListOf<String>()
input.getElementById("Character")?.children()?.forEach {
val entry = it.text().trim()
newCharacter.add(entry)
tags.add(RaisedTag("character", entry, TAG_TYPE_VIRTUAL))
}
character = newCharacter
input.getElementById("Tag")?.children()?.let {
tags.addAll(it.map {
RaisedTag(null, it.text().trim(), TAG_TYPE_DEFAULT)
})
}
}
}
fun genericMangaParse(response: Response): MangasPage {
val json = jsonParser.parse(response.body()!!.string()!!).asJsonObject
val hasNextPage = json["PageNumber"].int < json["PageCount"].int
@@ -121,8 +123,8 @@ class Tsumino(private val context: Context): ParsedHttpSource(), LewdSource<Tsum
SManga.create().apply {
val id = obj["Id"].long
setUrlWithoutDomain(TsuminoMetadata.mangaUrlFromId(id.toString()))
thumbnail_url = TsuminoMetadata.thumbUrlFromId(id.toString())
url = TsuminoSearchMetadata.mangaUrlFromId(id.toString())
thumbnail_url = BASE_URL + TsuminoSearchMetadata.thumbUrlFromId(id.toString())
title = obj["Title"].string
}
@@ -199,9 +201,10 @@ class Tsumino(private val context: Context): ParsedHttpSource(), LewdSource<Tsum
//Support direct URL importing
override fun fetchSearchManga(page: Int, query: String, filters: FilterList) =
urlImportFetchSearchManga(query, {
urlImportFetchSearchManga(query) {
super.fetchSearchManga(page, query, filters)
})
}
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
// Append filters again, to provide fallback in case a filter is not provided
// Since we only work with the first filter when building the result, if the filter is provided,
@@ -229,15 +232,28 @@ class Tsumino(private val context: Context): ParsedHttpSource(), LewdSource<Tsum
override fun searchMangaFromElement(element: Element) = throw UnsupportedOperationException("Unused method called!")
override fun searchMangaNextPageSelector() = throw UnsupportedOperationException("Unused method called!")
override fun searchMangaParse(response: Response) = genericMangaParse(response)
override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
return client.newCall(mangaDetailsRequest(manga))
.asObservableSuccess()
.flatMap {
parseToManga(manga, it.asJsoup()).andThen(Observable.just(manga.apply {
initialized = true
}))
}
}
override fun mangaDetailsParse(document: Document)
= parseToManga(queryFromUrl(document.location()), document)
= throw UnsupportedOperationException("Unused method called!")
override fun chapterListSelector() = throw UnsupportedOperationException("Unused method called!")
override fun chapterFromElement(element: Element) = throw UnsupportedOperationException("Unused method called!")
override fun fetchChapterList(manga: SManga) = lazyLoadMeta(queryFromUrl(manga.url),
client.newCall(mangaDetailsRequest(manga)).asObservableSuccess().map { it.asJsoup() }
).map {
override fun fetchChapterList(manga: SManga) = getOrLoadMetadata(manga.id) {
client.newCall(mangaDetailsRequest(manga))
.asObservableSuccess()
.map { it.asJsoup() }
.toSingle()
}.map {
trickTsumino(it.tmId)
listOf(
@@ -250,9 +266,9 @@ class Tsumino(private val context: Context): ParsedHttpSource(), LewdSource<Tsum
chapter_number = 1f
}
)
}
}.toObservable()
fun trickTsumino(id: String?) {
fun trickTsumino(id: Int?) {
if(id == null) return
//Make one call to /Read/View (ASP session cookie)
@@ -330,7 +346,8 @@ class Tsumino(private val context: Context): ParsedHttpSource(), LewdSource<Tsum
this,
cookiesMap,
CAPTCHA_SCRIPT,
"$BASE_URL/Read/Auth/$id")
"$BASE_URL/Read/Auth/$id",
".book-read-button")
} catch(t: Throwable) {
Crashlytics.logException(t)
context.toast("Could not launch captcha-solving activity: ${t.message}")
@@ -338,7 +355,7 @@ class Tsumino(private val context: Context): ParsedHttpSource(), LewdSource<Tsum
}
}
override fun verify(url: String): Boolean {
override fun verifyNoCaptcha(url: String): Boolean {
return Uri.parse(url).pathSegments.getOrNull(1) == "View"
}

View File

@@ -141,6 +141,99 @@ open class BrowseCatalogueController(bundle: Bundle) :
drawer.setDrawerLockMode(DrawerLayout.LOCK_MODE_UNLOCKED, Gravity.END)
// EXH -->
presenter.loadSearches()?.let {
navView.setSavedSearches(it)
} ?: run {
MaterialDialog.Builder(navView.context)
.title("Failed to load saved searches!")
.content("An error occurred while loading your saved searches.")
.cancelable(true)
.canceledOnTouchOutside(true)
.show()
}
navView.onSaveClicked = {
MaterialDialog.Builder(navView.context)
.title("Save current search query?")
.input("My search name", "") { _, searchName ->
val oldSavedSearches = presenter.loadSearches() ?: emptyList()
if(searchName.isNotBlank()
&& oldSavedSearches.size < CatalogueNavigationView.MAX_SAVED_SEARCHES) {
val newSearches = oldSavedSearches + EXHSavedSearch(
searchName.toString().trim(),
presenter.query,
presenter.sourceFilters.toList()
)
presenter.saveSearches(newSearches)
navView.setSavedSearches(newSearches)
}
}
.positiveText("Save")
.negativeText("Cancel")
.cancelable(true)
.canceledOnTouchOutside(true)
.show()
}
navView.onSavedSearchClicked = cb@{ indexToSearch ->
val savedSearches = presenter.loadSearches()
if(savedSearches == null) {
MaterialDialog.Builder(navView.context)
.title("Failed to load saved searches!")
.content("An error occurred while loading your saved searches.")
.cancelable(true)
.canceledOnTouchOutside(true)
.show()
return@cb
}
val search = savedSearches[indexToSearch]
presenter.sourceFilters = FilterList(search.filterList)
navView.setFilters(presenter.filterItems)
val allDefault = presenter.sourceFilters == presenter.source.getFilterList()
showProgressBar()
adapter?.clear()
drawer.closeDrawer(Gravity.END)
presenter.restartPager(search.query, if (allDefault) FilterList() else presenter.sourceFilters)
activity?.invalidateOptionsMenu()
}
navView.onSavedSearchDeleteClicked = cb@{ indexToDelete ->
val savedSearches = presenter.loadSearches()
if(savedSearches == null) {
MaterialDialog.Builder(navView.context)
.title("Failed to delete saved search!")
.content("An error occurred while deleting the search.")
.cancelable(true)
.canceledOnTouchOutside(true)
.show()
return@cb
}
val search = savedSearches[indexToDelete]
MaterialDialog.Builder(navView.context)
.title("Delete saved search query?")
.content("Are you sure you wish to delete your saved search query: '${search.name}'?")
.positiveText("Cancel")
.negativeText("Confirm")
.onNegative { _, _ ->
val newSearches = savedSearches.filterIndexed { index, _ ->
index != indexToDelete
}
presenter.saveSearches(newSearches)
navView.setSavedSearches(newSearches)
}
.cancelable(true)
.canceledOnTouchOutside(true)
.show()
}
// EXH <--
navView.onSearchClicked = {
val allDefault = presenter.sourceFilters == presenter.source.getFilterList()
showProgressBar()

View File

@@ -1,6 +1,9 @@
package eu.kanade.tachiyomi.ui.catalogue.browse
import android.os.Bundle
import com.fasterxml.jackson.core.JsonProcessingException
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import eu.davidea.flexibleadapter.items.IFlexible
import eu.davidea.flexibleadapter.items.ISectionable
import eu.kanade.tachiyomi.data.cache.CoverCache
@@ -9,6 +12,7 @@ import eu.kanade.tachiyomi.data.database.models.Category
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.database.models.MangaCategory
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.model.Filter
@@ -374,4 +378,23 @@ open class BrowseCataloguePresenter(
}
}
// EXH -->
private val mapper = jacksonObjectMapper().enableDefaultTyping()
fun saveSearches(searches: List<EXHSavedSearch>) {
val serialized = mapper.writeValueAsString(searches.toTypedArray())
prefs.eh_savedSearches().set(serialized)
}
fun loadSearches(): List<EXHSavedSearch>? {
val loaded = prefs.eh_savedSearches().getOrDefault()
return try {
if (!loaded.isEmpty()) mapper.readValue<Array<EXHSavedSearch>>(loaded).toList()
else emptyList()
} catch(t: JsonProcessingException) {
// Load failed
Timber.e(t, "Failed to load saved searches!")
null
}
}
// EXH <--
}

View File

@@ -2,14 +2,20 @@ package eu.kanade.tachiyomi.ui.catalogue.browse
import android.content.Context
import android.util.AttributeSet
import android.view.Gravity
import android.view.ViewGroup
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
import android.widget.LinearLayout
import android.widget.TextView
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.IFlexible
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.util.dpToPx
import eu.kanade.tachiyomi.util.inflate
import eu.kanade.tachiyomi.widget.SimpleNavigationView
import kotlinx.android.synthetic.main.catalogue_drawer_content.view.*
import android.util.TypedValue
import android.view.View
class CatalogueNavigationView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null)
: SimpleNavigationView(context, attrs) {
@@ -22,13 +28,26 @@ class CatalogueNavigationView @JvmOverloads constructor(context: Context, attrs:
var onResetClicked = {}
// EXH -->
var onSaveClicked = {}
// EXH <--
// EXH -->
var onSavedSearchClicked: (Int) -> Unit = {}
// EXH <--
// EXH -->
var onSavedSearchDeleteClicked: (Int) -> Unit = {}
// EXH <--
init {
recycler.adapter = adapter
recycler.setHasFixedSize(true)
val view = inflate(R.layout.catalogue_drawer_content)
val view = inflate(eu.kanade.tachiyomi.R.layout.catalogue_drawer_content)
((view as ViewGroup).getChildAt(1) as ViewGroup).addView(recycler)
addView(view)
title.text = context?.getString(R.string.source_search_options)
title.text = context?.getString(eu.kanade.tachiyomi.R.string.source_search_options)
save_search_btn.setOnClickListener { onSaveClicked() }
search_btn.setOnClickListener { onSearchClicked() }
reset_btn.setOnClickListener { onResetClicked() }
}
@@ -37,4 +56,33 @@ class CatalogueNavigationView @JvmOverloads constructor(context: Context, attrs:
adapter.updateDataSet(items)
}
// EXH -->
fun setSavedSearches(searches: List<EXHSavedSearch>) {
saved_searches.removeAllViews()
val outValue = TypedValue()
context.theme.resolveAttribute(android.R.attr.selectableItemBackground, outValue, true)
save_search_btn.visibility = if(searches.size < 5) View.VISIBLE else View.GONE
searches.forEachIndexed { index, search ->
val restoreBtn = TextView(context)
restoreBtn.text = search.name
val params = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)
params.gravity = Gravity.CENTER
restoreBtn.layoutParams = params
restoreBtn.gravity = Gravity.CENTER
restoreBtn.setBackgroundResource(outValue.resourceId)
restoreBtn.setPadding(8.dpToPx, 8.dpToPx, 8.dpToPx, 8.dpToPx)
restoreBtn.setOnClickListener { onSavedSearchClicked(index) }
restoreBtn.setOnLongClickListener { onSavedSearchDeleteClicked(index); true }
saved_searches.addView(restoreBtn)
}
}
companion object {
const val MAX_SAVED_SEARCHES = 5
}
// EXH <--
}

View File

@@ -0,0 +1,7 @@
package eu.kanade.tachiyomi.ui.catalogue.browse
import eu.kanade.tachiyomi.source.model.Filter
data class EXHSavedSearch(val name: String,
val query: String,
val filterList: List<Filter<*>>)

View File

@@ -1,17 +1,14 @@
package eu.kanade.tachiyomi.ui.library
import com.pushtorefresh.storio.sqlite.queries.RawQuery
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Manga
import exh.*
import exh.metadata.metadataClass
import exh.metadata.models.SearchableGalleryMetadata
import exh.metadata.syncMangaIds
import exh.isLewdSource
import exh.metadata.sql.tables.SearchMetadataTable
import exh.search.SearchEngine
import exh.util.defRealm
import io.realm.RealmResults
import timber.log.Timber
import java.util.concurrent.ConcurrentHashMap
import kotlin.concurrent.thread
import uy.kohesive.injekt.injectLazy
/**
* Adapter storing a list of manga in a certain category.
@@ -21,6 +18,7 @@ import kotlin.concurrent.thread
class LibraryCategoryAdapter(val view: LibraryCategoryView) :
FlexibleAdapter<LibraryItem>(null, view, true) {
// --> EH
private val db: DatabaseHelper by injectLazy()
private val searchEngine = SearchEngine()
// <-- EH
@@ -38,15 +36,6 @@ class LibraryCategoryAdapter(val view: LibraryCategoryView) :
// A copy of manga always unfiltered.
mangas = list.toList()
// Sync manga IDs in background (EH)
thread {
//Wait 1s to reduce UI stutter during animations
Thread.sleep(2000)
defRealm {
it.syncMangaIds(mangas)
}
}
performFilter()
}
@@ -61,104 +50,43 @@ class LibraryCategoryAdapter(val view: LibraryCategoryView) :
fun performFilter() {
if(searchText.isNotBlank()) {
if(cacheText != searchText) {
globalSearchCache.clear()
cacheText = searchText
}
// EXH -->
try {
val thisCache = globalSearchCache.getOrPut(view.category.name) {
SearchCache(mangas.size)
val startTime = System.currentTimeMillis()
val parsedQuery = searchEngine.parseQuery(searchText)
val sqlQuery = searchEngine.queryToSql(parsedQuery)
val queryResult = db.lowLevel().rawQuery(RawQuery.builder()
.query(sqlQuery.first)
.args(*sqlQuery.second.toTypedArray())
.build())
val convertedResult = ArrayList<Long>(queryResult.count)
val mangaIdCol = queryResult.getColumnIndex(SearchMetadataTable.COL_MANGA_ID)
queryResult.moveToFirst()
while(queryResult.count > 0 && !queryResult.isAfterLast) {
convertedResult += queryResult.getLong(mangaIdCol)
queryResult.moveToNext()
}
if(thisCache.ready) {
//Skip everything if cache matches our query exactly
updateDataSet(mangas.filter {
thisCache.cache[it.manga.id] ?: false
})
} else {
thisCache.cache.clear()
val parsedQuery = searchEngine.parseQuery(searchText)
var totalFilteredSize = 0
val metadata = view.controller.meta!!.map {
val meta: RealmResults<out SearchableGalleryMetadata> = if (it.value.isNotEmpty())
searchEngine.filterResults(it.value.where(),
parsedQuery,
it.value.first()!!.titleFields)
.sort(SearchableGalleryMetadata::mangaId.name)
.findAll().apply {
totalFilteredSize += size
}
else
it.value
Pair(it.key, meta)
}.toMap()
val out = ArrayList<LibraryItem>(mangas.size)
var lewdMatches = 0
for(manga in mangas) {
// --> EH
try {
if (isLewdSource(manga.manga.source)) {
//Stop matching lewd manga if we have matched them all already!
if (lewdMatches >= totalFilteredSize)
continue
val metaClass = manga.manga.metadataClass
val unfilteredMeta = view.controller.meta!![metaClass]
val filteredMeta = metadata[metaClass]
val hasMeta = manga.hasMetadata ?: (unfilteredMeta
?.where()
?.equalTo(SearchableGalleryMetadata::mangaId.name, manga.manga.id)
?.count() ?: 0 > 0)
if (hasMeta) {
if (filteredMeta!!.where()
.equalTo(SearchableGalleryMetadata::mangaId.name, manga.manga.id)
.count() > 0) {
//Metadata match!
lewdMatches++
thisCache.cache[manga.manga.id!!] = true
out += manga
continue
}
}
}
} catch (e: Exception) {
Timber.w(e, "Could not filter manga! %s", manga.manga)
}
//Fallback to regular filter
val filterRes = manga.filter(searchText)
thisCache.cache[manga.manga.id!!] = filterRes
if(filterRes) out += manga
// <-- EH
val out = mangas.filter {
if(isLewdSource(it.manga.source)) {
convertedResult.binarySearch(it.manga.id) >= 0
} else {
it.filter(searchText)
}
thisCache.ready = true
updateDataSet(out)
}
Timber.d("===> Took %s milliseconds to filter manga!", System.currentTimeMillis() - startTime)
updateDataSet(out)
} catch(e: Exception) {
Timber.w(e, "Could not filter mangas!")
updateDataSet(mangas)
}
// EXH <--
} else {
globalSearchCache.clear()
updateDataSet(mangas)
}
}
class SearchCache(size: Int) {
var ready = false
var cache = HashMap<Long, Boolean>(size)
}
companion object {
var cacheText: String? = null
val globalSearchCache = ConcurrentHashMap<String, SearchCache>()
}
}

View File

@@ -114,7 +114,7 @@ class LibraryCategoryView @JvmOverloads constructor(context: Context, attrs: Att
subscriptions += controller.searchRelay
.doOnNext { adapter.searchText = it }
.skip(1)
.debounce(350, TimeUnit.MILLISECONDS)
.debounce(500, TimeUnit.MILLISECONDS)
.observeOn(AndroidSchedulers.mainThread())
.subscribe { adapter.performFilter() }

View File

@@ -38,10 +38,6 @@ import eu.kanade.tachiyomi.util.inflate
import eu.kanade.tachiyomi.util.toast
import exh.favorites.FavoritesIntroDialog
import exh.favorites.FavoritesSyncStatus
import exh.metadata.loadAllMetadata
import exh.metadata.models.SearchableGalleryMetadata
import io.realm.Realm
import io.realm.RealmResults
import kotlinx.android.synthetic.main.library_controller.*
import kotlinx.android.synthetic.main.main_activity.*
import rx.Subscription
@@ -51,7 +47,6 @@ import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.io.IOException
import java.util.concurrent.TimeUnit
import kotlin.reflect.KClass
class LibraryController(
@@ -130,10 +125,6 @@ class LibraryController(
private var searchViewSubscription: Subscription? = null
// --> EH
//Cached realm
var realm: Realm? = null
//Cached metadata
var meta: Map<KClass<out SearchableGalleryMetadata>, RealmResults<out SearchableGalleryMetadata>>? = null
//Sync dialog
private var favSyncDialog: MaterialDialog? = null
//Old sync status
@@ -159,16 +150,6 @@ class LibraryController(
return inflater.inflate(R.layout.library_controller, container, false)
}
// --> EH
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup, savedViewState: Bundle?): View {
//Load realm
realm = Realm.getDefaultInstance()?.apply {
meta = loadAllMetadata()
}
return super.onCreateView(inflater, container, savedViewState)
}
// <-- EH
override fun onViewCreated(view: View) {
super.onViewCreated(view)
@@ -205,12 +186,6 @@ class LibraryController(
tabsVisibilitySubscription?.unsubscribe()
tabsVisibilitySubscription = null
super.onDestroyView(view)
// --> EH
//Clean up realm
realm?.close()
meta = null
// <-- EH
}
override fun createSecondaryDrawer(drawer: DrawerLayout): ViewGroup {

View File

@@ -15,7 +15,6 @@ import android.support.v7.graphics.drawable.DrawerArrowDrawable
import android.support.v7.widget.Toolbar
import android.view.ViewGroup
import com.bluelinelabs.conductor.*
import eu.kanade.tachiyomi.Migrations
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.getOrDefault
@@ -29,27 +28,20 @@ import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.ui.recent_updates.RecentChaptersController
import eu.kanade.tachiyomi.ui.recently_read.RecentlyReadController
import eu.kanade.tachiyomi.ui.setting.SettingsMainController
import exh.metadata.loadAllMetadata
import exh.uconfig.WarnConfigureDialogController
import exh.ui.batchadd.BatchAddController
import exh.ui.lock.LockChangeHandler
import exh.ui.lock.LockController
import exh.ui.lock.lockEnabled
import exh.ui.lock.notifyLockSecurity
import exh.ui.migration.MetadataFetchDialog
import exh.util.defRealm
import kotlinx.android.synthetic.main.main_activity.*
import uy.kohesive.injekt.injectLazy
import android.text.TextUtils
import android.view.View
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.online.all.Hitomi
import eu.kanade.tachiyomi.util.vibrate
import exh.HITOMI_SOURCE_ID
import rx.schedulers.Schedulers
import exh.EXHMigrations
import exh.ui.migration.MetadataFetchDialog
import timber.log.Timber
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class MainActivity : BaseActivity() {
@@ -176,35 +168,32 @@ class MainActivity : BaseActivity() {
notifyLockSecurity(this)
}
}
// Early hitomi.la refresh
if(preferences.eh_hl_earlyRefresh().getOrDefault()) {
(Injekt.get<SourceManager>().get(HITOMI_SOURCE_ID) as Hitomi)
.ensureCacheLoaded(false)
.subscribeOn(Schedulers.computation())
.subscribe()
}
// <-- EH
syncActivityViewWithController(router.backstack.lastOrNull()?.controller())
if (savedInstanceState == null) {
// Show changelog if needed
if (Migrations.upgrade(preferences)) {
// TODO
// if (Migrations.upgrade(preferences)) {
// ChangelogDialogController().showDialog(router)
// }
// EXH -->
// Perform EXH specific migrations
if(EXHMigrations.upgrade(preferences)) {
ChangelogDialogController().showDialog(router)
}
// Migrate metadata if empty (EH)
if(!defRealm {
it.loadAllMetadata().any {
it.value.isNotEmpty()
}
}) MetadataFetchDialog().askMigration(this, false)
if(!preferences.migrateLibraryAsked().getOrDefault()) {
MetadataFetchDialog().askMigration(this, false)
}
// Upload settings
if(preferences.enableExhentai().getOrDefault()
&& preferences.eh_showSettingsUploadWarning().getOrDefault())
WarnConfigureDialogController.uploadSettings(router)
// EXH <--
}
}

View File

@@ -53,6 +53,7 @@ import exh.ui.webview.WebViewActivity
import jp.wasabeef.glide.transformations.CropSquareTransformation
import jp.wasabeef.glide.transformations.MaskTransformation
import kotlinx.android.synthetic.main.manga_info_controller.*
import timber.log.Timber
import uy.kohesive.injekt.injectLazy
import java.text.DateFormat
import java.text.DecimalFormat
@@ -139,7 +140,7 @@ class MangaInfoController : NucleusController<MangaInfoPresenter>(),
var text = tag
if(isEHentaiBasedSource()) {
val parsed = parseTag(text)
text = wrapTag(parsed.first, parsed.second)
text = wrapTag(parsed.first, parsed.second.substringBefore('|').trim())
}
performGlobalSearch(text)
}
@@ -386,6 +387,9 @@ class MangaInfoController : NucleusController<MangaInfoPresenter>(),
fun onFetchMangaError(error: Throwable) {
setRefreshing(false)
activity?.toast(error.message)
// EXH -->
Timber.e(error, "Failed to fetch manga details!")
// EXH <--
}
/**

View File

@@ -14,23 +14,29 @@ import android.view.*
import android.view.animation.Animation
import android.view.animation.AnimationUtils
import android.widget.SeekBar
import com.afollestad.materialdialogs.MaterialDialog
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import com.jakewharton.rxbinding.view.clicks
import com.jakewharton.rxbinding.widget.checkedChanges
import com.jakewharton.rxbinding.widget.textChanges
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.online.all.EHentai
import eu.kanade.tachiyomi.ui.base.activity.BaseRxActivity
import eu.kanade.tachiyomi.ui.reader.ReaderPresenter.SetAsCoverResult.AddToLibraryFirst
import eu.kanade.tachiyomi.ui.reader.ReaderPresenter.SetAsCoverResult.Error
import eu.kanade.tachiyomi.ui.reader.ReaderPresenter.SetAsCoverResult.Success
import eu.kanade.tachiyomi.ui.reader.loader.HttpPageLoader
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters
import eu.kanade.tachiyomi.ui.reader.viewer.BaseViewer
import eu.kanade.tachiyomi.ui.reader.viewer.pager.L2RPagerViewer
import eu.kanade.tachiyomi.ui.reader.viewer.pager.R2LPagerViewer
import eu.kanade.tachiyomi.ui.reader.viewer.pager.VerticalPagerViewer
import eu.kanade.tachiyomi.ui.reader.viewer.pager.*
import eu.kanade.tachiyomi.ui.reader.viewer.webtoon.WebtoonViewer
import eu.kanade.tachiyomi.util.*
import eu.kanade.tachiyomi.widget.SimpleAnimationListener
@@ -46,6 +52,7 @@ import timber.log.Timber
import uy.kohesive.injekt.injectLazy
import java.io.File
import java.util.concurrent.TimeUnit
import kotlin.math.roundToLong
/**
* Activity containing the reader of Tachiyomi. This activity is mostly a container of the
@@ -76,6 +83,15 @@ class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
var menuVisible = false
private set
// --> EH
private var ehUtilsVisible = false
private val exhSubscriptions = CompositeSubscription()
private var autoscrollSubscription: Subscription? = null
private val sourceManager: SourceManager by injectLazy()
// <-- EH
/**
* System UI helper to hide status & navigation bar on all different API levels.
*/
@@ -132,12 +148,49 @@ class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
if (savedState != null) {
menuVisible = savedState.getBoolean(::menuVisible.name)
// --> EH
ehUtilsVisible = savedState.getBoolean(::ehUtilsVisible.name)
// <-- EH
}
config = ReaderConfig()
initializeMenu()
}
// --> EH
private fun setEhUtilsVisibility(visible: Boolean) {
if(visible) {
eh_utils.visible()
expand_eh_button.setImageResource(R.drawable.ic_keyboard_arrow_up_white_32dp)
} else {
eh_utils.gone()
expand_eh_button.setImageResource(R.drawable.ic_keyboard_arrow_down_white_32dp)
}
}
// <-- EH
// --> EH
private fun setupAutoscroll(interval: Float) {
exhSubscriptions.remove(autoscrollSubscription)
autoscrollSubscription = null
if(interval == -1f) return
val intervalMs = (interval * 1000).roundToLong()
val sub = Observable.interval(intervalMs, intervalMs, TimeUnit.MILLISECONDS)
.observeOn(AndroidSchedulers.mainThread())
.subscribe {
viewer.let { v ->
if(v is PagerViewer) v.moveToNext()
else if(v is WebtoonViewer) v.scrollDown()
}
}
autoscrollSubscription = sub
exhSubscriptions += sub
}
// <-- EH
/**
* Called when the activity is destroyed. Cleans up the viewer, configuration and any view.
*/
@@ -157,6 +210,9 @@ class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
*/
override fun onSaveInstanceState(outState: Bundle) {
outState.putBoolean(::menuVisible.name, menuVisible)
// EXH -->
outState.putBoolean(::ehUtilsVisible.name, ehUtilsVisible)
// EXH <--
if (!isChangingConfigurations) {
presenter.onSaveInstanceStateNonConfigurationChange()
}
@@ -257,10 +313,151 @@ class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
}
}
// --> EH
exhSubscriptions += expand_eh_button.clicks().subscribe {
ehUtilsVisible = !ehUtilsVisible
setEhUtilsVisibility(ehUtilsVisible)
}
eh_autoscroll_freq.setText(preferences.eh_utilAutoscrollInterval().getOrDefault().let {
if(it == -1f)
""
else it.toString()
})
exhSubscriptions += eh_autoscroll.checkedChanges()
.observeOn(AndroidSchedulers.mainThread())
.subscribe {
setupAutoscroll(if(it)
preferences.eh_utilAutoscrollInterval().getOrDefault()
else -1f)
}
exhSubscriptions += eh_autoscroll_freq.textChanges()
.observeOn(AndroidSchedulers.mainThread())
.subscribe {
val parsed = it?.toString()?.toFloatOrNull()
if (parsed == null || parsed <= 0 || parsed > 9999) {
eh_autoscroll_freq.error = "Invalid frequency"
preferences.eh_utilAutoscrollInterval().set(-1f)
eh_autoscroll.isEnabled = false
setupAutoscroll(-1f)
} else {
eh_autoscroll_freq.error = null
preferences.eh_utilAutoscrollInterval().set(parsed)
eh_autoscroll.isEnabled = true
setupAutoscroll(if(eh_autoscroll.isChecked) parsed else -1f)
}
}
exhSubscriptions += eh_autoscroll_help.clicks().subscribe {
MaterialDialog.Builder(this)
.title("Autoscroll help")
.content("Automatically scroll to the next page in the specified interval. Interval is specified in seconds.")
.positiveText("Ok")
.show()
}
exhSubscriptions += eh_retry_all.clicks().subscribe {
var retried = 0
presenter.viewerChaptersRelay.value
.currChapter
.pages
?.forEachIndexed { index, page ->
var shouldQueuePage = false
if(page.status == Page.ERROR) {
shouldQueuePage = true
} else if(page.status == Page.LOAD_PAGE
|| page.status == Page.DOWNLOAD_IMAGE) {
// Do nothing
}
if(shouldQueuePage) {
page.status = Page.QUEUE
} else {
return@forEachIndexed
}
//If we are using EHentai/ExHentai, get a new image URL
presenter.manga?.let { m ->
val src = sourceManager.get(m.source)
if(src is EHentai)
page.imageUrl = null
}
val loader = page.chapter.pageLoader
if(page.index == exh_currentPage()?.index && loader is HttpPageLoader) {
loader.boostPage(page)
} else {
loader?.retryPage(page)
}
retried++
}
toast("Retrying $retried failed pages...")
}
exhSubscriptions += eh_retry_all_help.clicks().subscribe {
MaterialDialog.Builder(this)
.title("Retry all help")
.content("Re-add all failed pages to the download queue.")
.positiveText("Ok")
.show()
}
exhSubscriptions += eh_boost_page.clicks().subscribe {
viewer?.let { viewer ->
val curPage = exh_currentPage() ?: run {
toast("This page cannot be boosted (invalid page)!")
return@let
}
if(curPage.status == Page.ERROR) {
toast("Page failed to load, press the retry button instead!")
} else if(curPage.status == Page.LOAD_PAGE || curPage.status == Page.DOWNLOAD_IMAGE) {
toast("This page is already downloading!")
} else if(curPage.status == Page.READY) {
toast("This page has already been downloaded!")
} else {
val loader = (presenter.viewerChaptersRelay.value.currChapter.pageLoader as? HttpPageLoader)
if(loader != null) {
loader.boostPage(curPage)
toast("Boosted current page!")
} else {
toast("This page cannot be boosted (invalid page loader)!")
}
}
}
}
exhSubscriptions += eh_boost_page_help.clicks().subscribe {
MaterialDialog.Builder(this)
.title("Boost page help")
.content("Normally the downloader can only download a specific amount of pages at the same time. This means you can be waiting for a page to download but the downloader will not start downloading the page until it has a free download slot. Pressing 'Boost page' will force the downloader to begin downloading the current page, regardless of whether or not there is an available slot.")
.positiveText("Ok")
.show()
}
// <-- EH
// Set initial visibility
setMenuVisibility(menuVisible)
// --> EH
setEhUtilsVisibility(ehUtilsVisible)
// <-- EH
}
// EXH -->
private fun exh_currentPage(): ReaderPage? {
val currentPage = (((viewer as? PagerViewer)?.currentPage
?: (viewer as? WebtoonViewer)?.currentPage) as? ReaderPage)?.index
return currentPage?.let { presenter.viewerChaptersRelay.value.currChapter.pages?.getOrNull(it) }
}
// EXH <--
/**
* Sets the visibility of the menu according to [visible] and with an optional parameter to
* [animate] the views.
@@ -282,7 +479,9 @@ class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
}
}
})
toolbar.startAnimation(toolbarAnimation)
// EXH -->
header.startAnimation(toolbarAnimation)
// EXH <--
val bottomAnimation = AnimationUtils.loadAnimation(this, R.anim.enter_from_bottom)
reader_menu_bottom.startAnimation(bottomAnimation)
@@ -297,7 +496,9 @@ class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
reader_menu.visibility = View.GONE
}
})
toolbar.startAnimation(toolbarAnimation)
// EXH -->
header.startAnimation(toolbarAnimation)
// EXH <--
val bottomAnimation = AnimationUtils.loadAnimation(this, R.anim.exit_to_bottom)
reader_menu_bottom.startAnimation(bottomAnimation)

View File

@@ -70,7 +70,7 @@ class ReaderPresenter(
/**
* Relay for currently active viewer chapters.
*/
private val viewerChaptersRelay = BehaviorRelay.create<ViewerChapters>()
/* [EXH] private */ val viewerChaptersRelay = BehaviorRelay.create<ViewerChapters>()
/**
* Relay used when loading prev/next chapter needed to lock the UI (with a dialog).

View File

@@ -1,6 +1,8 @@
package eu.kanade.tachiyomi.ui.reader.loader
import eu.kanade.tachiyomi.data.cache.ChapterCache
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
@@ -15,6 +17,7 @@ import rx.subscriptions.CompositeSubscription
import timber.log.Timber
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
import java.util.concurrent.PriorityBlockingQueue
import java.util.concurrent.atomic.AtomicInteger
@@ -26,6 +29,9 @@ class HttpPageLoader(
private val source: HttpSource,
private val chapterCache: ChapterCache = Injekt.get()
) : PageLoader() {
// EXH -->
private val prefs: PreferencesHelper by injectLazy()
// EXH <--
/**
* A queue used to manage requests one by one while allowing priorities.
@@ -38,17 +44,23 @@ class HttpPageLoader(
private val subscriptions = CompositeSubscription()
init {
subscriptions += Observable.defer { Observable.just(queue.take().page) }
.filter { it.status == Page.QUEUE }
.concatMap { source.fetchImageFromCacheThenNet(it) }
.repeat()
.subscribeOn(Schedulers.io())
.subscribe({
}, { error ->
if (error !is InterruptedException) {
Timber.e(error)
}
})
// EXH -->
repeat(prefs.eh_readerThreads().getOrDefault()) {
// EXH <--
subscriptions += Observable.defer { Observable.just(queue.take().page) }
.filter { it.status == Page.QUEUE }
.concatMap { source.fetchImageFromCacheThenNet(it) }
.repeat()
.subscribeOn(Schedulers.io())
.subscribe({
}, { error ->
if (error !is InterruptedException) {
Timber.e(error)
}
})
// EXH -->
}
// EXH <--
}
/**
@@ -142,6 +154,9 @@ class HttpPageLoader(
if (page.status == Page.ERROR) {
page.status = Page.QUEUE
}
// EXH -->
if(prefs.eh_readerInstantRetry().getOrDefault()) boostPage(page)
else // EXH <--
queue.offer(PriorityPage(page, 2))
}
@@ -223,4 +238,20 @@ class HttpPageLoader(
.doOnNext { chapterCache.putImageToCache(page.imageUrl!!, it) }
.map { page }
}
// EXH -->
fun boostPage(page: ReaderPage) {
if(page.status == Page.QUEUE) {
subscriptions += Observable.just(page)
.concatMap { source.fetchImageFromCacheThenNet(it) }
.subscribeOn(Schedulers.io())
.subscribe({
}, { error ->
if (error !is InterruptedException) {
Timber.e(error)
}
})
}
}
// EXH <--
}

View File

@@ -39,7 +39,7 @@ abstract class PagerViewer(val activity: ReaderActivity) : BaseViewer {
/**
* Currently active item. It can be a chapter page or a chapter transition.
*/
private var currentPage: Any? = null
/* [EXH] private */ var currentPage: Any? = null
/**
* Viewer chapters to set when the pager enters idle mode. Otherwise, if the view was settling

View File

@@ -48,7 +48,7 @@ class WebtoonViewer(val activity: ReaderActivity) : BaseViewer {
/**
* Currently active item. It can be a chapter page or a chapter transition.
*/
private var currentPage: Any? = null
/* [EXH] private */ var currentPage: Any? = null
/**
* Configuration used by this viewer, like allow taps, or crop image borders.
@@ -200,7 +200,7 @@ class WebtoonViewer(val activity: ReaderActivity) : BaseViewer {
/**
* Scrolls down by [scrollDistance].
*/
private fun scrollDown() {
/* [EXH] private */ fun scrollDown() {
recycler.smoothScrollBy(0, scrollDistance)
}

View File

@@ -3,8 +3,8 @@ package eu.kanade.tachiyomi.ui.setting
import android.app.Dialog
import android.os.Bundle
import android.support.v7.preference.PreferenceScreen
import android.text.Html
import android.view.View
import android.widget.Toast
import com.afollestad.materialdialogs.MaterialDialog
import com.bluelinelabs.conductor.RouterTransaction
import com.bluelinelabs.conductor.changehandler.FadeChangeHandler
@@ -15,12 +15,13 @@ import eu.kanade.tachiyomi.data.library.LibraryUpdateService
import eu.kanade.tachiyomi.data.library.LibraryUpdateService.Target
import eu.kanade.tachiyomi.data.preference.PreferenceKeys
import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.source.SourceManager.Companion.DELEGATED_SOURCES
import eu.kanade.tachiyomi.ui.base.controller.DialogController
import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction
import eu.kanade.tachiyomi.ui.library.LibraryController
import eu.kanade.tachiyomi.util.toast
import exh.debug.SettingsDebugController
import exh.ui.migration.MetadataFetchDialog
import exh.util.realmTrans
import io.realm.Realm
import rx.Observable
import rx.android.schedulers.AndroidSchedulers
import rx.schedulers.Schedulers
@@ -74,6 +75,8 @@ class SettingsAdvancedController : SettingsController() {
onClick { LibraryUpdateService.start(context, target = Target.TRACKING) }
}
// --> EXH
preferenceCategory {
title = "Gallery metadata"
isPersistent = false
@@ -98,14 +101,29 @@ class SettingsAdvancedController : SettingsController() {
summary = "Clear all library metadata. Disables tag searching in the library"
onClick {
realmTrans {
it.deleteAll()
db.inTransaction {
db.deleteAllSearchMetadata().executeAsBlocking()
db.deleteAllSearchTags().executeAsBlocking()
db.deleteAllSearchTitle().executeAsBlocking()
}
context.toast("Library metadata cleared!")
}
}
}
switchPreference {
title = "Enable delegated sources"
key = PreferenceKeys.eh_delegateSources
defaultValue = true
summary = "Apply TachiyomiEH enhancements to the following sources if they are installed: ${DELEGATED_SOURCES.values.joinToString { it.sourceName }}"
}
preference {
title = "Open debug menu"
summary = Html.fromHtml("DO NOT TOUCH THIS MENU UNLESS YOU KNOW WHAT YOU ARE DOING! <font color='red'>IT CAN CORRUPT YOUR LIBRARY!</font>")
onClick { router.pushController(SettingsDebugController().withFadeTransaction()) }
}
// <-- EXH
}
private fun clearChapterCache() {

View File

@@ -1,6 +1,7 @@
package eu.kanade.tachiyomi.ui.setting
import android.app.Dialog
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.support.v7.preference.PreferenceScreen
@@ -180,7 +181,7 @@ class SettingsGeneralController : SettingsController() {
}
}
// --> EH
// --> EXH
switchPreference {
key = Keys.eh_askCategoryOnLongPress
title = "Long-press favorite button to specify category"
@@ -193,6 +194,14 @@ class SettingsGeneralController : SettingsController() {
defaultValue = false
}
switchPreference {
key = Keys.eh_autoSolveCaptchas
title = "Automatically solve captcha"
summary = "Use HIGHLY EXPERIMENTAL automatic ReCAPTCHA solver. Will be grayed out if unsupported by your device."
defaultValue = false
shouldDisableView = Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP
}
switchPreference {
key = Keys.eh_incogWebview
title = "Incognito 'Open in browser'"
@@ -228,7 +237,7 @@ class SettingsGeneralController : SettingsController() {
defaultValue = false
}
}
// <-- EH
// <-- EXH
}
class LibraryColumnsDialog : DialogController() {

View File

@@ -4,7 +4,6 @@ import android.support.v7.preference.PreferenceScreen
import android.widget.Toast
import eu.kanade.tachiyomi.data.preference.PreferenceKeys
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.online.all.Hitomi
import eu.kanade.tachiyomi.util.toast
import exh.HITOMI_SOURCE_ID
import uy.kohesive.injekt.Injekt
@@ -18,41 +17,6 @@ class SettingsHlController : SettingsController() {
override fun setupPreferenceScreen(screen: PreferenceScreen) = with(screen) {
title = "hitomi.la"
editTextPreference {
title = "Search database refresh frequency"
summary = "How often to get new entries for the search database in hours. Setting this frequency too high may cause high CPU usage and network usage."
key = PreferenceKeys.eh_hl_refreshFrequency
defaultValue = "24"
onChange {
it as String
if((it.toLongOrNull() ?: -1) <= 0) {
context.toast("Invalid frequency. Frequency must be a positive whole number.")
false
} else true
}
}
switchPreference {
title = "Begin refreshing search database on app launch"
summary = "Normally the search database gets refreshed (if required) when you open the hitomi.la catalogue. If you enable this option, the database gets refreshed in the background as soon as you open the app. It will result in higher data usage but may increase hitomi.la search speeds."
key = PreferenceKeys.eh_hl_earlyRefresh
defaultValue = false
}
preference {
title = "Force refresh search database now"
summary = "Delete the local copy of the hitomi.la search database and download the new database now. Hitomi.la search will not work in the ~10mins that it takes to refresh the search database"
isPersistent = false
onClick {
context.toast(if((Injekt.get<SourceManager>().get(HITOMI_SOURCE_ID) as Hitomi).forceEnsureCacheLoaded()) {
"Refreshing database. You will NOT be notified when it is complete!"
} else {
"Could not begin refresh process as there is already one ongoing!"
}, Toast.LENGTH_LONG)
}
}
// TODO Thumbnail quality chooser
}
}

View File

@@ -86,6 +86,7 @@ class SettingsReaderController : SettingsController() {
defaultValue = false
}
}
// EXH -->
intListPreference {
key = Keys.eh_readerThreads
title = "Download threads"
@@ -147,6 +148,7 @@ class SettingsReaderController : SettingsController() {
title = "Preserve reading position on read manga"
defaultValue = false
}
// EXH <--
preferenceCategory {
titleRes = R.string.pager_viewer