Add automatic gallery updating

This commit is contained in:
NerdNumber9 2019-04-18 17:40:13 -04:00
parent a218f4a48b
commit 1d36c3269e
29 changed files with 1240 additions and 87 deletions

View File

@ -289,6 +289,9 @@ dependencies {
releaseTestImplementation 'com.ms-square:debugoverlay:1.1.3' releaseTestImplementation 'com.ms-square:debugoverlay:1.1.3'
releaseImplementation 'com.ms-square:debugoverlay-no-op:1.1.3' releaseImplementation 'com.ms-square:debugoverlay-no-op:1.1.3'
testImplementation 'com.ms-square:debugoverlay-no-op:1.1.3' testImplementation 'com.ms-square:debugoverlay-no-op:1.1.3'
// Humanize (EH)
implementation 'com.github.mfornos:humanize-slim:1.2.2'
} }
buildscript { buildscript {

View File

@ -122,6 +122,10 @@
android:exported="false" /> android:exported="false" />
<!-- EH --> <!-- EH -->
<service
android:name="exh.eh.EHentaiUpdateWorker"
android:permission="android.permission.BIND_JOB_SERVICE"
android:exported="true" />
<activity <activity
android:name="exh.ui.intercept.InterceptActivity" android:name="exh.ui.intercept.InterceptActivity"
android:label="TachiyomiEH" android:label="TachiyomiEH"
@ -218,7 +222,7 @@
android:name="exh.ui.captcha.SolveCaptchaActivity" android:name="exh.ui.captcha.SolveCaptchaActivity"
android:launchMode="singleInstance" android:launchMode="singleInstance"
android:theme="@style/Theme.EHActivity" /> android:theme="@style/Theme.EHActivity" />
<activity android:name="exh.ui.webview.WebViewActivity"></activity> <activity android:name="exh.ui.webview.WebViewActivity" />
</application> </application>
</manifest> </manifest>

View File

@ -11,6 +11,7 @@ import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.extension.ExtensionManager import eu.kanade.tachiyomi.extension.ExtensionManager
import eu.kanade.tachiyomi.network.NetworkHelper import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.source.SourceManager
import exh.eh.EHentaiUpdateHelper
import rx.Observable import rx.Observable
import rx.schedulers.Schedulers import rx.schedulers.Schedulers
import uy.kohesive.injekt.api.* import uy.kohesive.injekt.api.*
@ -41,6 +42,8 @@ class AppModule(val app: Application) : InjektModule {
addSingletonFactory { Gson() } addSingletonFactory { Gson() }
addSingletonFactory { EHentaiUpdateHelper(app) }
// Asynchronously init expensive components for a faster cold start // Asynchronously init expensive components for a faster cold start
rxAsync { get<PreferencesHelper>() } rxAsync { get<PreferencesHelper>() }

View File

@ -57,6 +57,9 @@ object Migrations {
} }
} }
} }
// ===========[ ALL MIGRATIONS ABOVE HERE HAVE BEEN ALREADY REWRITTEN ]===========
return true return true
} }
return false return false

View File

@ -15,12 +15,14 @@ import java.util.*
interface ChapterQueries : DbProvider { interface ChapterQueries : DbProvider {
fun getChapters(manga: Manga) = db.get() fun getChapters(manga: Manga) = getChaptersByMangaId(manga.id)
fun getChaptersByMangaId(mangaId: Long?) = db.get()
.listOfObjects(Chapter::class.java) .listOfObjects(Chapter::class.java)
.withQuery(Query.builder() .withQuery(Query.builder()
.table(ChapterTable.TABLE) .table(ChapterTable.TABLE)
.where("${ChapterTable.COL_MANGA_ID} = ?") .where("${ChapterTable.COL_MANGA_ID} = ?")
.whereArgs(manga.id) .whereArgs(mangaId)
.build()) .build())
.prepare() .prepare()
@ -52,6 +54,15 @@ interface ChapterQueries : DbProvider {
.build()) .build())
.prepare() .prepare()
fun getChapters(url: String) = db.get()
.listOfObjects(Chapter::class.java)
.withQuery(Query.builder()
.table(ChapterTable.TABLE)
.where("${ChapterTable.COL_URL} = ?")
.whereArgs(url)
.build())
.prepare()
fun insertChapter(chapter: Chapter) = db.put().`object`(chapter).prepare() fun insertChapter(chapter: Chapter) = db.put().`object`(chapter).prepare()

View File

@ -29,6 +29,8 @@ import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.main.MainActivity import eu.kanade.tachiyomi.ui.main.MainActivity
import eu.kanade.tachiyomi.util.* import eu.kanade.tachiyomi.util.*
import exh.EH_SOURCE_ID
import exh.EXH_SOURCE_ID
import rx.Observable import rx.Observable
import rx.Subscription import rx.Subscription
import rx.schedulers.Schedulers import rx.schedulers.Schedulers
@ -283,24 +285,29 @@ class LibraryUpdateService(
.doOnNext { showProgressNotification(it, count.andIncrement, mangaToUpdate.size) } .doOnNext { showProgressNotification(it, count.andIncrement, mangaToUpdate.size) }
// Update the chapters of the manga. // Update the chapters of the manga.
.concatMap { manga -> .concatMap { manga ->
updateManga(manga) if(manga.source == EXH_SOURCE_ID || manga.source == EH_SOURCE_ID) {
// If there's any error, return empty update and continue. // Ignore EXH manga, updating chapters for every manga will get you banned
.onErrorReturn { Observable.just(manga)
failedUpdates.add(manga) } else {
Pair(emptyList(), emptyList()) updateManga(manga)
} // If there's any error, return empty update and continue.
// Filter out mangas without new chapters (or failed). .onErrorReturn {
.filter { pair -> pair.first.isNotEmpty() } failedUpdates.add(manga)
.doOnNext { Pair(emptyList(), emptyList())
if (downloadNew && (categoriesToDownload.isEmpty() ||
manga.category in categoriesToDownload)) {
downloadChapters(manga, it.first)
hasDownloads = true
} }
} // Filter out mangas without new chapters (or failed).
// Convert to the manga that contains new chapters. .filter { pair -> pair.first.isNotEmpty() }
.map { manga } .doOnNext {
if (downloadNew && (categoriesToDownload.isEmpty() ||
manga.category in categoriesToDownload)) {
downloadChapters(manga, it.first)
hasDownloads = true
}
}
// Convert to the manga that contains new chapters.
.map { manga }
}
} }
// Add manga with new chapters to the list. // Add manga with new chapters to the list.
.doOnNext { manga -> .doOnNext { manga ->

View File

@ -186,4 +186,10 @@ object PreferenceKeys {
const val eh_logLevel = "eh_log_level" const val eh_logLevel = "eh_log_level"
const val eh_enableSourceBlacklist = "eh_enable_source_blacklist" const val eh_enableSourceBlacklist = "eh_enable_source_blacklist"
const val eh_autoUpdateFrequency = "eh_auto_update_frequency"
const val eh_autoUpdateRestrictions = "eh_auto_update_restrictions"
const val eh_autoUpdateStats = "eh_auto_update_stats"
} }

View File

@ -259,4 +259,10 @@ class PreferencesHelper(val context: Context) {
fun eh_logLevel() = rxPrefs.getInteger(Keys.eh_logLevel, 0) fun eh_logLevel() = rxPrefs.getInteger(Keys.eh_logLevel, 0)
fun eh_enableSourceBlacklist() = rxPrefs.getBoolean(Keys.eh_enableSourceBlacklist, true) fun eh_enableSourceBlacklist() = rxPrefs.getBoolean(Keys.eh_enableSourceBlacklist, true)
fun eh_autoUpdateFrequency() = rxPrefs.getInteger(Keys.eh_autoUpdateFrequency, 1)
fun eh_autoUpdateRequirements() = prefs.getStringSet(Keys.eh_autoUpdateRestrictions, emptySet())
fun eh_autoUpdateStats() = rxPrefs.getString(Keys.eh_autoUpdateStats, "")
} }

View File

@ -23,6 +23,11 @@ interface SManga : Serializable {
var initialized: Boolean var initialized: Boolean
fun copyFrom(other: SManga) { fun copyFrom(other: SManga) {
// EXH -->
url = other.url // Allow dynamically mutating one manga into another
title = other.title
// EXH <--
if (other.author != null) if (other.author != null)
author = other.author author = other.author

View File

@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.source.online.all
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import com.elvishew.xlog.XLog
import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.getOrDefault import eu.kanade.tachiyomi.data.preference.getOrDefault
@ -12,6 +13,7 @@ import eu.kanade.tachiyomi.source.model.*
import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.source.online.LewdSource import eu.kanade.tachiyomi.source.online.LewdSource
import eu.kanade.tachiyomi.util.asJsoup import eu.kanade.tachiyomi.util.asJsoup
import exh.eh.EHentaiUpdateHelper
import exh.metadata.EX_DATE_FORMAT import exh.metadata.EX_DATE_FORMAT
import exh.metadata.metadata.EHentaiSearchMetadata import exh.metadata.metadata.EHentaiSearchMetadata
import exh.metadata.metadata.EHentaiSearchMetadata.Companion.EH_GENRE_NAMESPACE import exh.metadata.metadata.EHentaiSearchMetadata.Companion.EH_GENRE_NAMESPACE
@ -33,11 +35,17 @@ import uy.kohesive.injekt.injectLazy
import java.net.URLEncoder import java.net.URLEncoder
import java.util.* import java.util.*
import exh.metadata.metadata.base.RaisedTag import exh.metadata.metadata.base.RaisedTag
import exh.eh.EHentaiUpdateWorker
import exh.eh.GalleryEntry
import kotlinx.coroutines.runBlocking
import org.jsoup.nodes.TextNode
import rx.Single
import java.lang.RuntimeException import java.lang.RuntimeException
// TODO Consider gallery updating when doing tabbed browsing
class EHentai(override val id: Long, class EHentai(override val id: Long,
val exh: Boolean, val exh: Boolean,
val context: Context) : HttpSource(), LewdSource<EHentaiSearchMetadata, Response> { val context: Context) : HttpSource(), LewdSource<EHentaiSearchMetadata, Document> {
override val metaClass = EHentaiSearchMetadata::class override val metaClass = EHentaiSearchMetadata::class
val schema: String val schema: String
@ -58,7 +66,8 @@ class EHentai(override val id: Long,
override val lang = "all" override val lang = "all"
override val supportsLatest = true override val supportsLatest = true
val prefs: PreferencesHelper by injectLazy() private val prefs: PreferencesHelper by injectLazy()
private val updateHelper: EHentaiUpdateHelper by injectLazy()
/** /**
* Gallery list entry * Gallery list entry
@ -115,15 +124,83 @@ class EHentai(override val id: Long,
MangasPage(it.first.map { it.manga }, it.second) MangasPage(it.first.map { it.manga }, it.second)
} }
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> override fun fetchChapterList(manga: SManga)
= Observable.just(listOf(SChapter.create().apply { = fetchChapterList(manga) {}
url = manga.url
name = "Chapter" fun fetchChapterList(manga: SManga, throttleFunc: () -> Unit): Observable<List<SChapter>> {
chapter_number = 1f return Single.fromCallable {
})) // Pull all the way to the root gallery
// We can't do this with RxJava or we run into stack overflows on shit like this:
// https://exhentai.org/g/1073061/f9345f1c12/
var url: String = manga.url
var doc: Document? = null
runBlocking {
while (true) {
val gid = EHentaiSearchMetadata.galleryId(url).toInt()
val cachedParent = updateHelper.parentLookupTable.get(
gid
)
if(cachedParent == null) {
throttleFunc()
val resp = client.newCall(exGet(baseUrl + url)).execute()
if (!resp.isSuccessful) error("HTTP error (${resp.code()})!")
doc = resp.asJsoup()
val parentLink = doc!!.select("#gdd .gdt1").find { el ->
el.text().toLowerCase() == "parent:"
}!!.nextElementSibling().selectFirst("a")?.attr("href")
if (parentLink != null) {
updateHelper.parentLookupTable.put(
gid,
GalleryEntry(
EHentaiSearchMetadata.galleryId(parentLink),
EHentaiSearchMetadata.galleryToken(parentLink)
)
)
url = EHentaiSearchMetadata.normalizeUrl(parentLink)
} else break
} else {
XLog.d("Parent cache hit: %s!", gid)
url = EHentaiSearchMetadata.idAndTokenToUrl(
cachedParent.gId,
cachedParent.gToken
)
}
}
}
doc!!
}.map { d ->
val newDisplay = d.select("#gnd a")
// Build chapter for root gallery
val self = SChapter.create().apply {
url = EHentaiSearchMetadata.normalizeUrl(d.location())
name = "v1: " + d.selectFirst("#gn").text()
chapter_number = 1f
date_upload = EX_DATE_FORMAT.parse(d.select("#gdd .gdt1").find { el ->
el.text().toLowerCase() == "posted:"
}!!.nextElementSibling().text()).time
}
// Build and append the rest of the galleries
listOf(self) + newDisplay.mapIndexed { index, newGallery ->
val link = newGallery.attr("href")
val name = newGallery.text()
val posted = (newGallery.nextSibling() as TextNode).text().removePrefix(", added ")
SChapter.create().apply {
this.url = EHentaiSearchMetadata.normalizeUrl(link)
this.name = "v${index + 2}: $name"
this.chapter_number = index + 2f
this.date_upload = EX_DATE_FORMAT.parse(posted).time
}
}
}.toObservable()
}
override fun fetchPageList(chapter: SChapter) override fun fetchPageList(chapter: SChapter)
= fetchChapterPage(chapter, "$baseUrl/${chapter.url}").map { = fetchChapterPage(chapter, baseUrl + chapter.url).map {
it.mapIndexed { i, s -> it.mapIndexed { i, s ->
Page(i, s) Page(i, s)
} }
@ -241,9 +318,20 @@ class EHentai(override val id: Long,
.asObservableWithAsyncStacktrace() .asObservableWithAsyncStacktrace()
.flatMap { (stacktrace, response) -> .flatMap { (stacktrace, response) ->
if(response.isSuccessful) { if(response.isSuccessful) {
parseToManga(manga, response).andThen(Observable.just(manga.apply { // Pull to most recent
initialized = true val doc = response.asJsoup()
})) val newerGallery = doc.select("#gnd a").lastOrNull()
val pre = if(newerGallery != null) {
manga.url = EHentaiSearchMetadata.normalizeUrl(newerGallery.attr("href"))
client.newCall(mangaDetailsRequest(manga))
.asObservableSuccess().map { it.asJsoup() }
} else Observable.just(doc)
pre.flatMap {
parseToManga(manga, it).andThen(Observable.just(manga.apply {
initialized = true
}))
}
} else { } else {
response.close() response.close()
@ -261,10 +349,10 @@ class EHentai(override val id: Long,
*/ */
override fun mangaDetailsParse(response: Response) = throw UnsupportedOperationException() override fun mangaDetailsParse(response: Response) = throw UnsupportedOperationException()
override fun parseIntoMetadata(metadata: EHentaiSearchMetadata, input: Response) { override fun parseIntoMetadata(metadata: EHentaiSearchMetadata, input: Document) {
with(metadata) { with(metadata) {
with(input.asJsoup()) { with(input) {
val url = input.request().url().encodedPath() val url = input.location()
gId = EHentaiSearchMetadata.galleryId(url) gId = EHentaiSearchMetadata.galleryId(url)
gToken = EHentaiSearchMetadata.galleryToken(url) gToken = EHentaiSearchMetadata.galleryToken(url)
@ -296,6 +384,8 @@ class EHentai(override val id: Long,
.toLowerCase()) { .toLowerCase()) {
"posted" -> datePosted = EX_DATE_FORMAT.parse(right).time "posted" -> datePosted = EX_DATE_FORMAT.parse(right).time
// Example gallery with parent: https://e-hentai.org/g/1390451/7f181c2426/ // Example gallery with parent: https://e-hentai.org/g/1390451/7f181c2426/
// Example JP gallery: https://exhentai.org/g/1375385/03519d541b/
// Parent is older variation of the gallery
"parent" -> parent = if (!right.equals("None", true)) { "parent" -> parent = if (!right.equals("None", true)) {
rightElement.child(0).attr("href") rightElement.child(0).attr("href")
} else null } else null
@ -312,6 +402,12 @@ class EHentai(override val id: Long,
} }
} }
lastUpdateCheck = System.currentTimeMillis()
if(datePosted != null
&& lastUpdateCheck - datePosted!! > EHentaiUpdateWorker.GALLERY_AGE_TIME) {
aged = true
}
//Parse ratings //Parse ratings
ignore { ignore {
averageRating = select("#rating_label") averageRating = select("#rating_label")

View File

@ -26,6 +26,7 @@ import eu.kanade.tachiyomi.ui.base.controller.RxController
import eu.kanade.tachiyomi.ui.base.controller.TabbedController import eu.kanade.tachiyomi.ui.base.controller.TabbedController
import eu.kanade.tachiyomi.ui.base.controller.requestPermissionsSafe import eu.kanade.tachiyomi.ui.base.controller.requestPermissionsSafe
import eu.kanade.tachiyomi.ui.manga.chapter.ChaptersController import eu.kanade.tachiyomi.ui.manga.chapter.ChaptersController
import eu.kanade.tachiyomi.ui.manga.chapter.ChaptersPresenter
import eu.kanade.tachiyomi.ui.manga.info.MangaInfoController import eu.kanade.tachiyomi.ui.manga.info.MangaInfoController
import eu.kanade.tachiyomi.ui.manga.track.TrackController import eu.kanade.tachiyomi.ui.manga.track.TrackController
import eu.kanade.tachiyomi.util.toast import eu.kanade.tachiyomi.util.toast
@ -48,6 +49,18 @@ class MangaController : RxController, TabbedController {
} }
} }
// EXH -->
constructor(redirect: ChaptersPresenter.EXHRedirect) : super(Bundle().apply {
putLong(MANGA_EXTRA, redirect.manga.id!!)
putBoolean(UPDATE_EXTRA, redirect.update)
}) {
this.manga = redirect.manga
if (manga != null) {
source = Injekt.get<SourceManager>().getOrStub(redirect.manga.source)
}
}
// EXH <--
constructor(mangaId: Long) : this( constructor(mangaId: Long) : this(
Injekt.get<DatabaseHelper>().getManga(mangaId).executeAsBlocking()) Injekt.get<DatabaseHelper>().getManga(mangaId).executeAsBlocking())
@ -64,6 +77,8 @@ class MangaController : RxController, TabbedController {
val fromCatalogue = args.getBoolean(FROM_CATALOGUE_EXTRA, false) val fromCatalogue = args.getBoolean(FROM_CATALOGUE_EXTRA, false)
val update = args.getBoolean(UPDATE_EXTRA, false)
val lastUpdateRelay: BehaviorRelay<Date> = BehaviorRelay.create() val lastUpdateRelay: BehaviorRelay<Date> = BehaviorRelay.create()
val chapterCountRelay: BehaviorRelay<Float> = BehaviorRelay.create() val chapterCountRelay: BehaviorRelay<Float> = BehaviorRelay.create()
@ -180,6 +195,9 @@ class MangaController : RxController, TabbedController {
companion object { companion object {
// EXH -->
const val UPDATE_EXTRA = "update"
// EXH <--
const val FROM_CATALOGUE_EXTRA = "from_catalogue" const val FROM_CATALOGUE_EXTRA = "from_catalogue"
const val MANGA_EXTRA = "manga" const val MANGA_EXTRA = "manga"

View File

@ -11,6 +11,7 @@ import android.support.v7.view.ActionMode
import android.support.v7.widget.DividerItemDecoration import android.support.v7.widget.DividerItemDecoration
import android.support.v7.widget.LinearLayoutManager import android.support.v7.widget.LinearLayoutManager
import android.view.* import android.view.*
import com.bluelinelabs.conductor.RouterTransaction
import com.elvishew.xlog.XLog import com.elvishew.xlog.XLog
import com.jakewharton.rxbinding.support.v4.widget.refreshes import com.jakewharton.rxbinding.support.v4.widget.refreshes
import com.jakewharton.rxbinding.view.clicks import com.jakewharton.rxbinding.view.clicks
@ -28,6 +29,7 @@ import eu.kanade.tachiyomi.util.getCoordinates
import eu.kanade.tachiyomi.util.snack import eu.kanade.tachiyomi.util.snack
import eu.kanade.tachiyomi.util.toast import eu.kanade.tachiyomi.util.toast
import kotlinx.android.synthetic.main.chapters_controller.* import kotlinx.android.synthetic.main.chapters_controller.*
import rx.android.schedulers.AndroidSchedulers
import timber.log.Timber import timber.log.Timber
class ChaptersController : NucleusController<ChaptersPresenter>(), class ChaptersController : NucleusController<ChaptersPresenter>(),
@ -104,6 +106,14 @@ class ChaptersController : NucleusController<ChaptersPresenter>(),
view.context.toast(R.string.no_next_chapter) view.context.toast(R.string.no_next_chapter)
} }
} }
presenter.redirectUserRelay
.observeOn(AndroidSchedulers.mainThread())
.subscribeUntilDestroy { redirect ->
XLog.d("Redirecting to updated manga (manga.id: %s, manga.title: %s, update: %s)!", redirect.manga.id, redirect.manga.title, redirect.update)
// Replace self
parentController?.router?.replaceTopController(RouterTransaction.with(MangaController(redirect)))
}
} }
override fun onDestroyView(view: View) { override fun onDestroyView(view: View) {
@ -188,6 +198,9 @@ class ChaptersController : NucleusController<ChaptersPresenter>(),
if (presenter.chapters.isEmpty()) if (presenter.chapters.isEmpty())
initialFetchChapters() initialFetchChapters()
if ((parentController as MangaController).update)
fetchChaptersFromSource()
val adapter = adapter ?: return val adapter = adapter ?: return
adapter.updateDataSet(chapters) adapter.updateDataSet(chapters)

View File

@ -15,6 +15,9 @@ import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import eu.kanade.tachiyomi.util.isNullOrUnsubscribed import eu.kanade.tachiyomi.util.isNullOrUnsubscribed
import eu.kanade.tachiyomi.util.syncChaptersWithSource import eu.kanade.tachiyomi.util.syncChaptersWithSource
import exh.EH_SOURCE_ID
import exh.EXH_SOURCE_ID
import exh.eh.EHentaiUpdateHelper
import rx.Observable import rx.Observable
import rx.Subscription import rx.Subscription
import rx.android.schedulers.AndroidSchedulers import rx.android.schedulers.AndroidSchedulers
@ -22,6 +25,7 @@ import rx.schedulers.Schedulers
import timber.log.Timber import timber.log.Timber
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
import java.util.Date import java.util.Date
/** /**
@ -66,6 +70,14 @@ class ChaptersPresenter(
*/ */
private var observeDownloadsSubscription: Subscription? = null private var observeDownloadsSubscription: Subscription? = null
// EXH -->
private val updateHelper: EHentaiUpdateHelper by injectLazy()
val redirectUserRelay = BehaviorRelay.create<EXHRedirect>()
data class EXHRedirect(val manga: Manga, val update: Boolean)
// EXH <--
override fun onCreate(savedState: Bundle?) { override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState) super.onCreate(savedState)
@ -100,6 +112,25 @@ class ChaptersPresenter(
lastUpdateRelay.call(Date(chapters.maxBy { it.date_upload }?.date_upload lastUpdateRelay.call(Date(chapters.maxBy { it.date_upload }?.date_upload
?: 0)) ?: 0))
// EXH -->
if(chapters.isNotEmpty()
&& (source.id == EXH_SOURCE_ID || source.id == EH_SOURCE_ID)) {
// Check for gallery in library and accept manga with lowest id
// Find chapters sharing same root
add(updateHelper.findAcceptedRootAndDiscardOthers(chapters)
.subscribeOn(Schedulers.io())
.subscribe { (acceptedChain, _) ->
// Redirect if we are not the accepted root
if(manga.id != acceptedChain.manga.id) {
// Update if any of our chapters are not in accepted manga's chapters
val ourChapterUrls = chapters.map { it.url }.toSet()
val acceptedChapterUrls = acceptedChain.chapters.map { it.url }.toSet()
val update = (ourChapterUrls - acceptedChapterUrls).isNotEmpty()
redirectUserRelay.call(EXHRedirect(acceptedChain.manga, update))
}
})
}
// EXH <--
} }
.subscribe { chaptersRelay.call(it) }) .subscribe { chaptersRelay.call(it) })
} }

View File

@ -22,6 +22,7 @@ import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.load.resource.bitmap.RoundedCorners import com.bumptech.glide.load.resource.bitmap.RoundedCorners
import com.bumptech.glide.request.target.SimpleTarget import com.bumptech.glide.request.target.SimpleTarget
import com.bumptech.glide.request.transition.Transition import com.bumptech.glide.request.transition.Transition
import com.bumptech.glide.signature.ObjectKey
import com.elvishew.xlog.XLog import com.elvishew.xlog.XLog
import com.jakewharton.rxbinding.support.v4.widget.refreshes import com.jakewharton.rxbinding.support.v4.widget.refreshes
import com.jakewharton.rxbinding.view.clicks import com.jakewharton.rxbinding.view.clicks
@ -74,7 +75,9 @@ class MangaInfoController : NucleusController<MangaInfoPresenter>(),
*/ */
private val preferences: PreferencesHelper by injectLazy() private val preferences: PreferencesHelper by injectLazy()
private val sourceManager: SourceManager by injectLazy() // EXH -->
private var lastMangaThumbnail: String? = null
// EXH <--
init { init {
setHasOptionsMenu(true) setHasOptionsMenu(true)
@ -181,6 +184,7 @@ class MangaInfoController : NucleusController<MangaInfoPresenter>(),
// Update view. // Update view.
setMangaInfo(manga, source) setMangaInfo(manga, source)
if((parentController as MangaController).update) fetchMangaFromSource()
} else { } else {
// Initialize manga. // Initialize manga.
fetchMangaFromSource() fetchMangaFromSource()
@ -247,10 +251,17 @@ class MangaInfoController : NucleusController<MangaInfoPresenter>(),
// Set the favorite drawable to the correct one. // Set the favorite drawable to the correct one.
setFavoriteDrawable(manga.favorite) setFavoriteDrawable(manga.favorite)
// Set cover if it wasn't already. // Set cover if it matches
if (manga_cover.drawable == null && !manga.thumbnail_url.isNullOrEmpty()) { val tagMatches = lastMangaThumbnail == manga.thumbnail_url
val coverLoaded = manga_cover.drawable != null
if ((!tagMatches || !coverLoaded) && !manga.thumbnail_url.isNullOrEmpty()) {
lastMangaThumbnail = manga.thumbnail_url
val coverSig = ObjectKey(manga.thumbnail_url ?: "")
GlideApp.with(view.context) GlideApp.with(view.context)
.load(manga) .load(manga)
.signature(coverSig)
.diskCacheStrategy(DiskCacheStrategy.RESOURCE) .diskCacheStrategy(DiskCacheStrategy.RESOURCE)
.centerCrop() .centerCrop()
.into(manga_cover) .into(manga_cover)
@ -258,6 +269,7 @@ class MangaInfoController : NucleusController<MangaInfoPresenter>(),
if (backdrop != null) { if (backdrop != null) {
GlideApp.with(view.context) GlideApp.with(view.context)
.load(manga) .load(manga)
.signature(coverSig)
.diskCacheStrategy(DiskCacheStrategy.RESOURCE) .diskCacheStrategy(DiskCacheStrategy.RESOURCE)
.centerCrop() .centerCrop()
.into(backdrop) .into(backdrop)

View File

@ -1,26 +1,54 @@
package eu.kanade.tachiyomi.ui.setting package eu.kanade.tachiyomi.ui.setting
import android.os.Build
import android.os.Handler
import android.support.v7.preference.PreferenceScreen import android.support.v7.preference.PreferenceScreen
import android.widget.Toast import android.widget.Toast
import com.afollestad.materialdialogs.MaterialDialog import com.afollestad.materialdialogs.MaterialDialog
import com.bluelinelabs.conductor.RouterTransaction import com.bluelinelabs.conductor.RouterTransaction
import com.bluelinelabs.conductor.changehandler.FadeChangeHandler import com.bluelinelabs.conductor.changehandler.FadeChangeHandler
import com.f2prateek.rx.preferences.Preference import com.f2prateek.rx.preferences.Preference
import com.github.salomonbrys.kotson.fromJson
import com.google.gson.Gson
import com.kizitonwose.time.Interval
import com.kizitonwose.time.days
import com.kizitonwose.time.hours
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.preference.PreferenceKeys import eu.kanade.tachiyomi.data.preference.PreferenceKeys
import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.util.toast import eu.kanade.tachiyomi.util.toast
import exh.EH_SOURCE_ID
import exh.EXH_SOURCE_ID
import exh.eh.EHentaiUpdateWorker
import exh.eh.EHentaiUpdaterStats
import exh.favorites.FavoritesIntroDialog import exh.favorites.FavoritesIntroDialog
import exh.favorites.LocalFavoritesStorage import exh.favorites.LocalFavoritesStorage
import exh.metadata.metadata.EHentaiSearchMetadata
import exh.metadata.metadata.base.getFlatMetadataForManga
import exh.metadata.nullIfBlank
import exh.uconfig.WarnConfigureDialogController import exh.uconfig.WarnConfigureDialogController
import exh.ui.login.LoginController import exh.ui.login.LoginController
import exh.util.await
import exh.util.trans import exh.util.trans
import humanize.Humanize
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import rx.android.schedulers.AndroidSchedulers import rx.android.schedulers.AndroidSchedulers
import rx.schedulers.Schedulers import rx.schedulers.Schedulers
import uy.kohesive.injekt.injectLazy
import java.util.*
/** /**
* EH Settings fragment * EH Settings fragment
*/ */
class SettingsEhController : SettingsController() { class SettingsEhController : SettingsController() {
private val gson: Gson by injectLazy()
private val db: DatabaseHelper by injectLazy()
private fun Preference<*>.reconfigure(): Boolean { private fun Preference<*>.reconfigure(): Boolean {
//Listen for change commit //Listen for change commit
asObservable() asObservable()
@ -183,5 +211,113 @@ class SettingsEhController : SettingsController() {
} }
} }
} }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
preferenceCategory {
title = "Gallery update checker"
intListPreference {
key = PreferenceKeys.eh_autoUpdateFrequency
title = "Time between update batches"
entries = arrayOf("Never update galleries", "1 hour", "2 hours", "3 hours", "6 hours", "12 hours", "24 hours", "48 hours")
entryValues = arrayOf("0", "1", "2", "3", "6", "12", "24", "48")
defaultValue = "0"
preferences.eh_autoUpdateFrequency().asObservable().subscribeUntilDestroy { newVal ->
summary = if(newVal == 0) {
"${context.getString(R.string.app_name)} will currently never check galleries in your library for updates."
} else {
"${context.getString(R.string.app_name)} checks/updates galleries in batches. " +
"This means it will wait $newVal hour(s), check ${EHentaiUpdateWorker.UPDATES_PER_ITERATION} galleries," +
" wait $newVal hour(s), check ${EHentaiUpdateWorker.UPDATES_PER_ITERATION} and so on..."
}
}
onChange { newValue ->
val interval = (newValue as String).toInt()
EHentaiUpdateWorker.scheduleBackground(context, interval)
true
}
}
multiSelectListPreference {
key = PreferenceKeys.eh_autoUpdateRestrictions
title = "Auto update restrictions"
entriesRes = arrayOf(R.string.wifi, R.string.charging)
entryValues = arrayOf("wifi", "ac")
summaryRes = R.string.pref_library_update_restriction_summary
preferences.eh_autoUpdateFrequency().asObservable()
.subscribeUntilDestroy { isVisible = it > 0 }
onChange {
// Post to event looper to allow the preference to be updated.
Handler().post { EHentaiUpdateWorker.scheduleBackground(context) }
true
}
}
preference {
title = "Show updater statistics"
onClick {
val progress = MaterialDialog.Builder(context)
.progress(true, 0)
.content("Collecting statistics...")
.cancelable(false)
.show()
GlobalScope.launch(Dispatchers.IO) {
val updateInfo = try {
val stats = preferences.eh_autoUpdateStats().getOrDefault().nullIfBlank()?.let {
gson.fromJson<EHentaiUpdaterStats>(it)
}
val statsText = if (stats != null) {
"The updater last ran ${Humanize.naturalTime(Date(stats.startTime))}, and checked ${stats.updateCount} out of the ${stats.possibleUpdates} galleries that were ready for checking."
} else "The updater has not ran yet."
val allMeta = db.getMangaWithMetadata().await().filter {
it.favorite && (it.source == EH_SOURCE_ID || it.source == EXH_SOURCE_ID)
}.mapNotNull {
db.getFlatMetadataForManga(it.id!!).await()?.raise<EHentaiSearchMetadata>()
}.toList()
fun metaInRelativeDuration(duration: Interval<*>): Int {
val durationMs = duration.inMilliseconds.longValue
return allMeta.asSequence().filter {
System.currentTimeMillis() - it.lastUpdateCheck < durationMs
}.count()
}
"""
$statsText
Galleries that were checked in the last:
- hour: ${metaInRelativeDuration(1.hours)}
- 6 hours: ${metaInRelativeDuration(6.hours)}
- 12 hours: ${metaInRelativeDuration(12.hours)}
- day: ${metaInRelativeDuration(1.days)}
- 2 days: ${metaInRelativeDuration(2.days)}
- week: ${metaInRelativeDuration(7.days)}
- month: ${metaInRelativeDuration(30.days)}
- year: ${metaInRelativeDuration(365.days)}
""".trimIndent()
} finally {
progress.dismiss()
}
withContext(Dispatchers.Main) {
MaterialDialog.Builder(context)
.title("Gallery updater statistics")
.content(updateInfo)
.positiveText("Ok")
.show()
}
}
}
}
}
}
} }
} }

View File

@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.util
import android.app.ActivityManager import android.app.ActivityManager
import android.app.Notification import android.app.Notification
import android.app.NotificationManager import android.app.NotificationManager
import android.app.job.JobScheduler
import android.content.* import android.content.*
import android.content.Context.VIBRATOR_SERVICE import android.content.Context.VIBRATOR_SERVICE
import android.content.pm.PackageManager import android.content.pm.PackageManager
@ -128,11 +129,11 @@ val Context.wifiManager: WifiManager
get() = applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager get() = applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
// --> EH // --> EH
/**
* Property to get the wifi manager from the context.
*/
val Context.clipboardManager: ClipboardManager val Context.clipboardManager: ClipboardManager
get() = applicationContext.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager get() = applicationContext.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val Context.jobScheduler: JobScheduler
get() = applicationContext.getSystemService(Context.JOB_SCHEDULER_SERVICE) as JobScheduler
// <-- EH // <-- EH
/** /**

View File

@ -1,5 +1,6 @@
package exh package exh
import com.elvishew.xlog.XLog
import com.pushtorefresh.storio.sqlite.queries.Query import com.pushtorefresh.storio.sqlite.queries.Query
import com.pushtorefresh.storio.sqlite.queries.RawQuery import com.pushtorefresh.storio.sqlite.queries.RawQuery
import eu.kanade.tachiyomi.data.backup.models.DHistory import eu.kanade.tachiyomi.data.backup.models.DHistory
@ -20,6 +21,8 @@ import java.net.URISyntaxException
object EXHMigrations { object EXHMigrations {
private val db: DatabaseHelper by injectLazy() private val db: DatabaseHelper by injectLazy()
private val logger = XLog.tag("EXHMigrations")
private const val CURRENT_MIGRATION_VERSION = 1 private const val CURRENT_MIGRATION_VERSION = 1
/** /**
@ -31,45 +34,49 @@ object EXHMigrations {
fun upgrade(preferences: PreferencesHelper): Boolean { fun upgrade(preferences: PreferencesHelper): Boolean {
val context = preferences.context val context = preferences.context
val oldVersion = preferences.eh_lastVersionCode().getOrDefault() val oldVersion = preferences.eh_lastVersionCode().getOrDefault()
if (oldVersion < CURRENT_MIGRATION_VERSION) { try {
preferences.eh_lastVersionCode().set(CURRENT_MIGRATION_VERSION) if (oldVersion < CURRENT_MIGRATION_VERSION) {
if (oldVersion < 1) {
if(oldVersion < 1) { db.inTransaction {
db.inTransaction { // Migrate HentaiCafe source IDs
// Migrate HentaiCafe source IDs db.lowLevel().executeSQL(RawQuery.builder()
db.lowLevel().executeSQL(RawQuery.builder() .query("""
.query("""
UPDATE ${MangaTable.TABLE} UPDATE ${MangaTable.TABLE}
SET ${MangaTable.COL_SOURCE} = $HENTAI_CAFE_SOURCE_ID SET ${MangaTable.COL_SOURCE} = $HENTAI_CAFE_SOURCE_ID
WHERE ${MangaTable.COL_SOURCE} = 6908 WHERE ${MangaTable.COL_SOURCE} = 6908
""".trimIndent()) """.trimIndent())
.affectsTables(MangaTable.TABLE) .affectsTables(MangaTable.TABLE)
.build()) .build())
// Migrate nhentai URLs // Migrate nhentai URLs
val nhentaiManga = db.db.get() val nhentaiManga = db.db.get()
.listOfObjects(Manga::class.java) .listOfObjects(Manga::class.java)
.withQuery(Query.builder() .withQuery(Query.builder()
.table(MangaTable.TABLE) .table(MangaTable.TABLE)
.where("${MangaTable.COL_SOURCE} = $NHENTAI_SOURCE_ID") .where("${MangaTable.COL_SOURCE} = $NHENTAI_SOURCE_ID")
.build()) .build())
.prepare() .prepare()
.executeAsBlocking() .executeAsBlocking()
nhentaiManga.forEach { nhentaiManga.forEach {
it.url = getUrlWithoutDomain(it.url) it.url = getUrlWithoutDomain(it.url)
}
db.db.put()
.objects(nhentaiManga)
// Extremely slow without the resolver :/
.withPutResolver(MangaUrlPutResolver())
.prepare()
.executeAsBlocking()
} }
db.db.put()
.objects(nhentaiManga)
// Extremely slow without the resolver :/
.withPutResolver(MangaUrlPutResolver())
.prepare()
.executeAsBlocking()
} }
}
return true preferences.eh_lastVersionCode().set(CURRENT_MIGRATION_VERSION)
return true
}
} catch(e: Exception) {
logger.e( "Failed to migrate app from $oldVersion -> $CURRENT_MIGRATION_VERSION!", e)
} }
return false return false
} }

View File

@ -61,7 +61,8 @@ class GalleryAdder {
fun addGallery(url: String, fun addGallery(url: String,
fav: Boolean = false, fav: Boolean = false,
forceSource: Long? = null): GalleryAddEvent { forceSource: Long? = null,
throttleFunc: () -> Unit = {}): GalleryAddEvent {
XLog.d("Importing gallery (url: %s, fav: %s, forceSource: %s)...", url, fav, forceSource) XLog.d("Importing gallery (url: %s, fav: %s, forceSource: %s)...", url, fav, forceSource)
try { try {
val urlObj = Uri.parse(url) val urlObj = Uri.parse(url)
@ -167,7 +168,6 @@ class GalleryAdder {
// Fetch and copy details // Fetch and copy details
val newManga = sourceObj.fetchMangaDetails(manga).toBlocking().first() val newManga = sourceObj.fetchMangaDetails(manga).toBlocking().first()
manga.copyFrom(newManga) manga.copyFrom(newManga)
manga.title = newManga.title //Forcibly copy title as copyFrom does not copy title
manga.initialized = true manga.initialized = true
if (fav) manga.favorite = true if (fav) manga.favorite = true
@ -180,13 +180,13 @@ class GalleryAdder {
syncChaptersWithSource(db, it, manga, sourceObj) syncChaptersWithSource(db, it, manga, sourceObj)
}.toBlocking().first() }.toBlocking().first()
} catch (e: Exception) { } catch (e: Exception) {
XLog.w("Failed to update chapters for gallery: %s!", manga.title) XLog.w("Failed to update chapters for gallery: ${manga.title}!", e)
return GalleryAddEvent.Fail.Error(url, "Failed to update chapters for gallery: $url") return GalleryAddEvent.Fail.Error(url, "Failed to update chapters for gallery: $url")
} }
return GalleryAddEvent.Success(url, manga) return GalleryAddEvent.Success(url, manga)
} catch(e: Exception) { } catch(e: Exception) {
XLog.w("Could not add gallery!", e) XLog.w("Could not add gallery (url: $url)!", e)
if(e is EHentai.GalleryNotFoundException) { if(e is EHentai.GalleryNotFoundException) {
return GalleryAddEvent.Fail.NotFound(url) return GalleryAddEvent.Fail.NotFound(url)

View File

@ -1,5 +1,7 @@
package exh.debug package exh.debug
import android.app.Application
import android.os.Build
import com.pushtorefresh.storio.sqlite.queries.RawQuery import com.pushtorefresh.storio.sqlite.queries.RawQuery
import eu.kanade.tachiyomi.data.database.DatabaseHelper import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.tables.MangaTable import eu.kanade.tachiyomi.data.database.tables.MangaTable
@ -7,9 +9,11 @@ import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.source.SourceManager
import exh.EH_SOURCE_ID import exh.EH_SOURCE_ID
import exh.EXH_SOURCE_ID import exh.EXH_SOURCE_ID
import exh.eh.EHentaiUpdateWorker
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
object DebugFunctions { object DebugFunctions {
val app: Application by injectLazy()
val db: DatabaseHelper by injectLazy() val db: DatabaseHelper by injectLazy()
val prefs: PreferencesHelper by injectLazy() val prefs: PreferencesHelper by injectLazy()
val sourceManager: SourceManager by injectLazy() val sourceManager: SourceManager by injectLazy()
@ -48,6 +52,14 @@ object DebugFunctions {
fun convertAllExhentaiGalleriesToEhentai() = convertSources(EXH_SOURCE_ID, EH_SOURCE_ID) fun convertAllExhentaiGalleriesToEhentai() = convertSources(EXH_SOURCE_ID, EH_SOURCE_ID)
fun testLaunchBackgroundUpdater() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
EHentaiUpdateWorker.launchBackgroundTest(app)
} else {
error("OS/SDK version too old!")
}
}
private fun convertSources(from: Long, to: Long) { private fun convertSources(from: Long, to: Long) {
db.lowLevel().executeSQL(RawQuery.builder() db.lowLevel().executeSQL(RawQuery.builder()
.query(""" .query("""

View File

@ -0,0 +1,137 @@
package exh.eh
import android.content.Context
import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.ChapterImpl
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.database.models.MangaCategory
import rx.Observable
import rx.Single
import uy.kohesive.injekt.injectLazy
import java.io.File
data class ChapterChain(val manga: Manga, val chapters: List<Chapter>)
class EHentaiUpdateHelper(context: Context) {
val parentLookupTable =
MemAutoFlushingLookupTable(
File(context.filesDir, "exh-plt.maftable"),
GalleryEntry.Serializer()
)
private val db: DatabaseHelper by injectLazy()
/**
* @param chapters Cannot be an empty list!
*
* @return Pair<Accepted, Discarded>
*/
fun findAcceptedRootAndDiscardOthers(chapters: List<Chapter>): Single<Pair<ChapterChain, List<ChapterChain>>> {
// Find other chains
val chainsObservable = Observable.merge(chapters.map { chapter ->
db.getChapters(chapter.url).asRxSingle().toObservable()
}).toList().map { allChapters ->
allChapters.flatMap { innerChapters -> innerChapters.map { it.manga_id!! } }.distinct()
}.flatMap { mangaIds ->
Observable.merge(
mangaIds.map { mangaId ->
Single.zip(
db.getManga(mangaId).asRxSingle(),
db.getChaptersByMangaId(mangaId).asRxSingle()
) { manga, chapters ->
ChapterChain(manga, chapters)
}.toObservable()
}
)
}.toList()
// Accept oldest chain
val chainsWithAccepted = chainsObservable.map { chains ->
val acceptedChain = chains.minBy { it.manga.id!! }!!
acceptedChain to chains
}
return chainsWithAccepted.map { (accepted, chains) ->
val toDiscard = chains.filter { it.manga.favorite && it.manga.id != accepted.manga.id }
if(toDiscard.isNotEmpty()) {
// Copy chain chapters to curChapters
val newChapters = toDiscard
.flatMap { it.chapters }
.fold(accepted.chapters) { curChapters, chapter ->
val existing = curChapters.find { it.url == chapter.url }
if (existing != null) {
existing.read = existing.read || chapter.read
existing.last_page_read = existing.last_page_read.coerceAtLeast(chapter.last_page_read)
existing.bookmark = existing.bookmark || chapter.bookmark
curChapters
} else if (chapter.date_upload > 0) { // Ignore chapters using the old system
curChapters + ChapterImpl().apply {
manga_id = accepted.manga.id
url = chapter.url
name = chapter.name
read = chapter.read
bookmark = chapter.bookmark
last_page_read = chapter.last_page_read
date_fetch = chapter.date_fetch
date_upload = chapter.date_upload
}
} else curChapters
}
.filter { it.date_upload <= 0 } // Ignore chapters using the old system (filter after to prevent dupes from insert)
.sortedBy { it.date_upload }
.apply {
withIndex().map { (index, chapter) ->
chapter.name = "v${index + 1}: " + chapter.name.substringAfter(" ")
chapter.chapter_number = index + 1f
chapter.source_order = index
}
}
toDiscard.forEach { it.manga.favorite = false }
accepted.manga.favorite = true
val newAccepted = ChapterChain(accepted.manga, newChapters)
val rootsToMutate = toDiscard + newAccepted
db.inTransaction {
// Apply changes to all manga
db.insertMangas(rootsToMutate.map { it.manga }).executeAsBlocking()
// Insert new chapters for accepted manga
db.insertChapters(newAccepted.chapters)
// Copy categories from all chains to accepted manga
val newCategories = rootsToMutate.flatMap {
db.getCategoriesForManga(it.manga).executeAsBlocking()
}.distinctBy { it.id }.map {
MangaCategory.create(newAccepted.manga, it)
}
db.setMangaCategories(newCategories, rootsToMutate.map { it.manga })
}
newAccepted to toDiscard
} else accepted to emptyList()
}.toSingle()
}
}
data class GalleryEntry(val gId: String, val gToken: String) {
class Serializer: MemAutoFlushingLookupTable.EntrySerializer<GalleryEntry> {
/**
* Serialize an entry as a String.
*/
override fun write(entry: GalleryEntry) = with(entry) { "$gId:$gToken" }
/**
* Read an entry from a String.
*/
override fun read(string: String): GalleryEntry {
val colonIndex = string.indexOf(':')
return GalleryEntry(
string.substring(0, colonIndex),
string.substring(colonIndex + 1, string.length)
)
}
}
}

View File

@ -0,0 +1,351 @@
package exh.eh
import android.app.job.JobInfo
import android.app.job.JobParameters
import android.app.job.JobScheduler
import android.app.job.JobService
import android.content.ComponentName
import android.content.Context
import android.os.Build
import android.support.annotation.RequiresApi
import com.elvishew.xlog.XLog
import com.evernote.android.job.JobRequest
import com.google.gson.Gson
import com.kizitonwose.time.days
import com.kizitonwose.time.hours
import com.kizitonwose.time.minutes
import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.ChapterImpl
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.SChapter
import eu.kanade.tachiyomi.source.online.all.EHentai
import eu.kanade.tachiyomi.util.jobScheduler
import eu.kanade.tachiyomi.util.syncChaptersWithSource
import exh.EH_SOURCE_ID
import exh.EXH_SOURCE_ID
import exh.metadata.metadata.EHentaiSearchMetadata
import exh.metadata.metadata.base.*
import exh.metadata.sql.models.SearchMetadata
import exh.util.await
import exh.util.awaitSuspending
import exh.util.cancellable
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import rx.schedulers.Schedulers
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
import java.io.IOException
import kotlin.coroutines.CoroutineContext
@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
class EHentaiUpdateWorker: JobService(), CoroutineScope {
override val coroutineContext: CoroutineContext
get() = Dispatchers.Default + Job()
private val db: DatabaseHelper by injectLazy()
private val prefs: PreferencesHelper by injectLazy()
private val gson: Gson by injectLazy()
private val sourceManager: SourceManager by injectLazy()
private val updateHelper: EHentaiUpdateHelper by injectLazy()
private val logger = XLog.tag("EHUpdater")
/**
* This method is called if the system has determined that you must stop execution of your job
* even before you've had a chance to call [.jobFinished].
*
*
* This will happen if the requirements specified at schedule time are no longer met. For
* example you may have requested WiFi with
* [android.app.job.JobInfo.Builder.setRequiredNetworkType], yet while your
* job was executing the user toggled WiFi. Another example is if you had specified
* [android.app.job.JobInfo.Builder.setRequiresDeviceIdle], and the phone left its
* idle maintenance window. You are solely responsible for the behavior of your application
* upon receipt of this message; your app will likely start to misbehave if you ignore it.
*
*
* Once this method returns, the system releases the wakelock that it is holding on
* behalf of the job.
*
* @param params The parameters identifying this job, as supplied to
* the job in the [.onStartJob] callback.
* @return `true` to indicate to the JobManager whether you'd like to reschedule
* this job based on the retry criteria provided at job creation-time; or `false`
* to end the job entirely. Regardless of the value returned, your job must stop executing.
*/
override fun onStopJob(params: JobParameters?): Boolean {
runBlocking { coroutineContext[Job]?.cancelAndJoin() }
return false
}
/**
* Called to indicate that the job has begun executing. Override this method with the
* logic for your job. Like all other component lifecycle callbacks, this method executes
* on your application's main thread.
*
*
* Return `true` from this method if your job needs to continue running. If you
* do this, the job remains active until you call
* [.jobFinished] to tell the system that it has completed
* its work, or until the job's required constraints are no longer satisfied. For
* example, if the job was scheduled using
* [setRequiresCharging(true)][JobInfo.Builder.setRequiresCharging],
* it will be immediately halted by the system if the user unplugs the device from power,
* the job's [.onStopJob] callback will be invoked, and the app
* will be expected to shut down all ongoing work connected with that job.
*
*
* The system holds a wakelock on behalf of your app as long as your job is executing.
* This wakelock is acquired before this method is invoked, and is not released until either
* you call [.jobFinished], or after the system invokes
* [.onStopJob] to notify your job that it is being shut down
* prematurely.
*
*
* Returning `false` from this method means your job is already finished. The
* system's wakelock for the job will be released, and [.onStopJob]
* will not be invoked.
*
* @param params Parameters specifying info about this job, including the optional
* extras configured with [ This object serves to identify this specific running job instance when calling][JobInfo.Builder.setExtras]
*/
override fun onStartJob(params: JobParameters): Boolean {
launch {
startUpdating()
logger.d("Update job completed!")
jobFinished(params, false)
}
return true
}
suspend fun startUpdating() {
logger.d("Update job started!")
val startTime = System.currentTimeMillis()
logger.d("Finding manga with metadata...")
val metadataManga = db.getMangaWithMetadata().await()
logger.d("Filtering manga and raising metadata...")
val curTime = System.currentTimeMillis()
val allMeta = metadataManga.asFlow().cancellable().mapNotNull { manga ->
if (!manga.favorite || (manga.source != EH_SOURCE_ID && manga.source != EXH_SOURCE_ID))
return@mapNotNull null
val meta = db.getFlatMetadataForManga(manga.id!!).asRxSingle().await()
?: return@mapNotNull null
val raisedMeta = meta.raise<EHentaiSearchMetadata>()
// Don't update galleries too frequently
if (raisedMeta.aged || curTime - raisedMeta.lastUpdateCheck < MIN_BACKGROUND_UPDATE_FREQ)
return@mapNotNull null
val chapter = db.getChaptersByMangaId(manga.id!!).asRxSingle().await().minBy {
it.date_upload
}
UpdateEntry(manga, raisedMeta, chapter)
}.toList()
logger.d("Found %s manga to update, starting updates!", allMeta.size)
val mangaMetaToUpdateThisIter = allMeta.take(UPDATES_PER_ITERATION)
var failuresThisIteration = 0
var updatedThisIteration = 0
val modifiedThisIteration = mutableSetOf<Long>()
try {
for ((index, entry) in mangaMetaToUpdateThisIter.withIndex()) {
val (manga, meta) = entry
if (failuresThisIteration > MAX_UPDATE_FAILURES) {
logger.w("Too many update failures, aborting...")
break
}
logger.d("Updating gallery (index: %s, manga.id: %s, meta.gId: %s, meta.gToken: %s, failures-so-far: %s, modifiedThisIteration.size: %s)...",
index,
manga.id,
meta.gId,
meta.gToken,
failuresThisIteration,
modifiedThisIteration.size)
if (manga.id in modifiedThisIteration) {
// We already processed this manga!
logger.w("Gallery already updated this iteration, skipping...")
updatedThisIteration++
continue
}
val chapters = try {
updateEntryAndGetChapters(manga)
} catch (e: GalleryNotUpdatedException) {
if (e.network) {
failuresThisIteration++
logger.e("> Network error while updating gallery!", e)
logger.e("> (manga.id: %s, meta.gId: %s, meta.gToken: %s, failures-so-far: %s)",
manga.id,
meta.gId,
meta.gToken,
failuresThisIteration)
}
continue
}
if (chapters.isEmpty()) {
logger.e("No chapters found for gallery (manga.id: %s, meta.gId: %s, meta.gToken: %s, failures-so-far: %s)!",
manga.id,
meta.gId,
meta.gToken,
failuresThisIteration)
continue
}
// Find accepted root and discard others
val (acceptedRoot, discardedRoots) =
updateHelper.findAcceptedRootAndDiscardOthers(chapters).await()
modifiedThisIteration += acceptedRoot.manga.id!!
modifiedThisIteration += discardedRoots.map { it.manga.id!! }
updatedThisIteration++
}
} finally {
prefs.eh_autoUpdateStats().set(
gson.toJson(
EHentaiUpdaterStats(
startTime,
allMeta.size,
updatedThisIteration
)
)
)
}
}
suspend fun updateEntryAndGetChapters(manga: Manga): List<Chapter> {
val source = sourceManager.get(manga.source) as EHentai
try {
val updatedManga = source.fetchMangaDetails(manga).toSingle().await(Schedulers.io())
manga.copyFrom(updatedManga)
db.insertManga(manga).asRxSingle().await()
val newChapters = source.fetchChapterList(manga).toSingle().await(Schedulers.io())
syncChaptersWithSource(db, newChapters, manga, source) // Not suspending, but does block, maybe fix this?
return db.getChapters(manga).await()
} catch(t: Throwable) {
if(t is EHentai.GalleryNotFoundException) {
val meta = db.getFlatMetadataForManga(manga.id!!).await()?.raise<EHentaiSearchMetadata>()
if(meta != null) {
// Age dead galleries
meta.aged = true
db.insertFlatMetadata(meta.flatten()).await()
}
throw GalleryNotUpdatedException(false, t)
}
throw GalleryNotUpdatedException(true, t)
}
}
companion object {
const val UPDATES_PER_ITERATION = 50
private const val MAX_UPDATE_FAILURES = 5
private val MIN_BACKGROUND_UPDATE_FREQ = 1.days.inMilliseconds.longValue
val GALLERY_AGE_TIME = 365.days.inMilliseconds.longValue
private const val JOB_ID_UPDATE_BACKGROUND = 0
private const val JOB_ID_UPDATE_BACKGROUND_TEST = 1
private val logger by lazy { XLog.tag("EHUpdaterScheduler") }
private fun Context.componentName(): ComponentName {
return ComponentName(this, EHentaiUpdateWorker::class.java)
}
private fun Context.baseBackgroundJobInfo(isTest: Boolean): JobInfo.Builder {
return JobInfo.Builder(
if(isTest) JOB_ID_UPDATE_BACKGROUND_TEST
else JOB_ID_UPDATE_BACKGROUND, componentName())
.apply {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
setEstimatedNetworkBytes(15000L * UPDATES_PER_ITERATION,
1000L * UPDATES_PER_ITERATION)
}
}
}
private fun Context.periodicBackgroundJobInfo(period: Long,
requireCharging: Boolean,
requireUnmetered: Boolean): JobInfo {
return baseBackgroundJobInfo(false)
.setPeriodic(period)
.setPersisted(true)
.apply {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
setRequiresBatteryNotLow(true)
}
}
.setRequiresCharging(requireCharging)
.setRequiredNetworkType(
if(requireUnmetered) JobInfo.NETWORK_TYPE_UNMETERED
else JobInfo.NETWORK_TYPE_ANY)
.setRequiresDeviceIdle(true)
.build()
}
private fun Context.testBackgroundJobInfo(): JobInfo {
return baseBackgroundJobInfo(true)
.setOverrideDeadline(1)
.build()
}
fun launchBackgroundTest(context: Context) {
val jobScheduler = context.jobScheduler
if(jobScheduler.schedule(context.testBackgroundJobInfo()) == JobScheduler.RESULT_FAILURE) {
logger.e("Failed to schedule background test job!")
} else {
logger.d("Successfully scheduled background test job!")
}
}
fun scheduleBackground(context: Context, prefInterval: Int? = null) {
cancelBackground(context)
val preferences = Injekt.get<PreferencesHelper>()
val interval = prefInterval ?: preferences.eh_autoUpdateFrequency().getOrDefault()
if (interval > 0) {
val restrictions = preferences.eh_autoUpdateRequirements()!!
val acRestriction = "ac" in restrictions
val wifiRestriction = "wifi" in restrictions
val jobInfo = context.periodicBackgroundJobInfo(
interval.hours.inMilliseconds.longValue,
acRestriction,
wifiRestriction
)
if(context.jobScheduler.schedule(jobInfo) == JobScheduler.RESULT_FAILURE) {
logger.e("Failed to schedule background update job!")
} else {
logger.d("Successfully scheduled background update job!")
}
}
}
fun cancelBackground(context: Context) {
context.jobScheduler.cancel(JOB_ID_UPDATE_BACKGROUND)
}
}
}
data class UpdateEntry(val manga: Manga, val meta: EHentaiSearchMetadata, val rootChapter: Chapter?)

View File

@ -0,0 +1,7 @@
package exh.eh
data class EHentaiUpdaterStats(
val startTime: Long,
val possibleUpdates: Int,
val updateCount: Int
)

View File

@ -0,0 +1,3 @@
package exh.eh
class GalleryNotUpdatedException(val network: Boolean, cause: Throwable): RuntimeException(cause)

View File

@ -0,0 +1,214 @@
package exh.eh
import android.support.v4.util.AtomicFile
import android.util.SparseArray
import android.util.SparseIntArray
import com.elvishew.xlog.XLog
import exh.ui.captcha.SolveCaptchaActivity.Companion.launch
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.io.*
import java.nio.ByteBuffer
import kotlin.concurrent.thread
import kotlin.coroutines.CoroutineContext
/**
* In memory Int -> Obj lookup table implementation that
* automatically persists itself to disk atomically and asynchronously.
*
* Thread safe
*
* @author nulldev
*/
class MemAutoFlushingLookupTable<T>(
file: File,
private val serializer: EntrySerializer<T>,
private val debounceTimeMs: Long = 3000
) : CoroutineScope, Closeable {
/**
* The context of this scope.
* Context is encapsulated by the scope and used for implementation of coroutine builders that are extensions on the scope.
* Accessing this property in general code is not recommended for any purposes except accessing [Job] instance for advanced usages.
*
* By convention, should contain an instance of a [job][Job] to enforce structured concurrency.
*/
override val coroutineContext: CoroutineContext
get() = Dispatchers.IO + SupervisorJob()
private val table = SparseArray<T>(INITIAL_SIZE)
private val mutex = Mutex(true)
// Used to debounce
@Volatile
private var writeCounter = Long.MIN_VALUE
@Volatile
private var flushed = true
private val atomicFile = AtomicFile(file)
private val shutdownHook = thread(start = false) {
if(!flushed) writeSynchronously()
}
init {
initialLoad()
Runtime.getRuntime().addShutdownHook(shutdownHook)
}
private fun InputStream.requireBytes(targetArray: ByteArray, byteCount: Int): Boolean {
var readIter = 0
while (true) {
val readThisIter = read(targetArray, readIter, byteCount - readIter)
if(readThisIter <= 0) return false // No more data to read
readIter += readThisIter
if(readIter == byteCount) return true
}
}
private fun initialLoad() {
launch {
try {
atomicFile.openRead().buffered().use { input ->
val bb = ByteBuffer.allocate(8)
while(true) {
if(!input.requireBytes(bb.array(), 8)) break
val k = bb.getInt(0)
val size = bb.getInt(4)
val strBArr = ByteArray(size)
if(!input.requireBytes(strBArr, size)) break
table.put(k, serializer.read(strBArr.toString(Charsets.UTF_8)))
}
}
} catch(e: FileNotFoundException) {
XLog.d("Lookup table not found!", e)
// Ignored
}
mutex.unlock()
}
}
private fun tryWrite() {
val id = ++writeCounter
flushed = false
launch {
delay(debounceTimeMs)
if(id != writeCounter) return@launch
mutex.withLock {
// Second check inside of mutex to prevent dupe writes
if(id != writeCounter) return@launch
withContext(NonCancellable) {
writeSynchronously()
// Yes there is a race here, no it's isn't critical
if (id == writeCounter) flushed = true
}
}
}
}
private fun writeSynchronously() {
val bb = ByteBuffer.allocate(ENTRY_SIZE_BYTES)
val fos = atomicFile.startWrite()
try {
val out = fos.buffered()
for(i in 0 until table.size()) {
val k = table.keyAt(i)
val v = serializer.write(table.valueAt(i)).toByteArray(Charsets.UTF_8)
bb.putInt(0, k)
bb.putInt(4, v.size)
out.write(bb.array())
out.write(v)
}
out.flush()
atomicFile.finishWrite(fos)
} catch(t: Throwable) {
atomicFile.failWrite(fos)
throw t
}
}
suspend fun put(key: Int, value: T) {
mutex.withLock { table.put(key, value) }
tryWrite()
}
suspend fun get(key: Int): T? {
return mutex.withLock { table.get(key) }
}
suspend fun size(): Int {
return mutex.withLock { table.size() }
}
/**
* Closes this resource, relinquishing any underlying resources.
* This method is invoked automatically on objects managed by the
* `try`-with-resources statement.
*
*
* While this interface method is declared to throw `Exception`, implementers are *strongly* encouraged to
* declare concrete implementations of the `close` method to
* throw more specific exceptions, or to throw no exception at all
* if the close operation cannot fail.
*
*
* Cases where the close operation may fail require careful
* attention by implementers. It is strongly advised to relinquish
* the underlying resources and to internally *mark* the
* resource as closed, prior to throwing the exception. The `close` method is unlikely to be invoked more than once and so
* this ensures that the resources are released in a timely manner.
* Furthermore it reduces problems that could arise when the resource
* wraps, or is wrapped, by another resource.
*
*
* *Implementers of this interface are also strongly advised
* to not have the `close` method throw [ ].*
*
* This exception interacts with a thread's interrupted status,
* and runtime misbehavior is likely to occur if an `InterruptedException` is [ suppressed][Throwable.addSuppressed].
*
* More generally, if it would cause problems for an
* exception to be suppressed, the `AutoCloseable.close`
* method should not throw it.
*
*
* Note that unlike the [close][java.io.Closeable.close]
* method of [java.io.Closeable], this `close` method
* is *not* required to be idempotent. In other words,
* calling this `close` method more than once may have some
* visible side effect, unlike `Closeable.close` which is
* required to have no effect if called more than once.
*
* However, implementers of this interface are strongly encouraged
* to make their `close` methods idempotent.
*
* @throws Exception if this resource cannot be closed
*/
override fun close() {
runBlocking { coroutineContext[Job]?.cancelAndJoin() }
Runtime.getRuntime().removeShutdownHook(shutdownHook)
}
interface EntrySerializer<T> {
/**
* Serialize an entry as a String.
*/
fun write(entry: T): String
/**
* Read an entry from a String.
*/
fun read(string: String): T
}
companion object {
private const val INITIAL_SIZE = 1000
private const val ENTRY_SIZE_BYTES = 8
}
}

View File

@ -17,6 +17,7 @@ import eu.kanade.tachiyomi.util.powerManager
import eu.kanade.tachiyomi.util.toast import eu.kanade.tachiyomi.util.toast
import eu.kanade.tachiyomi.util.wifiManager import eu.kanade.tachiyomi.util.wifiManager
import exh.* import exh.*
import exh.eh.EHentaiUpdateWorker
import exh.util.ignore import exh.util.ignore
import exh.util.trans import exh.util.trans
import okhttp3.FormBody import okhttp3.FormBody
@ -112,6 +113,9 @@ class FavoritesSyncHelper(val context: Context) {
"teh:ExhFavoritesSyncWifi") "teh:ExhFavoritesSyncWifi")
} }
// Do not update galleries while syncing favorites
EHentaiUpdateWorker.cancelBackground(context)
storage.getRealm().use { realm -> storage.getRealm().use { realm ->
realm.trans { realm.trans {
db.inTransaction { db.inTransaction {
@ -161,6 +165,9 @@ class FavoritesSyncHelper(val context: Context) {
wifiLock?.release() wifiLock?.release()
wifiLock = null wifiLock = null
} }
// Update galleries again!
EHentaiUpdateWorker.scheduleBackground(context)
} }
if(errorList.isEmpty()) if(errorList.isEmpty())
@ -338,7 +345,8 @@ class FavoritesSyncHelper(val context: Context) {
//Import using gallery adder //Import using gallery adder
val result = galleryAdder.addGallery("${exh.baseUrl}${it.getUrl()}", val result = galleryAdder.addGallery("${exh.baseUrl}${it.getUrl()}",
true, true,
EXH_SOURCE_ID) EXH_SOURCE_ID,
::throttle)
if(result is GalleryAddEvent.Fail) { if(result is GalleryAddEvent.Fail) {
if(result is GalleryAddEvent.Fail.NotFound) { if(result is GalleryAddEvent.Fail.NotFound) {
@ -396,7 +404,7 @@ class FavoritesSyncHelper(val context: Context) {
class IgnoredException : RuntimeException() class IgnoredException : RuntimeException()
companion object { companion object {
private const val THROTTLE_MAX = 4500 private const val THROTTLE_MAX = 5500
private const val THROTTLE_INC = 10 private const val THROTTLE_INC = 10
private const val THROTTLE_WARN = 1000 private const val THROTTLE_WARN = 1000
} }

View File

@ -36,6 +36,9 @@ class EHentaiSearchMetadata : RaisedSearchMetadata() {
var ratingCount: Int? = null var ratingCount: Int? = null
var averageRating: Double? = null var averageRating: Double? = null
var aged: Boolean = false
var lastUpdateCheck: Long = 0
override fun copyTo(manga: SManga) { override fun copyTo(manga: SManga) {
gId?.let { gId -> gId?.let { gId ->
gToken?.let { gToken -> gToken?.let { gToken ->

View File

@ -25,7 +25,7 @@ data class FlatMetadata(
fun DatabaseHelper.getFlatMetadataForManga(mangaId: Long): PreparedOperation<FlatMetadata?> { fun DatabaseHelper.getFlatMetadataForManga(mangaId: Long): PreparedOperation<FlatMetadata?> {
// We have to use fromCallable because StorIO messes up the thread scheduling if we use their rx functions // We have to use fromCallable because StorIO messes up the thread scheduling if we use their rx functions
fun getSingle() = Single.fromCallable { val single = Single.fromCallable {
val meta = getSearchMetadataForManga(mangaId).executeAsBlocking() val meta = getSearchMetadataForManga(mangaId).executeAsBlocking()
if(meta != null) { if(meta != null) {
val tags = getSearchTagsForManga(mangaId).executeAsBlocking() val tags = getSearchTagsForManga(mangaId).executeAsBlocking()
@ -35,7 +35,11 @@ fun DatabaseHelper.getFlatMetadataForManga(mangaId: Long): PreparedOperation<Fla
} else null } else null
} }
return object : PreparedOperation<FlatMetadata?> { return preparedOperationFromSingle(single)
}
private fun <T> preparedOperationFromSingle(single: Single<T>): PreparedOperation<T> {
return object : PreparedOperation<T> {
/** /**
* Creates [rx.Observable] that emits result of Operation. * Creates [rx.Observable] that emits result of Operation.
* *
@ -44,7 +48,7 @@ fun DatabaseHelper.getFlatMetadataForManga(mangaId: Long): PreparedOperation<Fla
* *
* @return observable result of operation with only one [rx.Observer.onNext] call. * @return observable result of operation with only one [rx.Observer.onNext] call.
*/ */
override fun createObservable() = getSingle().toObservable() override fun createObservable() = single.toObservable()
/** /**
* Executes operation synchronously in current thread. * Executes operation synchronously in current thread.
@ -57,7 +61,7 @@ fun DatabaseHelper.getFlatMetadataForManga(mangaId: Long): PreparedOperation<Fla
* *
* @return nullable result of operation. * @return nullable result of operation.
*/ */
override fun executeAsBlocking() = getSingle().toBlocking().value() override fun executeAsBlocking() = single.toBlocking().value()
/** /**
* Creates [rx.Observable] that emits result of Operation. * Creates [rx.Observable] that emits result of Operation.
@ -67,7 +71,7 @@ fun DatabaseHelper.getFlatMetadataForManga(mangaId: Long): PreparedOperation<Fla
* *
* @return observable result of operation with only one [rx.Observer.onNext] call. * @return observable result of operation with only one [rx.Observer.onNext] call.
*/ */
override fun asRxObservable() = getSingle().toObservable() override fun asRxObservable() = single.toObservable()
/** /**
* Creates [rx.Single] that emits result of Operation lazily when somebody subscribes to it. * Creates [rx.Single] that emits result of Operation lazily when somebody subscribes to it.
@ -76,8 +80,7 @@ fun DatabaseHelper.getFlatMetadataForManga(mangaId: Long): PreparedOperation<Fla
* *
* @return single result of operation. * @return single result of operation.
*/ */
override fun asRxSingle() = getSingle() override fun asRxSingle() = single
} }
} }

View File

@ -25,6 +25,15 @@ interface SearchMetadataQueries : DbProvider {
.build()) .build())
.prepare() .prepare()
fun getSearchMetadataByIndexedExtra(extra: String) = db.get()
.listOfObjects(SearchMetadata::class.java)
.withQuery(Query.builder()
.table(SearchMetadataTable.TABLE)
.where("${SearchMetadataTable.COL_INDEXED_EXTRA} = ?")
.whereArgs(extra)
.build())
.prepare()
fun insertSearchMetadata(metadata: SearchMetadata) = db.put().`object`(metadata).prepare() fun insertSearchMetadata(metadata: SearchMetadata) = db.put().`object`(metadata).prepare()
fun deleteSearchMetadata(metadata: SearchMetadata) = db.delete().`object`(metadata).prepare() fun deleteSearchMetadata(metadata: SearchMetadata) = db.delete().`object`(metadata).prepare()

View File

@ -1,8 +1,11 @@
package exh.util package exh.util
import rx.Observable import com.pushtorefresh.storio.operations.PreparedOperation
import rx.Single import com.pushtorefresh.storio.sqlite.operations.get.PreparedGetObject
import kotlinx.coroutines.suspendCancellableCoroutine
import rx.*
import rx.subjects.ReplaySubject import rx.subjects.ReplaySubject
import kotlin.coroutines.resumeWithException
/** /**
* Transform a cold single to a hot single * Transform a cold single to a hot single
@ -24,4 +27,45 @@ fun <T> Observable<T>.melt(): Observable<T> {
val rs = ReplaySubject.create<T>() val rs = ReplaySubject.create<T>()
subscribe(rs) subscribe(rs)
return rs return rs
} }
suspend fun <T> Single<T>.await(subscribeOn: Scheduler? = null): T {
return suspendCancellableCoroutine { continuation ->
val self = if (subscribeOn != null) subscribeOn(subscribeOn) else this
lateinit var sub: Subscription
sub = self.subscribe({
continuation.resume(it) {
sub.unsubscribe()
}
}, {
if (!continuation.isCancelled)
continuation.resumeWithException(it)
})
continuation.invokeOnCancellation {
sub.unsubscribe()
}
}
}
suspend fun <T> PreparedOperation<T>.await(): T = asRxSingle().await()
suspend fun <T> PreparedGetObject<T>.await(): T? = asRxSingle().await()
suspend fun Completable.awaitSuspending(subscribeOn: Scheduler? = null) {
return suspendCancellableCoroutine { continuation ->
val self = if (subscribeOn != null) subscribeOn(subscribeOn) else this
lateinit var sub: Subscription
sub = self.subscribe({
continuation.resume(Unit) {
sub.unsubscribe()
}
}, {
if (!continuation.isCancelled)
continuation.resumeWithException(it)
})
continuation.invokeOnCancellation {
sub.unsubscribe()
}
}
}