
Add login dialog that pops up whenever you are not logged in when trying to browse MangaDex Remove attempts at porting over chapter read history from older galleries to new ones Disable latest for ExHentai, it was browse without buttons anyway
391 lines
16 KiB
Kotlin
391 lines
16 KiB
Kotlin
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 com.elvishew.xlog.XLog
|
|
import com.google.gson.Gson
|
|
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.data.library.LibraryUpdateNotifier
|
|
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
|
import eu.kanade.tachiyomi.source.SourceManager
|
|
import eu.kanade.tachiyomi.source.online.all.EHentai
|
|
import eu.kanade.tachiyomi.util.chapter.syncChaptersWithSource
|
|
import exh.EH_SOURCE_ID
|
|
import exh.EXH_SOURCE_ID
|
|
import exh.debug.DebugToggles
|
|
import exh.eh.EHentaiUpdateWorkerConstants.UPDATES_PER_ITERATION
|
|
import exh.metadata.metadata.EHentaiSearchMetadata
|
|
import exh.metadata.metadata.base.getFlatMetadataForManga
|
|
import exh.metadata.metadata.base.insertFlatMetadata
|
|
import exh.util.await
|
|
import exh.util.cancellable
|
|
import exh.util.jobScheduler
|
|
import java.util.ArrayList
|
|
import kotlin.coroutines.CoroutineContext
|
|
import kotlin.time.ExperimentalTime
|
|
import kotlin.time.days
|
|
import kotlin.time.hours
|
|
import kotlinx.coroutines.CoroutineScope
|
|
import kotlinx.coroutines.Dispatchers
|
|
import kotlinx.coroutines.Job
|
|
import kotlinx.coroutines.cancelAndJoin
|
|
import kotlinx.coroutines.flow.asFlow
|
|
import kotlinx.coroutines.flow.mapNotNull
|
|
import kotlinx.coroutines.flow.toList
|
|
import kotlinx.coroutines.launch
|
|
import kotlinx.coroutines.runBlocking
|
|
import rx.schedulers.Schedulers
|
|
import uy.kohesive.injekt.Injekt
|
|
import uy.kohesive.injekt.api.get
|
|
import uy.kohesive.injekt.injectLazy
|
|
|
|
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")
|
|
|
|
private val updateNotifier by lazy { LibraryUpdateNotifier(this) }
|
|
|
|
/**
|
|
* 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 { this@EHentaiUpdateWorker.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
|
|
}
|
|
|
|
private suspend fun startUpdating() {
|
|
logger.d("Update job started!")
|
|
val startTime = System.currentTimeMillis()
|
|
|
|
logger.d("Finding manga with metadata...")
|
|
val metadataManga = db.getFavoriteMangaWithMetadata().await()
|
|
|
|
logger.d("Filtering manga and raising metadata...")
|
|
val curTime = System.currentTimeMillis()
|
|
val allMeta = metadataManga.asFlow().cancellable().mapNotNull { manga ->
|
|
if (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 && DebugToggles.RESTRICT_EXH_GALLERY_UPDATE_CHECK_FREQUENCY.enabled)) {
|
|
return@mapNotNull null
|
|
}
|
|
|
|
val chapter = db.getChapters(manga.id!!).asRxSingle().await().minByOrNull {
|
|
it.date_upload
|
|
}
|
|
|
|
UpdateEntry(manga, raisedMeta, chapter)
|
|
}.toList().sortedBy { it.meta.lastUpdateCheck }
|
|
|
|
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 updatedManga = ArrayList<Pair<Manga, Array<Chapter>>>()
|
|
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 (new, 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, hasNew) =
|
|
updateHelper.findAcceptedRootAndDiscardOthers(manga.source, chapters).await()
|
|
|
|
if ((new.isNotEmpty() && manga.id == acceptedRoot.manga.id) ||
|
|
(hasNew && updatedManga.none { it.first.id == acceptedRoot.manga.id })
|
|
) {
|
|
updatedManga += Pair(acceptedRoot.manga, new.toTypedArray())
|
|
}
|
|
|
|
modifiedThisIteration += acceptedRoot.manga.id!!
|
|
modifiedThisIteration += discardedRoots.map { it.manga.id!! }
|
|
updatedThisIteration++
|
|
}
|
|
} finally {
|
|
prefs.eh_autoUpdateStats().set(
|
|
gson.toJson(
|
|
EHentaiUpdaterStats(
|
|
startTime,
|
|
allMeta.size,
|
|
updatedThisIteration
|
|
)
|
|
)
|
|
)
|
|
|
|
if (updatedManga.isNotEmpty()) {
|
|
updateNotifier.showUpdateNotifications(updatedManga)
|
|
}
|
|
}
|
|
}
|
|
|
|
// New, current
|
|
private suspend fun updateEntryAndGetChapters(manga: Manga): Pair<List<Chapter>, List<Chapter>> {
|
|
val source = sourceManager.get(manga.source) as? EHentai
|
|
?: throw GalleryNotUpdatedException(false, IllegalStateException("Missing EH-based source (${manga.source})!"))
|
|
|
|
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())
|
|
val (new, _) = syncChaptersWithSource(db, newChapters, manga, source) // Not suspending, but does block, maybe fix this?
|
|
return new to 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
|
|
logger.d("Aged %s - notfound", manga.id)
|
|
meta.aged = true
|
|
db.insertFlatMetadata(meta.flatten()).await()
|
|
}
|
|
throw GalleryNotUpdatedException(false, t)
|
|
}
|
|
throw GalleryNotUpdatedException(true, t)
|
|
}
|
|
}
|
|
|
|
companion object {
|
|
private const val MAX_UPDATE_FAILURES = 5
|
|
|
|
@OptIn(ExperimentalTime::class)
|
|
private val MIN_BACKGROUND_UPDATE_FREQ = 1.days.toLongMilliseconds()
|
|
|
|
private const val JOB_ID_UPDATE_BACKGROUND = 700000
|
|
private const val JOB_ID_UPDATE_BACKGROUND_TEST = 700001
|
|
|
|
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()
|
|
)
|
|
}
|
|
|
|
private fun Context.periodicBackgroundJobInfo(
|
|
period: Long,
|
|
requireCharging: Boolean,
|
|
requireUnmetered: Boolean
|
|
): JobInfo {
|
|
return baseBackgroundJobInfo(false)
|
|
.setPeriodic(period)
|
|
.setPersisted(true)
|
|
.setRequiredNetworkType(
|
|
if (requireUnmetered) JobInfo.NETWORK_TYPE_UNMETERED
|
|
else JobInfo.NETWORK_TYPE_ANY
|
|
)
|
|
.apply {
|
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
setRequiresBatteryNotLow(true)
|
|
}
|
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
|
setEstimatedNetworkBytes(
|
|
15000L * UPDATES_PER_ITERATION,
|
|
1000L * UPDATES_PER_ITERATION
|
|
)
|
|
}
|
|
}
|
|
.setRequiresCharging(requireCharging)
|
|
// .setRequiresDeviceIdle(true) Job never seems to run with this
|
|
.build()
|
|
}
|
|
|
|
private fun Context.testBackgroundJobInfo(): JobInfo {
|
|
return baseBackgroundJobInfo(true)
|
|
.setOverrideDeadline(1)
|
|
.build()
|
|
}
|
|
|
|
fun launchBackgroundTest(context: Context): String {
|
|
val jobScheduler = context.jobScheduler
|
|
return if (jobScheduler.schedule(context.testBackgroundJobInfo()) == JobScheduler.RESULT_FAILURE) {
|
|
logger.e("Failed to schedule background test job!")
|
|
"Failed"
|
|
} else {
|
|
logger.d("Successfully scheduled background test job!")
|
|
"Success"
|
|
}
|
|
}
|
|
|
|
fun scheduleBackground(context: Context, prefInterval: Int? = null) {
|
|
cancelBackground(context)
|
|
|
|
val preferences = Injekt.get<PreferencesHelper>()
|
|
val duration = prefInterval ?: preferences.eh_autoUpdateFrequency().get()
|
|
if (duration > 0) {
|
|
val restrictions = preferences.eh_autoUpdateRequirements()!!
|
|
val acRestriction = "ac" in restrictions
|
|
val wifiRestriction = "wifi" in restrictions
|
|
|
|
@OptIn(ExperimentalTime::class)
|
|
val jobInfo = context.periodicBackgroundJobInfo(
|
|
duration.hours.toLongMilliseconds(),
|
|
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?)
|
|
|
|
object EHentaiUpdateWorkerConstants {
|
|
const val UPDATES_PER_ITERATION = 50
|
|
@OptIn(ExperimentalTime::class)
|
|
val GALLERY_AGE_TIME = 365.days.toLongMilliseconds()
|
|
}
|