diff --git a/app/build.gradle.kts b/app/build.gradle.kts index fc9e70657..412e5461d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -26,7 +26,7 @@ android { defaultConfig { applicationId = "eu.kanade.tachiyomi.sy" - versionCode = 47 + versionCode = 48 versionName = "1.9.0" buildConfigField("String", "COMMIT_COUNT", "\"${getCommitCount()}\"") diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 06058f210..681cc4812 100755 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -207,10 +207,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 01df15908..8624a30cc 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 @@ -51,7 +51,7 @@ import eu.kanade.tachiyomi.data.cache.ChapterCache import eu.kanade.tachiyomi.data.cache.PagePreviewCache import eu.kanade.tachiyomi.data.download.DownloadCache import eu.kanade.tachiyomi.data.download.DownloadManager -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 @@ -366,13 +366,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/presentation/more/settings/screen/SettingsMangadexScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsMangadexScreen.kt index 9dca7c0cb..f2cb31aca 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsMangadexScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsMangadexScreen.kt @@ -32,7 +32,7 @@ import eu.kanade.presentation.more.settings.Preference import eu.kanade.presentation.util.collectAsState import eu.kanade.presentation.util.padding import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.library.LibraryUpdateService +import eu.kanade.tachiyomi.data.library.LibraryUpdateJob import eu.kanade.tachiyomi.source.online.all.MangaDex import eu.kanade.tachiyomi.util.system.openInBrowser import eu.kanade.tachiyomi.util.system.toast @@ -227,9 +227,9 @@ object SettingsMangadexScreen : SearchableSettings { unsortedPreferences.mangadexSyncToLibraryIndexes().set( List(items.size) { index -> (index + 1).toString() }.toSet(), ) - LibraryUpdateService.start( + LibraryUpdateJob.startNow( context, - target = LibraryUpdateService.Target.SYNC_FOLLOWS, + target = LibraryUpdateJob.Target.SYNC_FOLLOWS, ) }, ) @@ -248,9 +248,9 @@ object SettingsMangadexScreen : SearchableSettings { title = stringResource(R.string.mangadex_push_favorites_to_mangadex), subtitle = stringResource(R.string.mangadex_push_favorites_to_mangadex_summary), onClick = { - LibraryUpdateService.start( + LibraryUpdateJob.startNow( context, - target = LibraryUpdateService.Target.PUSH_FAVORITES, + target = LibraryUpdateJob.Target.PUSH_FAVORITES, ) }, ) diff --git a/app/src/main/java/eu/kanade/tachiyomi/Migrations.kt b/app/src/main/java/eu/kanade/tachiyomi/Migrations.kt index 245d72b93..78cfec975 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/Migrations.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/Migrations.kt @@ -351,6 +351,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 51759c3d1..4a83016ea 100755 --- 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,760 @@ 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.UnsortedPreferences +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.model.GroupLibraryMode +import eu.kanade.domain.library.model.LibraryGroup import eu.kanade.domain.library.service.LibraryPreferences +import eu.kanade.domain.manga.interactor.GetFavorites +import eu.kanade.domain.manga.interactor.GetLibraryManga +import eu.kanade.domain.manga.interactor.GetManga +import eu.kanade.domain.manga.interactor.GetMergedMangaForDownloading +import eu.kanade.domain.manga.interactor.InsertFlatMetadata +import eu.kanade.domain.manga.interactor.NetworkToLocalManga +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.data.track.TrackStatus +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.source.online.all.MergedSource +import eu.kanade.tachiyomi.ui.manga.track.TrackItem +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 exh.log.xLogE +import exh.md.utils.FollowStatus +import exh.md.utils.MdUtil +import exh.source.LIBRARY_UPDATE_EXCLUDED_SOURCES +import exh.source.MERGED_SOURCE_ID +import exh.source.isMdBasedSource +import exh.source.mangaDexSourceIds +import exh.util.nullIfBlank +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.launch +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() + + // SY --> + private val getFavorites: GetFavorites = Injekt.get() + private val insertFlatMetadata: InsertFlatMetadata = Injekt.get() + private val networkToLocalManga: NetworkToLocalManga = Injekt.get() + private val getMergedMangaForDownloading: GetMergedMangaForDownloading = Injekt.get() + // SY <-- + + 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) + // SY --> + val group = inputData.getInt(KEY_GROUP, LibraryGroup.BY_DEFAULT) + val groupExtra = inputData.getString(KEY_GROUP_EXTRA) + // SY <-- + addMangaToQueue(categoryId, group, groupExtra) + + return withIOContext { + try { + when (target) { + Target.CHAPTERS -> updateChapterList() + Target.COVERS -> updateCovers() + Target.TRACKING -> updateTrackings() + // SY --> + Target.SYNC_FOLLOWS -> syncFollows() + Target.PUSH_FAVORITES -> pushFavorites() + // SY <-- + } + 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, group: Int, groupExtra: String?) { + val libraryManga = runBlocking { getLibraryManga.await() } + // SY --> + val groupLibraryUpdateType = libraryPreferences.groupLibraryUpdateType().get() + // SY <-- + + val listToUpdate = if (categoryId != -1L) { + libraryManga.filter { it.category == categoryId } + } else if ( + group == LibraryGroup.BY_DEFAULT || + groupLibraryUpdateType == GroupLibraryMode.GLOBAL || + (groupLibraryUpdateType == GroupLibraryMode.ALL_BUT_UNGROUPED && group == LibraryGroup.UNGROUPED) + ) { + val categoriesToUpdate = libraryPreferences.libraryUpdateCategories().get().map(String::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 } + } else { + when (group) { + LibraryGroup.BY_TRACK_STATUS -> { + val trackingExtra = groupExtra?.toIntOrNull() ?: -1 + val tracks = runBlocking { getTracks.await() }.groupBy { it.mangaId } + + libraryManga.filter { (manga) -> + val status = tracks[manga.id]?.firstNotNullOfOrNull { track -> + TrackStatus.parseTrackerStatus(track.syncId, track.status) + } ?: TrackStatus.OTHER + status.int == trackingExtra + } + } + LibraryGroup.BY_SOURCE -> { + val sourceExtra = groupExtra?.nullIfBlank()?.toIntOrNull() + val source = libraryManga.map { it.manga.source } + .distinct() + .sorted() + .getOrNull(sourceExtra ?: -1) + + if (source != null) libraryManga.filter { it.manga.source == source } else emptyList() + } + LibraryGroup.BY_STATUS -> { + val statusExtra = groupExtra?.toLongOrNull() ?: -1 + libraryManga.filter { + it.manga.status == statusExtra + } + } + LibraryGroup.UNGROUPED -> libraryManga + else -> libraryManga + } + // SY <-- + } + + mangaToUpdate = listToUpdate + // SY --> + .distinctBy { it.manga.id } + // SY <-- + .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 } + // SY --> + .filterNot { it.key in LIBRARY_UPDATE_EXCLUDED_SOURCES } + // SY <-- + .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, loggedServices) + .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. + // SY --> + if (manga.source == MERGED_SOURCE_ID) { + val downloadingManga = runBlocking { getMergedMangaForDownloading.await(manga.id) } + .associateBy { it.id } + chapters.groupBy { it.mangaId } + .forEach { + downloadManager.downloadChapters( + downloadingManga[it.key] ?: return@forEach, + it.value, + false, + ) + } + + return + } + // SY <-- + 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, loggedServices: List): List = coroutineScope { + 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) + } + + // SY --> + if (source.isMdBasedSource() && trackManager.mdList in loggedServices) { + launch { + try { + val tracks = getTracks.await(manga.id) + if (tracks.isEmpty() || tracks.none { it.syncId == TrackManager.MDLIST }) { + val track = trackManager.mdList.createInitialTracker(manga) + insertTrack.await(trackManager.mdList.refresh(track).toDomainTrack(false)!!) + } + } catch (e: Exception) { + if (e is CancellationException) throw e + xLogE("Error adding initial track for ${manga.title}", e) + } + } + } + + if (source is MergedSource) { + return@coroutineScope source.fetchChaptersAndSync(manga, false) + } + // SY <-- + + 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@coroutineScope emptyList() + + return@coroutineScope 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() + } + + // SY --> + /** + * filter all follows from Mangadex and only add reading or rereading manga to library + */ + private suspend fun syncFollows() = coroutineScope { + val preferences = Injekt.get() + var count = 0 + val mangaDex = MdUtil.getEnabledMangaDex(preferences, sourceManager = sourceManager) ?: return@coroutineScope + val syncFollowStatusInts = preferences.mangadexSyncToLibraryIndexes().get().map { it.toInt() } + + val size: Int + mangaDex.fetchAllFollows() + .filter { (_, metadata) -> + syncFollowStatusInts.contains(metadata.followStatus) + } + .also { size = it.size } + .forEach { (networkManga, metadata) -> + ensureActive() + + count++ + notifier.showProgressNotification(listOf(Manga.create().copy(ogTitle = networkManga.title)), count, size) + + var dbManga = getManga.await(networkManga.url, mangaDex.id) + + if (dbManga == null) { + dbManga = networkToLocalManga.await( + Manga.create().copy( + url = networkManga.url, + ogTitle = networkManga.title, + source = mangaDex.id, + favorite = true, + dateAdded = System.currentTimeMillis(), + ), + ) + } else if (!dbManga.favorite) { + updateManga.awaitUpdateFavorite(dbManga.id, true) + } + + updateManga.awaitUpdateFromSource(dbManga, networkManga, true) + metadata.mangaId = dbManga.id + insertFlatMetadata.await(metadata) + } + + notifier.cancelProgressNotification() + } + + /** + * Method that updates the all mangas which are not tracked as "reading" on mangadex + */ + private suspend fun pushFavorites() = coroutineScope { + var count = 0 + val listManga = getFavorites.await().filter { it.source in mangaDexSourceIds } + + // filter all follows from Mangadex and only add reading or rereading manga to library + if (trackManager.mdList.isLogged) { + listManga.forEach { manga -> + ensureActive() + + count++ + notifier.showProgressNotification(listOf(manga), count, listManga.size) + + // Get this manga's trackers from the database + val dbTracks = getTracks.await(manga.id) + + // find the mdlist entry if its unfollowed the follow it + val tracker = TrackItem(dbTracks.firstOrNull { it.syncId == TrackManager.MDLIST }?.toDbTrack() ?: trackManager.mdList.createInitialTracker(manga), trackManager.mdList) + + if (tracker.track?.status == FollowStatus.UNFOLLOWED.int) { + tracker.track.status = FollowStatus.READING.int + val updatedTrack = tracker.service.update(tracker.track) + insertTrack.await(updatedTrack.toDomainTrack(false)!!) + } + } + } + + notifier.cancelProgressNotification() + } + // SY <-- + + 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 + + // SY --> + SYNC_FOLLOWS, // MangaDex specific, pull mangadex manga in reading, rereading + + PUSH_FAVORITES, // MangaDex specific, push mangadex manga to mangadex + // SY <-- } 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" + + // SY --> + /** + * Key for group to update. + */ + const val KEY_GROUP = "group" + const val KEY_GROUP_EXTRA = "group_extra" + // SY <-- + + 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 +772,66 @@ 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, + // SY --> + group: Int = LibraryGroup.BY_DEFAULT, + groupExtra: String? = null, + // SY <-- + ): 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, + // SY --> + KEY_GROUP to group, + KEY_GROUP_EXTRA to groupExtra, + // SY <-- + ) + 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 100755 index 8be7819fd..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateService.kt +++ /dev/null @@ -1,817 +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.UnsortedPreferences -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.model.GroupLibraryMode -import eu.kanade.domain.library.model.LibraryGroup -import eu.kanade.domain.library.service.LibraryPreferences -import eu.kanade.domain.manga.interactor.GetFavorites -import eu.kanade.domain.manga.interactor.GetLibraryManga -import eu.kanade.domain.manga.interactor.GetManga -import eu.kanade.domain.manga.interactor.GetMergedMangaForDownloading -import eu.kanade.domain.manga.interactor.InsertFlatMetadata -import eu.kanade.domain.manga.interactor.NetworkToLocalManga -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.data.track.TrackStatus -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.source.online.all.MergedSource -import eu.kanade.tachiyomi.ui.manga.track.TrackItem -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 exh.log.xLogE -import exh.md.utils.FollowStatus -import exh.md.utils.MdUtil -import exh.source.LIBRARY_UPDATE_EXCLUDED_SOURCES -import exh.source.MERGED_SOURCE_ID -import exh.source.isMdBasedSource -import exh.source.mangaDexSourceIds -import exh.util.nullIfBlank -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(), - // SY --> - private val getFavorites: GetFavorites = Injekt.get(), - private val insertFlatMetadata: InsertFlatMetadata = Injekt.get(), - private val networkToLocalManga: NetworkToLocalManga = Injekt.get(), - private val getMergedMangaForDownloading: GetMergedMangaForDownloading = Injekt.get(), - // SY <-- -) : 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 - - // SY --> - SYNC_FOLLOWS, // MangaDex specific, pull mangadex manga in reading, rereading - - PUSH_FAVORITES, // MangaDex specific, push mangadex manga to mangadex - // SY <-- - } - - 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" - - // SY --> - /** - * Key for group to update. - */ - const val KEY_GROUP = "group" - const val KEY_GROUP_EXTRA = "group_extra" - // SY <-- - - /** - * 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 /* SY --> */, group: Int = LibraryGroup.BY_DEFAULT, groupExtra: String? = null /* SY <-- */): 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) } - // SY --> - putExtra(KEY_GROUP, group) - groupExtra?.let { putExtra(KEY_GROUP_EXTRA, it) } - // SY <-- - } - 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) - val group = intent.getIntExtra(KEY_GROUP, LibraryGroup.BY_DEFAULT) - val groupExtra = intent.getStringExtra(KEY_GROUP_EXTRA) - addMangaToQueue(categoryId, group, groupExtra) - - // 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() - // SY --> - Target.SYNC_FOLLOWS -> syncFollows() - Target.PUSH_FAVORITES -> pushFavorites() - // SY <-- - } - } - 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, group: Int, groupExtra: String?) { - val libraryManga = runBlocking { getLibraryManga.await() } - // SY --> - val groupLibraryUpdateType = libraryPreferences.groupLibraryUpdateType().get() - // SY <-- - - val listToUpdate = if (categoryId != -1L) { - libraryManga.filter { it.category == categoryId } - // SY --> - } else if ( - group == LibraryGroup.BY_DEFAULT || - groupLibraryUpdateType == GroupLibraryMode.GLOBAL || - (groupLibraryUpdateType == GroupLibraryMode.ALL_BUT_UNGROUPED && group == LibraryGroup.UNGROUPED) - ) { - val categoriesToUpdate = libraryPreferences.libraryUpdateCategories().get().map(String::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 } - } else { - when (group) { - LibraryGroup.BY_TRACK_STATUS -> { - val trackingExtra = groupExtra?.toIntOrNull() ?: -1 - val tracks = runBlocking { getTracks.await() }.groupBy { it.mangaId } - - libraryManga.filter { (manga) -> - val status = tracks[manga.id]?.firstNotNullOfOrNull { track -> - TrackStatus.parseTrackerStatus(track.syncId, track.status) - } ?: TrackStatus.OTHER - status.int == trackingExtra - } - } - LibraryGroup.BY_SOURCE -> { - val sourceExtra = groupExtra?.nullIfBlank()?.toIntOrNull() - val source = libraryManga.map { it.manga.source } - .distinct() - .sorted() - .getOrNull(sourceExtra ?: -1) - - if (source != null) libraryManga.filter { it.manga.source == source } else emptyList() - } - LibraryGroup.BY_STATUS -> { - val statusExtra = groupExtra?.toLongOrNull() ?: -1 - libraryManga.filter { - it.manga.status == statusExtra - } - } - LibraryGroup.UNGROUPED -> libraryManga - else -> libraryManga - } - // SY <-- - } - - mangaToUpdate = listToUpdate - .distinctBy { it.manga.id } - .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 } - // SY --> - .filterNot { it.key in LIBRARY_UPDATE_EXCLUDED_SOURCES } - // SY <-- - .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, loggedServices) - .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. - // SY --> - if (manga.source == MERGED_SOURCE_ID) { - val downloadingManga = runBlocking { getMergedMangaForDownloading.await(manga.id) } - .associateBy { it.id } - chapters.groupBy { it.mangaId } - .forEach { - downloadManager.downloadChapters( - downloadingManga[it.key] ?: return@forEach, - it.value, - false, - ) - } - - return - } - // SY <-- - 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, loggedServices: List): 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) - } - - // SY --> - if (source.isMdBasedSource() && trackManager.mdList in loggedServices) { - val handler = CoroutineExceptionHandler { _, exception -> - xLogE("Error adding initial track for ${manga.title}", exception) - } - scope?.launch(handler) { - val tracks = getTracks.await(manga.id) - if (tracks.isEmpty() || tracks.none { it.syncId == TrackManager.MDLIST }) { - val track = trackManager.mdList.createInitialTracker(manga) - insertTrack.await(trackManager.mdList.refresh(track).toDomainTrack(false)!!) - } - } - } - - if (source is MergedSource) { - return source.fetchChaptersAndSync(manga, false) - } - // SY <-- - - 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, - ) - } - - // SY --> - /** - * filter all follows from Mangadex and only add reading or rereading manga to library - */ - private suspend fun syncFollows() { - val preferences = Injekt.get() - var count = 0 - val mangaDex = MdUtil.getEnabledMangaDex(preferences, sourceManager = sourceManager) ?: return - val syncFollowStatusInts = preferences.mangadexSyncToLibraryIndexes().get().map { it.toInt() } - - val size: Int - mangaDex.fetchAllFollows() - .filter { (_, metadata) -> - syncFollowStatusInts.contains(metadata.followStatus) - } - .also { size = it.size } - .forEach { (networkManga, metadata) -> - if (updateJob?.isActive != true) { - return - } - - count++ - notifier.showProgressNotification(listOf(Manga.create().copy(ogTitle = networkManga.title)), count, size) - - var dbManga = getManga.await(networkManga.url, mangaDex.id) - - if (dbManga == null) { - dbManga = networkToLocalManga.await( - Manga.create().copy( - url = networkManga.url, - ogTitle = networkManga.title, - source = mangaDex.id, - favorite = true, - dateAdded = System.currentTimeMillis(), - ), - ) - } else if (!dbManga.favorite) { - updateManga.awaitUpdateFavorite(dbManga.id, true) - } - - updateManga.awaitUpdateFromSource(dbManga, networkManga, true) - metadata.mangaId = dbManga.id - insertFlatMetadata.await(metadata) - } - - notifier.cancelProgressNotification() - } - - /** - * Method that updates the all mangas which are not tracked as "reading" on mangadex - */ - private suspend fun pushFavorites() { - var count = 0 - val listManga = getFavorites.await().filter { it.source in mangaDexSourceIds } - - // filter all follows from Mangadex and only add reading or rereading manga to library - if (trackManager.mdList.isLogged) { - listManga.forEach { manga -> - if (updateJob?.isActive != true) { - return - } - - count++ - notifier.showProgressNotification(listOf(manga), count, listManga.size) - - // Get this manga's trackers from the database - val dbTracks = getTracks.await(manga.id) - - // find the mdlist entry if its unfollowed the follow it - val tracker = TrackItem(dbTracks.firstOrNull { it.syncId == TrackManager.MDLIST }?.toDbTrack() ?: trackManager.mdList.createInitialTracker(manga), trackManager.mdList) - - if (tracker.track?.status == FollowStatus.UNFOLLOWED.int) { - tracker.track.status = FollowStatus.READING.int - val updatedTrack = tracker.service.update(tracker.track) - insertTrack.await(updatedTrack.toDomainTrack(false)!!) - } - } - } - - notifier.cancelProgressNotification() - } - // SY <-- - - /** - * 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 d471938c1..046cc4898 100755 --- 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/LibraryScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryScreenModel.kt index b3fb6f81a..b27620a69 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryScreenModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryScreenModel.kt @@ -688,7 +688,7 @@ class LibraryScreenModel( .forEach ab@{ (mangaId, chapters) -> val mergedManga = mergedMangas[mangaId] ?: return@ab val downloadChapters = chapters.fastFilterNot { chapter -> - downloadManager.queue.fastAny { chapter.id == it.chapter.id } || + downloadManager.queue.state.value.fastAny { chapter.id == it.chapter.id } || downloadManager.isChapterDownloaded( chapter.name, chapter.scanlator, 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 ae114ae4b..ea1e585ab 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 @@ -46,7 +46,7 @@ import eu.kanade.presentation.library.components.SyncFavoritesWarningDialog 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.migration.advanced.design.PreMigrationScreen import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchScreen import eu.kanade.tachiyomi.ui.category.CategoryScreen @@ -101,7 +101,7 @@ object LibraryTab : Tab { val onClickRefresh: (Category?) -> Boolean = { // SY --> - val started = LibraryUpdateService.start( + val started = LibraryUpdateJob.startNow( context = context, category = if (state.groupType == LibraryGroup.BY_DEFAULT) it else null, group = state.groupType, 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 950f8d903..66b6b40bf 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.ui.reader.setting.ReaderPreferences import eu.kanade.tachiyomi.util.lang.toDateKey @@ -142,7 +142,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)) } diff --git a/app/src/main/java/exh/EXHMigrations.kt b/app/src/main/java/exh/EXHMigrations.kt index 9c6ce359b..fb1269315 100644 --- a/app/src/main/java/exh/EXHMigrations.kt +++ b/app/src/main/java/exh/EXHMigrations.kt @@ -515,6 +515,10 @@ object EXHMigrations { val trackManager = Injekt.get() trackManager.mdList.logout() } + if (oldVersion under 48) { + LibraryUpdateJob.cancelAllWorks(context) + LibraryUpdateJob.setupTask(context) + } // if (oldVersion under 1) { } (1 is current release version) // do stuff here when releasing changed crap