diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b2c23342e4..562ee34cc5 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -22,7 +22,7 @@ android { defaultConfig { applicationId = "eu.kanade.tachiyomi" - versionCode = 95 + versionCode = 96 versionName = "0.14.4" buildConfigField("String", "COMMIT_COUNT", "\"${getCommitCount()}\"") diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9c211bffab..010ee07f03 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -201,10 +201,6 @@ android:resource="@xml/updates_grid_glance_widget_info" /> - - diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsAdvancedScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsAdvancedScreen.kt index 1ddc04c91d..547687c043 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsAdvancedScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsAdvancedScreen.kt @@ -31,7 +31,7 @@ import eu.kanade.presentation.util.collectAsState import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.cache.ChapterCache import eu.kanade.tachiyomi.data.download.DownloadCache -import eu.kanade.tachiyomi.data.library.LibraryUpdateService +import eu.kanade.tachiyomi.data.library.LibraryUpdateJob import eu.kanade.tachiyomi.data.preference.PreferenceValues import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.network.NetworkHelper @@ -307,13 +307,13 @@ object SettingsAdvancedScreen : SearchableSettings { preferenceItems = listOf( Preference.PreferenceItem.TextPreference( title = stringResource(R.string.pref_refresh_library_covers), - onClick = { LibraryUpdateService.start(context, target = LibraryUpdateService.Target.COVERS) }, + onClick = { LibraryUpdateJob.startNow(context, target = LibraryUpdateJob.Target.COVERS) }, ), Preference.PreferenceItem.TextPreference( title = stringResource(R.string.pref_refresh_library_tracking), subtitle = stringResource(R.string.pref_refresh_library_tracking_summary), enabled = trackManager.hasLoggedServices(), - onClick = { LibraryUpdateService.start(context, target = LibraryUpdateService.Target.TRACKING) }, + onClick = { LibraryUpdateJob.startNow(context, target = LibraryUpdateJob.Target.TRACKING) }, ), Preference.PreferenceItem.TextPreference( title = stringResource(R.string.pref_reset_viewer_flags), diff --git a/app/src/main/java/eu/kanade/tachiyomi/Migrations.kt b/app/src/main/java/eu/kanade/tachiyomi/Migrations.kt index cd8f088f15..20299f6915 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/Migrations.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/Migrations.kt @@ -348,6 +348,10 @@ object Migrations { } } } + if (oldVersion < 95) { + LibraryUpdateJob.cancelAllWorks(context) + LibraryUpdateJob.setupTask(context) + } return true } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateJob.kt b/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateJob.kt index 51759c3d11..cd81e18776 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateJob.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateJob.kt @@ -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 = mutableListOf() + + override suspend fun doWork(): Result { val preferences = Injekt.get() val restrictions = preferences.libraryUpdateDeviceRestriction().get() if ((DEVICE_ONLY_ON_WIFI in restrictions) && !context.isConnectedToWifi()) { return Result.failure() } - return if (LibraryUpdateService.start(context)) { - Result.success() - } else { - Result.failure() + 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 { + 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() + val newUpdates = CopyOnWriteArrayList>>() + val skippedUpdates = CopyOnWriteArrayList>() + val failedUpdates = CopyOnWriteArrayList>() + 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) { + // 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 { + 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() + + 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) { + 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, + 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>): 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" - fun setupTask(context: Context, prefInterval: Int? = null) { + private const val ERROR_LOG_HELP_URL = "https://tachiyomi.org/help/guides/troubleshooting" + + 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() 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() + .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) + } + } + } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateService.kt b/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateService.kt deleted file mode 100644 index 021bfdddb8..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateService.kt +++ /dev/null @@ -1,607 +0,0 @@ -package eu.kanade.tachiyomi.data.library - -import android.app.Service -import android.content.Context -import android.content.Intent -import android.os.IBinder -import android.os.PowerManager -import androidx.core.content.ContextCompat -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.library.LibraryUpdateService.Companion.start -import eu.kanade.tachiyomi.data.notification.Notifications -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.acquireWakeLock -import eu.kanade.tachiyomi.util.system.createFileInCacheDir -import eu.kanade.tachiyomi.util.system.getSerializableExtraCompat -import eu.kanade.tachiyomi.util.system.isServiceRunning -import kotlinx.coroutines.CoroutineExceptionHandler -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.cancel -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.supervisorScope -import kotlinx.coroutines.sync.Semaphore -import kotlinx.coroutines.sync.withPermit -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.atomic.AtomicBoolean -import java.util.concurrent.atomic.AtomicInteger - -/** - * This class will take care of updating the chapters of the manga from the library. It can be - * started calling the [start] method. If it's already running, it won't do anything. - * While the library is updating, a [PowerManager.WakeLock] will be held until the update is - * completed, preventing the device from going to sleep mode. A notification will display the - * progress of the update, and if case of an unexpected error, this service will be silently - * destroyed. - */ -class LibraryUpdateService( - val sourceManager: SourceManager = Injekt.get(), - val downloadPreferences: DownloadPreferences = Injekt.get(), - val libraryPreferences: LibraryPreferences = Injekt.get(), - val downloadManager: DownloadManager = Injekt.get(), - val trackManager: TrackManager = Injekt.get(), - 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(), -) : Service() { - - private lateinit var wakeLock: PowerManager.WakeLock - private lateinit var notifier: LibraryUpdateNotifier - private var scope: CoroutineScope? = null - - private var mangaToUpdate: List = mutableListOf() - private var updateJob: Job? = null - - /** - * Defines what should be updated within a service execution. - */ - enum class Target { - CHAPTERS, // Manga chapters - COVERS, // Manga covers - TRACKING, // Tracking metadata - } - - companion object { - - private var instance: LibraryUpdateService? = null - - /** - * Key for category to update. - */ - const val KEY_CATEGORY = "category" - - /** - * Key that defines what should be updated. - */ - const val KEY_TARGET = "target" - - /** - * Returns the status of the service. - * - * @param context the application context. - * @return true if the service is running, false otherwise. - */ - fun isRunning(context: Context): Boolean { - return context.isServiceRunning(LibraryUpdateService::class.java) - } - - /** - * Starts the service. It will be started only if there isn't another instance already - * running. - * - * @param context the application context. - * @param category a specific category to update, or null for global update. - * @param target defines what should be updated. - * @return true if service newly started, false otherwise - */ - fun start(context: Context, category: Category? = null, target: Target = Target.CHAPTERS): Boolean { - if (isRunning(context)) return false - - val intent = Intent(context, LibraryUpdateService::class.java).apply { - putExtra(KEY_TARGET, target) - category?.let { putExtra(KEY_CATEGORY, it.id) } - } - ContextCompat.startForegroundService(context, intent) - - return true - } - - /** - * Stops the service. - * - * @param context the application context. - */ - fun stop(context: Context) { - context.stopService(Intent(context, LibraryUpdateService::class.java)) - } - } - - /** - * Method called when the service is created. It injects dagger dependencies and acquire - * the wake lock. - */ - override fun onCreate() { - notifier = LibraryUpdateNotifier(this) - wakeLock = acquireWakeLock(javaClass.name) - - startForeground(Notifications.ID_LIBRARY_PROGRESS, notifier.progressNotificationBuilder.build()) - } - - /** - * Method called when the service is destroyed. It destroys subscriptions and releases the wake - * lock. - */ - override fun onDestroy() { - updateJob?.cancel() - scope?.cancel() - if (wakeLock.isHeld) { - wakeLock.release() - } - if (instance == this) { - instance = null - } - } - - /** - * This method needs to be implemented, but it's not used/needed. - */ - override fun onBind(intent: Intent): IBinder? = null - - /** - * Method called when the service receives an intent. - * - * @param intent the start intent from. - * @param flags the flags of the command. - * @param startId the start id of this command. - * @return the start value of the command. - */ - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - if (intent == null) return START_NOT_STICKY - val target = intent.getSerializableExtraCompat(KEY_TARGET) - ?: return START_NOT_STICKY - - instance = this - - // Unsubscribe from any previous subscription if needed - updateJob?.cancel() - scope?.cancel() - - // If this is a chapter update; set the last update time to now - if (target == Target.CHAPTERS) { - libraryPreferences.libraryUpdateLastTimestamp().set(Date().time) - } - - // Update favorite manga - val categoryId = intent.getLongExtra(KEY_CATEGORY, -1L) - addMangaToQueue(categoryId) - - // Destroy service when completed or in case of an error. - val handler = CoroutineExceptionHandler { _, exception -> - logcat(LogPriority.ERROR, exception) - stopSelf(startId) - } - scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - updateJob = scope?.launch(handler) { - when (target) { - Target.CHAPTERS -> updateChapterList() - Target.COVERS -> updateCovers() - Target.TRACKING -> updateTrackings() - } - } - updateJob?.invokeOnCompletion { stopSelf(startId) } - - return START_REDELIVER_INTENT - } - - private val isUpdateJobActive: Boolean - get() = (updateJob?.isActive == true) - - /** - * 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 { - 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() - val newUpdates = CopyOnWriteArrayList>>() - val skippedUpdates = CopyOnWriteArrayList>() - val failedUpdates = CopyOnWriteArrayList>() - val hasDownloads = AtomicBoolean(false) - val loggedServices by lazy { trackManager.services.filter { it.isLogged } } - val restrictions = libraryPreferences.libraryUpdateMangaRestriction().get() - - withIOContext { - mangaToUpdate.groupBy { it.manga.source }.values - .map { mangaInSource -> - async { - semaphore.withPermit { - mangaInSource.forEach { libraryManga -> - val manga = libraryManga.manga - if (!isUpdateJobActive) { - notifier.cancelProgressNotification() - return@async - } - - // 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 getString(R.string.skipped_reason_completed)) - - MANGA_HAS_UNREAD in restrictions && libraryManga.unreadCount != 0L -> - skippedUpdates.add(manga to getString(R.string.skipped_reason_not_caught_up)) - - MANGA_NON_READ in restrictions && libraryManga.totalChapters > 0L && !libraryManga.hasStarted -> - skippedUpdates.add(manga to getString(R.string.skipped_reason_not_started)) - - manga.updateStrategy != UpdateStrategy.ALWAYS_UPDATE -> - skippedUpdates.add(manga to 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 -> getString(R.string.no_chapters_error) - // failedUpdates will already have the source, don't need to copy it into the message - is SourceManager.SourceNotInstalledException -> 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(this) - } - } - - if (failedUpdates.isNotEmpty()) { - val errorFile = writeErrorFile(failedUpdates) - notifier.showUpdateErrorNotification( - failedUpdates.size, - errorFile.getUriCompat(this), - ) - } - if (skippedUpdates.isNotEmpty()) { - notifier.showUpdateSkippedNotification(skippedUpdates.size) - } - } - - private fun downloadChapters(manga: Manga, chapters: List) { - // 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 { - 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() - - withIOContext { - mangaToUpdate.groupBy { it.manga.source } - .values - .map { mangaInSource -> - async { - semaphore.withPermit { - mangaInSource.forEach { libraryManga -> - val manga = libraryManga.manga - if (!isUpdateJobActive) { - notifier.cancelProgressNotification() - return@async - } - - 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() { - var progressCount = 0 - val loggedServices = trackManager.services.filter { it.isLogged } - - mangaToUpdate.forEach { libraryManga -> - val manga = libraryManga.manga - if (!isUpdateJobActive) { - notifier.cancelProgressNotification() - return - } - - notifier.showProgressNotification(listOf(manga), progressCount++, mangaToUpdate.size) - - // Update the tracking details. - updateTrackings(manga, loggedServices) - } - - notifier.cancelProgressNotification() - } - - private suspend fun updateTrackings(manga: Manga, loggedServices: List) { - 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, - completed: AtomicInteger, - manga: Manga, - block: suspend () -> Unit, - ) { - if (!isUpdateJobActive) { - notifier.cancelProgressNotification() - return - } - - updatingManga.add(manga) - notifier.showProgressNotification( - updatingManga, - completed.get(), - mangaToUpdate.size, - ) - - block() - - if (!isUpdateJobActive) { - notifier.cancelProgressNotification() - return - } - - 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>): File { - try { - if (errors.isNotEmpty()) { - val file = createFileInCacheDir("tachiyomi_update_errors.txt") - file.bufferedWriter().use { out -> - out.write(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("") - } -} - -private const val MANGA_PER_SOURCE_QUEUE_WARNING_THRESHOLD = 60 -private const val ERROR_LOG_HELP_URL = "https://tachiyomi.org/help/guides/troubleshooting" diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/notification/NotificationReceiver.kt b/app/src/main/java/eu/kanade/tachiyomi/data/notification/NotificationReceiver.kt index d471938c19..046cc48987 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/notification/NotificationReceiver.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/notification/NotificationReceiver.kt @@ -16,7 +16,7 @@ import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.backup.BackupRestoreService import eu.kanade.tachiyomi.data.download.DownloadManager import eu.kanade.tachiyomi.data.download.DownloadService -import eu.kanade.tachiyomi.data.library.LibraryUpdateService +import eu.kanade.tachiyomi.data.library.LibraryUpdateJob import eu.kanade.tachiyomi.data.updater.AppUpdateService import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.ui.main.MainActivity @@ -91,7 +91,7 @@ class NotificationReceiver : BroadcastReceiver() { intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1), ) // Cancel library update and dismiss notification - ACTION_CANCEL_LIBRARY_UPDATE -> cancelLibraryUpdate(context, Notifications.ID_LIBRARY_PROGRESS) + ACTION_CANCEL_LIBRARY_UPDATE -> cancelLibraryUpdate(context) // Cancel downloading app update ACTION_CANCEL_APP_UPDATE_DOWNLOAD -> cancelDownloadAppUpdate(context) // Open reader activity @@ -221,11 +221,9 @@ class NotificationReceiver : BroadcastReceiver() { * Method called when user wants to stop a library update * * @param context context of application - * @param notificationId id of notification */ - private fun cancelLibraryUpdate(context: Context, notificationId: Int) { - LibraryUpdateService.stop(context) - ContextCompat.getMainExecutor(context).execute { dismissNotification(context, notificationId) } + private fun cancelLibraryUpdate(context: Context) { + LibraryUpdateJob.stop(context) } private fun cancelDownloadAppUpdate(context: Context) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryTab.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryTab.kt index 6aa817b9d0..ca44167b7f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryTab.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryTab.kt @@ -41,7 +41,7 @@ import eu.kanade.presentation.library.components.LibraryToolbar import eu.kanade.presentation.manga.components.DownloadCustomAmountDialog import eu.kanade.presentation.util.Tab import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.library.LibraryUpdateService +import eu.kanade.tachiyomi.data.library.LibraryUpdateJob import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchScreen import eu.kanade.tachiyomi.ui.category.CategoryScreen import eu.kanade.tachiyomi.ui.home.HomeScreen @@ -89,7 +89,7 @@ object LibraryTab : Tab { val snackbarHostState = remember { SnackbarHostState() } val onClickRefresh: (Category?) -> Boolean = { - val started = LibraryUpdateService.start(context, it) + val started = LibraryUpdateJob.startNow(context, it) scope.launch { val msgRes = if (started) R.string.updating_category else R.string.update_already_running snackbarHostState.showSnackbar(context.getString(msgRes)) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/updates/UpdatesScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/updates/UpdatesScreenModel.kt index cd85c55d6b..51d800d36c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/updates/UpdatesScreenModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/updates/UpdatesScreenModel.kt @@ -23,7 +23,7 @@ import eu.kanade.tachiyomi.data.download.DownloadCache import eu.kanade.tachiyomi.data.download.DownloadManager import eu.kanade.tachiyomi.data.download.DownloadService import eu.kanade.tachiyomi.data.download.model.Download -import eu.kanade.tachiyomi.data.library.LibraryUpdateService +import eu.kanade.tachiyomi.data.library.LibraryUpdateJob import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.util.lang.toDateKey import eu.kanade.tachiyomi.util.lang.toRelativeString @@ -130,7 +130,7 @@ class UpdatesScreenModel( } fun updateLibrary(): Boolean { - val started = LibraryUpdateService.start(Injekt.get()) + val started = LibraryUpdateJob.startNow(Injekt.get()) coroutineScope.launch { _events.send(Event.LibraryUpdateTriggered(started)) }