|
@@ -1,44 +1,551 @@
|
|
|
package eu.kanade.tachiyomi.data.library
|
|
|
|
|
|
import android.content.Context
|
|
|
+import androidx.work.BackoffPolicy
|
|
|
import androidx.work.Constraints
|
|
|
+import androidx.work.CoroutineWorker
|
|
|
import androidx.work.ExistingPeriodicWorkPolicy
|
|
|
+import androidx.work.ExistingWorkPolicy
|
|
|
+import androidx.work.ForegroundInfo
|
|
|
import androidx.work.NetworkType
|
|
|
+import androidx.work.OneTimeWorkRequestBuilder
|
|
|
import androidx.work.PeriodicWorkRequestBuilder
|
|
|
+import androidx.work.WorkInfo
|
|
|
import androidx.work.WorkManager
|
|
|
-import androidx.work.Worker
|
|
|
+import androidx.work.WorkQuery
|
|
|
import androidx.work.WorkerParameters
|
|
|
+import androidx.work.workDataOf
|
|
|
+import eu.kanade.domain.chapter.interactor.GetChapterByMangaId
|
|
|
+import eu.kanade.domain.chapter.interactor.SyncChaptersWithSource
|
|
|
+import eu.kanade.domain.chapter.interactor.SyncChaptersWithTrackServiceTwoWay
|
|
|
+import eu.kanade.domain.download.service.DownloadPreferences
|
|
|
import eu.kanade.domain.library.service.LibraryPreferences
|
|
|
+import eu.kanade.domain.manga.interactor.GetLibraryManga
|
|
|
+import eu.kanade.domain.manga.interactor.GetManga
|
|
|
+import eu.kanade.domain.manga.interactor.UpdateManga
|
|
|
+import eu.kanade.domain.manga.model.copyFrom
|
|
|
+import eu.kanade.domain.manga.model.toSManga
|
|
|
+import eu.kanade.domain.track.interactor.GetTracks
|
|
|
+import eu.kanade.domain.track.interactor.InsertTrack
|
|
|
+import eu.kanade.domain.track.model.toDbTrack
|
|
|
+import eu.kanade.domain.track.model.toDomainTrack
|
|
|
+import eu.kanade.tachiyomi.R
|
|
|
+import eu.kanade.tachiyomi.data.cache.CoverCache
|
|
|
+import eu.kanade.tachiyomi.data.download.DownloadManager
|
|
|
+import eu.kanade.tachiyomi.data.download.DownloadService
|
|
|
+import eu.kanade.tachiyomi.data.notification.Notifications
|
|
|
import eu.kanade.tachiyomi.data.preference.DEVICE_BATTERY_NOT_LOW
|
|
|
import eu.kanade.tachiyomi.data.preference.DEVICE_CHARGING
|
|
|
import eu.kanade.tachiyomi.data.preference.DEVICE_NETWORK_NOT_METERED
|
|
|
import eu.kanade.tachiyomi.data.preference.DEVICE_ONLY_ON_WIFI
|
|
|
+import eu.kanade.tachiyomi.data.preference.MANGA_HAS_UNREAD
|
|
|
+import eu.kanade.tachiyomi.data.preference.MANGA_NON_COMPLETED
|
|
|
+import eu.kanade.tachiyomi.data.preference.MANGA_NON_READ
|
|
|
+import eu.kanade.tachiyomi.data.track.EnhancedTrackService
|
|
|
+import eu.kanade.tachiyomi.data.track.TrackManager
|
|
|
+import eu.kanade.tachiyomi.data.track.TrackService
|
|
|
+import eu.kanade.tachiyomi.source.SourceManager
|
|
|
+import eu.kanade.tachiyomi.source.UnmeteredSource
|
|
|
+import eu.kanade.tachiyomi.source.model.SManga
|
|
|
+import eu.kanade.tachiyomi.source.model.UpdateStrategy
|
|
|
+import eu.kanade.tachiyomi.util.prepUpdateCover
|
|
|
+import eu.kanade.tachiyomi.util.shouldDownloadNewChapters
|
|
|
+import eu.kanade.tachiyomi.util.storage.getUriCompat
|
|
|
+import eu.kanade.tachiyomi.util.system.createFileInCacheDir
|
|
|
import eu.kanade.tachiyomi.util.system.isConnectedToWifi
|
|
|
+import kotlinx.coroutines.CancellationException
|
|
|
+import kotlinx.coroutines.Dispatchers
|
|
|
+import kotlinx.coroutines.async
|
|
|
+import kotlinx.coroutines.awaitAll
|
|
|
+import kotlinx.coroutines.coroutineScope
|
|
|
+import kotlinx.coroutines.ensureActive
|
|
|
+import kotlinx.coroutines.runBlocking
|
|
|
+import kotlinx.coroutines.supervisorScope
|
|
|
+import kotlinx.coroutines.sync.Semaphore
|
|
|
+import kotlinx.coroutines.sync.withPermit
|
|
|
+import kotlinx.coroutines.withContext
|
|
|
+import logcat.LogPriority
|
|
|
+import tachiyomi.core.preference.getAndSet
|
|
|
+import tachiyomi.core.util.lang.withIOContext
|
|
|
+import tachiyomi.core.util.system.logcat
|
|
|
+import tachiyomi.domain.category.interactor.GetCategories
|
|
|
+import tachiyomi.domain.category.model.Category
|
|
|
+import tachiyomi.domain.chapter.model.Chapter
|
|
|
+import tachiyomi.domain.chapter.model.NoChaptersException
|
|
|
+import tachiyomi.domain.library.model.LibraryManga
|
|
|
+import tachiyomi.domain.manga.model.Manga
|
|
|
+import tachiyomi.domain.manga.model.toMangaUpdate
|
|
|
import uy.kohesive.injekt.Injekt
|
|
|
import uy.kohesive.injekt.api.get
|
|
|
+import java.io.File
|
|
|
+import java.util.Date
|
|
|
+import java.util.concurrent.CopyOnWriteArrayList
|
|
|
import java.util.concurrent.TimeUnit
|
|
|
+import java.util.concurrent.atomic.AtomicBoolean
|
|
|
+import java.util.concurrent.atomic.AtomicInteger
|
|
|
|
|
|
class LibraryUpdateJob(private val context: Context, workerParams: WorkerParameters) :
|
|
|
- Worker(context, workerParams) {
|
|
|
+ CoroutineWorker(context, workerParams) {
|
|
|
|
|
|
- override fun doWork(): Result {
|
|
|
+ private val sourceManager: SourceManager = Injekt.get()
|
|
|
+ private val downloadPreferences: DownloadPreferences = Injekt.get()
|
|
|
+ private val libraryPreferences: LibraryPreferences = Injekt.get()
|
|
|
+ private val downloadManager: DownloadManager = Injekt.get()
|
|
|
+ private val trackManager: TrackManager = Injekt.get()
|
|
|
+ private val coverCache: CoverCache = Injekt.get()
|
|
|
+ private val getLibraryManga: GetLibraryManga = Injekt.get()
|
|
|
+ private val getManga: GetManga = Injekt.get()
|
|
|
+ private val updateManga: UpdateManga = Injekt.get()
|
|
|
+ private val getChapterByMangaId: GetChapterByMangaId = Injekt.get()
|
|
|
+ private val getCategories: GetCategories = Injekt.get()
|
|
|
+ private val syncChaptersWithSource: SyncChaptersWithSource = Injekt.get()
|
|
|
+ private val getTracks: GetTracks = Injekt.get()
|
|
|
+ private val insertTrack: InsertTrack = Injekt.get()
|
|
|
+ private val syncChaptersWithTrackServiceTwoWay: SyncChaptersWithTrackServiceTwoWay = Injekt.get()
|
|
|
+
|
|
|
+ private val notifier = LibraryUpdateNotifier(context)
|
|
|
+
|
|
|
+ private var mangaToUpdate: List<LibraryManga> = mutableListOf()
|
|
|
+
|
|
|
+ override suspend fun doWork(): Result {
|
|
|
val preferences = Injekt.get<LibraryPreferences>()
|
|
|
val restrictions = preferences.libraryUpdateDeviceRestriction().get()
|
|
|
if ((DEVICE_ONLY_ON_WIFI in restrictions) && !context.isConnectedToWifi()) {
|
|
|
return Result.failure()
|
|
|
}
|
|
|
|
|
|
- return if (LibraryUpdateService.start(context)) {
|
|
|
- Result.success()
|
|
|
+ if (tags.contains(WORK_NAME_AUTO)) {
|
|
|
+ // Find a running manual worker. If exists, try again later
|
|
|
+ val otherRunningWorker = withContext(Dispatchers.IO) {
|
|
|
+ WorkManager.getInstance(context)
|
|
|
+ .getWorkInfosByTag(WORK_NAME_MANUAL)
|
|
|
+ .get()
|
|
|
+ .find { it.state == WorkInfo.State.RUNNING }
|
|
|
+ }
|
|
|
+ if (otherRunningWorker != null) {
|
|
|
+ return Result.retry()
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ setForeground(getForegroundInfo())
|
|
|
+ } catch (e: IllegalStateException) {
|
|
|
+ logcat(LogPriority.ERROR, e) { "Not allowed to set foreground job" }
|
|
|
+ }
|
|
|
+
|
|
|
+ val target = inputData.getString(KEY_TARGET)?.let { Target.valueOf(it) } ?: Target.CHAPTERS
|
|
|
+
|
|
|
+ // If this is a chapter update; set the last update time to now
|
|
|
+ if (target == Target.CHAPTERS) {
|
|
|
+ libraryPreferences.libraryUpdateLastTimestamp().set(Date().time)
|
|
|
+ }
|
|
|
+
|
|
|
+ val categoryId = inputData.getLong(KEY_CATEGORY, -1L)
|
|
|
+ addMangaToQueue(categoryId)
|
|
|
+
|
|
|
+ return withIOContext {
|
|
|
+ try {
|
|
|
+ when (target) {
|
|
|
+ Target.CHAPTERS -> updateChapterList()
|
|
|
+ Target.COVERS -> updateCovers()
|
|
|
+ Target.TRACKING -> updateTrackings()
|
|
|
+ }
|
|
|
+ Result.success()
|
|
|
+ } catch (e: Exception) {
|
|
|
+ if (e is CancellationException) {
|
|
|
+ // Assume success although cancelled
|
|
|
+ Result.success()
|
|
|
+ } else {
|
|
|
+ logcat(LogPriority.ERROR, e)
|
|
|
+ Result.failure()
|
|
|
+ }
|
|
|
+ } finally {
|
|
|
+ notifier.cancelProgressNotification()
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ override suspend fun getForegroundInfo(): ForegroundInfo {
|
|
|
+ val notifier = LibraryUpdateNotifier(context)
|
|
|
+ return ForegroundInfo(Notifications.ID_LIBRARY_PROGRESS, notifier.progressNotificationBuilder.build())
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Adds list of manga to be updated.
|
|
|
+ *
|
|
|
+ * @param categoryId the ID of the category to update, or -1 if no category specified.
|
|
|
+ */
|
|
|
+ private fun addMangaToQueue(categoryId: Long) {
|
|
|
+ val libraryManga = runBlocking { getLibraryManga.await() }
|
|
|
+
|
|
|
+ val listToUpdate = if (categoryId != -1L) {
|
|
|
+ libraryManga.filter { it.category == categoryId }
|
|
|
} else {
|
|
|
- Result.failure()
|
|
|
+ val categoriesToUpdate = libraryPreferences.libraryUpdateCategories().get().map { it.toLong() }
|
|
|
+ val includedManga = if (categoriesToUpdate.isNotEmpty()) {
|
|
|
+ libraryManga.filter { it.category in categoriesToUpdate }
|
|
|
+ } else {
|
|
|
+ libraryManga
|
|
|
+ }
|
|
|
+
|
|
|
+ val categoriesToExclude = libraryPreferences.libraryUpdateCategoriesExclude().get().map { it.toLong() }
|
|
|
+ val excludedMangaIds = if (categoriesToExclude.isNotEmpty()) {
|
|
|
+ libraryManga.filter { it.category in categoriesToExclude }.map { it.manga.id }
|
|
|
+ } else {
|
|
|
+ emptyList()
|
|
|
+ }
|
|
|
+
|
|
|
+ includedManga
|
|
|
+ .filterNot { it.manga.id in excludedMangaIds }
|
|
|
+ .distinctBy { it.manga.id }
|
|
|
+ }
|
|
|
+
|
|
|
+ mangaToUpdate = listToUpdate
|
|
|
+ .sortedBy { it.manga.title }
|
|
|
+
|
|
|
+ // Warn when excessively checking a single source
|
|
|
+ val maxUpdatesFromSource = mangaToUpdate
|
|
|
+ .groupBy { it.manga.source }
|
|
|
+ .filterKeys { sourceManager.get(it) !is UnmeteredSource }
|
|
|
+ .maxOfOrNull { it.value.size } ?: 0
|
|
|
+ if (maxUpdatesFromSource > MANGA_PER_SOURCE_QUEUE_WARNING_THRESHOLD) {
|
|
|
+ notifier.showQueueSizeWarningNotification()
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Method that updates manga in [mangaToUpdate]. It's called in a background thread, so it's safe
|
|
|
+ * to do heavy operations or network calls here.
|
|
|
+ * For each manga it calls [updateManga] and updates the notification showing the current
|
|
|
+ * progress.
|
|
|
+ *
|
|
|
+ * @return an observable delivering the progress of each update.
|
|
|
+ */
|
|
|
+ private suspend fun updateChapterList() {
|
|
|
+ val semaphore = Semaphore(5)
|
|
|
+ val progressCount = AtomicInteger(0)
|
|
|
+ val currentlyUpdatingManga = CopyOnWriteArrayList<Manga>()
|
|
|
+ val newUpdates = CopyOnWriteArrayList<Pair<Manga, Array<Chapter>>>()
|
|
|
+ val skippedUpdates = CopyOnWriteArrayList<Pair<Manga, String?>>()
|
|
|
+ val failedUpdates = CopyOnWriteArrayList<Pair<Manga, String?>>()
|
|
|
+ val hasDownloads = AtomicBoolean(false)
|
|
|
+ val loggedServices by lazy { trackManager.services.filter { it.isLogged } }
|
|
|
+ val restrictions = libraryPreferences.libraryUpdateMangaRestriction().get()
|
|
|
+
|
|
|
+ coroutineScope {
|
|
|
+ mangaToUpdate.groupBy { it.manga.source }.values
|
|
|
+ .map { mangaInSource ->
|
|
|
+ async {
|
|
|
+ semaphore.withPermit {
|
|
|
+ mangaInSource.forEach { libraryManga ->
|
|
|
+ val manga = libraryManga.manga
|
|
|
+ ensureActive()
|
|
|
+
|
|
|
+ // Don't continue to update if manga is not in library
|
|
|
+ if (getManga.await(manga.id)?.favorite != true) {
|
|
|
+ return@forEach
|
|
|
+ }
|
|
|
+
|
|
|
+ withUpdateNotification(
|
|
|
+ currentlyUpdatingManga,
|
|
|
+ progressCount,
|
|
|
+ manga,
|
|
|
+ ) {
|
|
|
+ when {
|
|
|
+ MANGA_NON_COMPLETED in restrictions && manga.status.toInt() == SManga.COMPLETED ->
|
|
|
+ skippedUpdates.add(manga to context.getString(R.string.skipped_reason_completed))
|
|
|
+
|
|
|
+ MANGA_HAS_UNREAD in restrictions && libraryManga.unreadCount != 0L ->
|
|
|
+ skippedUpdates.add(manga to context.getString(R.string.skipped_reason_not_caught_up))
|
|
|
+
|
|
|
+ MANGA_NON_READ in restrictions && libraryManga.totalChapters > 0L && !libraryManga.hasStarted ->
|
|
|
+ skippedUpdates.add(manga to context.getString(R.string.skipped_reason_not_started))
|
|
|
+
|
|
|
+ manga.updateStrategy != UpdateStrategy.ALWAYS_UPDATE ->
|
|
|
+ skippedUpdates.add(manga to context.getString(R.string.skipped_reason_not_always_update))
|
|
|
+
|
|
|
+ else -> {
|
|
|
+ try {
|
|
|
+ val newChapters = updateManga(manga)
|
|
|
+ .sortedByDescending { it.sourceOrder }
|
|
|
+
|
|
|
+ if (newChapters.isNotEmpty()) {
|
|
|
+ val categoryIds = getCategories.await(manga.id).map { it.id }
|
|
|
+ if (manga.shouldDownloadNewChapters(categoryIds, downloadPreferences)) {
|
|
|
+ downloadChapters(manga, newChapters)
|
|
|
+ hasDownloads.set(true)
|
|
|
+ }
|
|
|
+
|
|
|
+ libraryPreferences.newUpdatesCount().getAndSet { it + newChapters.size }
|
|
|
+
|
|
|
+ // Convert to the manga that contains new chapters
|
|
|
+ newUpdates.add(manga to newChapters.toTypedArray())
|
|
|
+ }
|
|
|
+ } catch (e: Throwable) {
|
|
|
+ val errorMessage = when (e) {
|
|
|
+ is NoChaptersException -> context.getString(R.string.no_chapters_error)
|
|
|
+ // failedUpdates will already have the source, don't need to copy it into the message
|
|
|
+ is SourceManager.SourceNotInstalledException -> context.getString(R.string.loader_not_implemented_error)
|
|
|
+ else -> e.message
|
|
|
+ }
|
|
|
+ failedUpdates.add(manga to errorMessage)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (libraryPreferences.autoUpdateTrackers().get()) {
|
|
|
+ updateTrackings(manga, loggedServices)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ .awaitAll()
|
|
|
+ }
|
|
|
+
|
|
|
+ notifier.cancelProgressNotification()
|
|
|
+
|
|
|
+ if (newUpdates.isNotEmpty()) {
|
|
|
+ notifier.showUpdateNotifications(newUpdates)
|
|
|
+ if (hasDownloads.get()) {
|
|
|
+ DownloadService.start(context)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (failedUpdates.isNotEmpty()) {
|
|
|
+ val errorFile = writeErrorFile(failedUpdates)
|
|
|
+ notifier.showUpdateErrorNotification(
|
|
|
+ failedUpdates.size,
|
|
|
+ errorFile.getUriCompat(context),
|
|
|
+ )
|
|
|
+ }
|
|
|
+ if (skippedUpdates.isNotEmpty()) {
|
|
|
+ notifier.showUpdateSkippedNotification(skippedUpdates.size)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private fun downloadChapters(manga: Manga, chapters: List<Chapter>) {
|
|
|
+ // We don't want to start downloading while the library is updating, because websites
|
|
|
+ // may don't like it and they could ban the user.
|
|
|
+ downloadManager.downloadChapters(manga, chapters, false)
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Updates the chapters for the given manga and adds them to the database.
|
|
|
+ *
|
|
|
+ * @param manga the manga to update.
|
|
|
+ * @return a pair of the inserted and removed chapters.
|
|
|
+ */
|
|
|
+ private suspend fun updateManga(manga: Manga): List<Chapter> {
|
|
|
+ val source = sourceManager.getOrStub(manga.source)
|
|
|
+
|
|
|
+ // Update manga metadata if needed
|
|
|
+ if (libraryPreferences.autoUpdateMetadata().get()) {
|
|
|
+ val networkManga = source.getMangaDetails(manga.toSManga())
|
|
|
+ updateManga.awaitUpdateFromSource(manga, networkManga, manualFetch = false, coverCache)
|
|
|
+ }
|
|
|
+
|
|
|
+ val chapters = source.getChapterList(manga.toSManga())
|
|
|
+
|
|
|
+ // Get manga from database to account for if it was removed during the update and
|
|
|
+ // to get latest data so it doesn't get overwritten later on
|
|
|
+ val dbManga = getManga.await(manga.id)?.takeIf { it.favorite } ?: return emptyList()
|
|
|
+
|
|
|
+ return syncChaptersWithSource.await(chapters, dbManga, source)
|
|
|
+ }
|
|
|
+
|
|
|
+ private suspend fun updateCovers() {
|
|
|
+ val semaphore = Semaphore(5)
|
|
|
+ val progressCount = AtomicInteger(0)
|
|
|
+ val currentlyUpdatingManga = CopyOnWriteArrayList<Manga>()
|
|
|
+
|
|
|
+ coroutineScope {
|
|
|
+ mangaToUpdate.groupBy { it.manga.source }
|
|
|
+ .values
|
|
|
+ .map { mangaInSource ->
|
|
|
+ async {
|
|
|
+ semaphore.withPermit {
|
|
|
+ mangaInSource.forEach { libraryManga ->
|
|
|
+ val manga = libraryManga.manga
|
|
|
+ ensureActive()
|
|
|
+
|
|
|
+ withUpdateNotification(
|
|
|
+ currentlyUpdatingManga,
|
|
|
+ progressCount,
|
|
|
+ manga,
|
|
|
+ ) {
|
|
|
+ val source = sourceManager.get(manga.source) ?: return@withUpdateNotification
|
|
|
+ try {
|
|
|
+ val networkManga = source.getMangaDetails(manga.toSManga())
|
|
|
+ val updatedManga = manga.prepUpdateCover(coverCache, networkManga, true)
|
|
|
+ .copyFrom(networkManga)
|
|
|
+ try {
|
|
|
+ updateManga.await(updatedManga.toMangaUpdate())
|
|
|
+ } catch (e: Exception) {
|
|
|
+ logcat(LogPriority.ERROR) { "Manga doesn't exist anymore" }
|
|
|
+ }
|
|
|
+ } catch (e: Throwable) {
|
|
|
+ // Ignore errors and continue
|
|
|
+ logcat(LogPriority.ERROR, e)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ .awaitAll()
|
|
|
+ }
|
|
|
+
|
|
|
+ notifier.cancelProgressNotification()
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Method that updates the metadata of the connected tracking services. It's called in a
|
|
|
+ * background thread, so it's safe to do heavy operations or network calls here.
|
|
|
+ */
|
|
|
+ private suspend fun updateTrackings() {
|
|
|
+ coroutineScope {
|
|
|
+ var progressCount = 0
|
|
|
+ val loggedServices = trackManager.services.filter { it.isLogged }
|
|
|
+
|
|
|
+ mangaToUpdate.forEach { libraryManga ->
|
|
|
+ val manga = libraryManga.manga
|
|
|
+
|
|
|
+ ensureActive()
|
|
|
+
|
|
|
+ notifier.showProgressNotification(listOf(manga), progressCount++, mangaToUpdate.size)
|
|
|
+
|
|
|
+ // Update the tracking details.
|
|
|
+ updateTrackings(manga, loggedServices)
|
|
|
+ }
|
|
|
+
|
|
|
+ notifier.cancelProgressNotification()
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private suspend fun updateTrackings(manga: Manga, loggedServices: List<TrackService>) {
|
|
|
+ getTracks.await(manga.id)
|
|
|
+ .map { track ->
|
|
|
+ supervisorScope {
|
|
|
+ async {
|
|
|
+ val service = trackManager.getService(track.syncId)
|
|
|
+ if (service != null && service in loggedServices) {
|
|
|
+ try {
|
|
|
+ val updatedTrack = service.refresh(track.toDbTrack())
|
|
|
+ insertTrack.await(updatedTrack.toDomainTrack()!!)
|
|
|
+
|
|
|
+ if (service is EnhancedTrackService) {
|
|
|
+ val chapters = getChapterByMangaId.await(manga.id)
|
|
|
+ syncChaptersWithTrackServiceTwoWay.await(chapters, track, service)
|
|
|
+ }
|
|
|
+ } catch (e: Throwable) {
|
|
|
+ // Ignore errors and continue
|
|
|
+ logcat(LogPriority.ERROR, e)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ .awaitAll()
|
|
|
+ }
|
|
|
+
|
|
|
+ private suspend fun withUpdateNotification(
|
|
|
+ updatingManga: CopyOnWriteArrayList<Manga>,
|
|
|
+ completed: AtomicInteger,
|
|
|
+ manga: Manga,
|
|
|
+ block: suspend () -> Unit,
|
|
|
+ ) {
|
|
|
+ coroutineScope {
|
|
|
+ ensureActive()
|
|
|
+
|
|
|
+ updatingManga.add(manga)
|
|
|
+ notifier.showProgressNotification(
|
|
|
+ updatingManga,
|
|
|
+ completed.get(),
|
|
|
+ mangaToUpdate.size,
|
|
|
+ )
|
|
|
+
|
|
|
+ block()
|
|
|
+
|
|
|
+ ensureActive()
|
|
|
+
|
|
|
+ updatingManga.remove(manga)
|
|
|
+ completed.getAndIncrement()
|
|
|
+ notifier.showProgressNotification(
|
|
|
+ updatingManga,
|
|
|
+ completed.get(),
|
|
|
+ mangaToUpdate.size,
|
|
|
+ )
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * Writes basic file of update errors to cache dir.
|
|
|
+ */
|
|
|
+ private fun writeErrorFile(errors: List<Pair<Manga, String?>>): File {
|
|
|
+ try {
|
|
|
+ if (errors.isNotEmpty()) {
|
|
|
+ val file = context.createFileInCacheDir("tachiyomi_update_errors.txt")
|
|
|
+ file.bufferedWriter().use { out ->
|
|
|
+ out.write(context.getString(R.string.library_errors_help, ERROR_LOG_HELP_URL) + "\n\n")
|
|
|
+ // Error file format:
|
|
|
+ // ! Error
|
|
|
+ // # Source
|
|
|
+ // - Manga
|
|
|
+ errors.groupBy({ it.second }, { it.first }).forEach { (error, mangas) ->
|
|
|
+ out.write("\n! ${error}\n")
|
|
|
+ mangas.groupBy { it.source }.forEach { (srcId, mangas) ->
|
|
|
+ val source = sourceManager.getOrStub(srcId)
|
|
|
+ out.write(" # $source\n")
|
|
|
+ mangas.forEach {
|
|
|
+ out.write(" - ${it.title}\n")
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return file
|
|
|
+ }
|
|
|
+ } catch (_: Exception) {}
|
|
|
+ return File("")
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Defines what should be updated within a service execution.
|
|
|
+ */
|
|
|
+ enum class Target {
|
|
|
+ CHAPTERS, // Manga chapters
|
|
|
+ COVERS, // Manga covers
|
|
|
+ TRACKING, // Tracking metadata
|
|
|
+ }
|
|
|
+
|
|
|
companion object {
|
|
|
private const val TAG = "LibraryUpdate"
|
|
|
+ private const val WORK_NAME_AUTO = "LibraryUpdate-auto"
|
|
|
+ private const val WORK_NAME_MANUAL = "LibraryUpdate-manual"
|
|
|
+
|
|
|
+ private const val ERROR_LOG_HELP_URL = "https://tachiyomi.org/help/guides/troubleshooting"
|
|
|
|
|
|
- fun setupTask(context: Context, prefInterval: Int? = null) {
|
|
|
+ private const val MANGA_PER_SOURCE_QUEUE_WARNING_THRESHOLD = 60
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Key for category to update.
|
|
|
+ */
|
|
|
+ private const val KEY_CATEGORY = "category"
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Key that defines what should be updated.
|
|
|
+ */
|
|
|
+ private const val KEY_TARGET = "target"
|
|
|
+
|
|
|
+ fun cancelAllWorks(context: Context) {
|
|
|
+ WorkManager.getInstance(context).cancelAllWorkByTag(TAG)
|
|
|
+ }
|
|
|
+
|
|
|
+ fun setupTask(
|
|
|
+ context: Context,
|
|
|
+ prefInterval: Int? = null,
|
|
|
+ ) {
|
|
|
val preferences = Injekt.get<LibraryPreferences>()
|
|
|
val interval = prefInterval ?: preferences.libraryUpdateInterval().get()
|
|
|
if (interval > 0) {
|
|
@@ -56,15 +563,58 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet
|
|
|
TimeUnit.MINUTES,
|
|
|
)
|
|
|
.addTag(TAG)
|
|
|
+ .addTag(WORK_NAME_AUTO)
|
|
|
.setConstraints(constraints)
|
|
|
+ .setBackoffCriteria(BackoffPolicy.LINEAR, 10, TimeUnit.MINUTES)
|
|
|
.build()
|
|
|
|
|
|
- // Re-enqueue work because of common support suggestion to change
|
|
|
- // the settings on the desired time to schedule it at that time
|
|
|
- WorkManager.getInstance(context).enqueueUniquePeriodicWork(TAG, ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE, request)
|
|
|
+ WorkManager.getInstance(context).enqueueUniquePeriodicWork(WORK_NAME_AUTO, ExistingPeriodicWorkPolicy.UPDATE, request)
|
|
|
} else {
|
|
|
- WorkManager.getInstance(context).cancelAllWorkByTag(TAG)
|
|
|
+ WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME_AUTO)
|
|
|
}
|
|
|
}
|
|
|
+
|
|
|
+ fun startNow(
|
|
|
+ context: Context,
|
|
|
+ category: Category? = null,
|
|
|
+ target: Target = Target.CHAPTERS,
|
|
|
+ ): Boolean {
|
|
|
+ val wm = WorkManager.getInstance(context)
|
|
|
+ val infos = wm.getWorkInfosByTag(TAG).get()
|
|
|
+ if (infos.find { it.state == WorkInfo.State.RUNNING } != null) {
|
|
|
+ // Already running either as a scheduled or manual job
|
|
|
+ return false
|
|
|
+ }
|
|
|
+
|
|
|
+ val inputData = workDataOf(
|
|
|
+ KEY_CATEGORY to category?.id,
|
|
|
+ KEY_TARGET to target.name,
|
|
|
+ )
|
|
|
+ val request = OneTimeWorkRequestBuilder<LibraryUpdateJob>()
|
|
|
+ .addTag(TAG)
|
|
|
+ .addTag(WORK_NAME_MANUAL)
|
|
|
+ .setInputData(inputData)
|
|
|
+ .build()
|
|
|
+ wm.enqueueUniqueWork(WORK_NAME_MANUAL, ExistingWorkPolicy.KEEP, request)
|
|
|
+
|
|
|
+ return true
|
|
|
+ }
|
|
|
+
|
|
|
+ fun stop(context: Context) {
|
|
|
+ val wm = WorkManager.getInstance(context)
|
|
|
+ val workQuery = WorkQuery.Builder.fromTags(listOf(TAG))
|
|
|
+ .addStates(listOf(WorkInfo.State.RUNNING))
|
|
|
+ .build()
|
|
|
+ wm.getWorkInfos(workQuery).get()
|
|
|
+ // Should only return one work but just in case
|
|
|
+ .forEach {
|
|
|
+ wm.cancelWorkById(it.id)
|
|
|
+
|
|
|
+ // Re-enqueue cancelled scheduled work
|
|
|
+ if (it.tags.contains(WORK_NAME_AUTO)) {
|
|
|
+ setupTask(context)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
}
|
|
|
}
|