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

@@ -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
}
}