Fully utilize WorkManager for library updates (#9007)

No more trampolining, and stuff.

It's pretty much straight copy-paste from the service, with
some changes related to cancellation handling. Manual updates
will also runs with workman job so auto update work
scheduling need some adjustments too.

Bumped version code to re-enqueue auto update job with the
new spec.

Co-authored-by: arkon <arkon@users.noreply.github.com>
(cherry picked from commit ef9dacde79b1803ec117aae4ea948194b6394605)

# Conflicts:
#	app/build.gradle.kts
#	app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsAdvancedScreen.kt
#	app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateService.kt
#	app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryTab.kt
This commit is contained in:
Ivan Iskandar 2023-02-08 10:37:20 +07:00 committed by Jobobby04
parent f54e5515ac
commit 4f4205230f
12 changed files with 805 additions and 853 deletions

View File

@ -26,7 +26,7 @@ android {
defaultConfig {
applicationId = "eu.kanade.tachiyomi.sy"
versionCode = 47
versionCode = 48
versionName = "1.9.0"
buildConfigField("String", "COMMIT_COUNT", "\"${getCommitCount()}\"")

View File

@ -207,10 +207,6 @@
android:resource="@xml/updates_grid_glance_widget_info" />
</receiver>
<service
android:name=".data.library.LibraryUpdateService"
android:exported="false" />
<service
android:name=".data.download.DownloadService"
android:exported="false" />

View File

@ -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),

View File

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

View File

@ -351,6 +351,10 @@ object Migrations {
}
}
}
if (oldVersion < 95) {
LibraryUpdateJob.cancelAllWorks(context)
LibraryUpdateJob.setupTask(context)
}
return true
}

View File

@ -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<LibraryManga> = mutableListOf()
override suspend fun doWork(): Result {
val preferences = Injekt.get<LibraryPreferences>()
val restrictions = preferences.libraryUpdateDeviceRestriction().get()
if ((DEVICE_ONLY_ON_WIFI in restrictions) && !context.isConnectedToWifi()) {
return Result.failure()
}
return if (LibraryUpdateService.start(context)) {
Result.success()
} 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<Manga>()
val newUpdates = CopyOnWriteArrayList<Pair<Manga, Array<Chapter>>>()
val skippedUpdates = CopyOnWriteArrayList<Pair<Manga, String?>>()
val failedUpdates = CopyOnWriteArrayList<Pair<Manga, String?>>()
val hasDownloads = AtomicBoolean(false)
val loggedServices by lazy { trackManager.services.filter { it.isLogged } }
val restrictions = libraryPreferences.libraryUpdateMangaRestriction().get()
coroutineScope {
mangaToUpdate.groupBy { it.manga.source }
// 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<Chapter>) {
// We don't want to start downloading while the library is updating, because websites
// may don't like it and they could ban the user.
// 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<TrackService>): List<Chapter> = 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<Manga>()
coroutineScope {
mangaToUpdate.groupBy { it.manga.source }
.values
.map { mangaInSource ->
async {
semaphore.withPermit {
mangaInSource.forEach { libraryManga ->
val manga = libraryManga.manga
ensureActive()
withUpdateNotification(
currentlyUpdatingManga,
progressCount,
manga,
) {
val source = sourceManager.get(manga.source) ?: return@withUpdateNotification
try {
val networkManga = source.getMangaDetails(manga.toSManga())
val updatedManga = manga.prepUpdateCover(coverCache, networkManga, true)
.copyFrom(networkManga)
try {
updateManga.await(updatedManga.toMangaUpdate())
} catch (e: Exception) {
logcat(LogPriority.ERROR) { "Manga doesn't exist anymore" }
}
} catch (e: Throwable) {
// Ignore errors and continue
logcat(LogPriority.ERROR, e)
}
}
}
}
}
}
.awaitAll()
}
notifier.cancelProgressNotification()
}
/**
* Method that updates the metadata of the connected tracking services. It's called in a
* background thread, so it's safe to do heavy operations or network calls here.
*/
private suspend fun updateTrackings() {
coroutineScope {
var progressCount = 0
val loggedServices = trackManager.services.filter { it.isLogged }
mangaToUpdate.forEach { libraryManga ->
val manga = libraryManga.manga
ensureActive()
notifier.showProgressNotification(listOf(manga), progressCount++, mangaToUpdate.size)
// Update the tracking details.
updateTrackings(manga, loggedServices)
}
notifier.cancelProgressNotification()
}
}
private suspend fun updateTrackings(manga: Manga, loggedServices: List<TrackService>) {
getTracks.await(manga.id)
.map { track ->
supervisorScope {
async {
val service = trackManager.getService(track.syncId)
if (service != null && service in loggedServices) {
try {
val updatedTrack = service.refresh(track.toDbTrack())
insertTrack.await(updatedTrack.toDomainTrack()!!)
if (service is EnhancedTrackService) {
val chapters = getChapterByMangaId.await(manga.id)
syncChaptersWithTrackServiceTwoWay.await(chapters, track, service)
}
} catch (e: Throwable) {
// Ignore errors and continue
logcat(LogPriority.ERROR, e)
}
}
}
}
}
.awaitAll()
}
// SY -->
/**
* filter all follows from Mangadex and only add reading or rereading manga to library
*/
private suspend fun syncFollows() = coroutineScope {
val preferences = Injekt.get<UnsortedPreferences>()
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<Manga>,
completed: AtomicInteger,
manga: Manga,
block: suspend () -> Unit,
) {
coroutineScope {
ensureActive()
updatingManga.add(manga)
notifier.showProgressNotification(
updatingManga,
completed.get(),
mangaToUpdate.size,
)
block()
ensureActive()
updatingManga.remove(manga)
completed.getAndIncrement()
notifier.showProgressNotification(
updatingManga,
completed.get(),
mangaToUpdate.size,
)
}
}
/**
* Writes basic file of update errors to cache dir.
*/
private fun writeErrorFile(errors: List<Pair<Manga, String?>>): File {
try {
if (errors.isNotEmpty()) {
val file = context.createFileInCacheDir("tachiyomi_update_errors.txt")
file.bufferedWriter().use { out ->
out.write(context.getString(R.string.library_errors_help, ERROR_LOG_HELP_URL) + "\n\n")
// Error file format:
// ! Error
// # Source
// - Manga
errors.groupBy({ it.second }, { it.first }).forEach { (error, mangas) ->
out.write("\n! ${error}\n")
mangas.groupBy { it.source }.forEach { (srcId, mangas) ->
val source = sourceManager.getOrStub(srcId)
out.write(" # $source\n")
mangas.forEach {
out.write(" - ${it.title}\n")
}
}
}
}
return file
}
} catch (_: Exception) {}
return File("")
}
/**
* Defines what should be updated within a service execution.
*/
enum class Target {
CHAPTERS, // Manga chapters
COVERS, // Manga covers
TRACKING, // Tracking metadata
// 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<LibraryPreferences>()
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<LibraryUpdateJob>()
.addTag(TAG)
.addTag(WORK_NAME_MANUAL)
.setInputData(inputData)
.build()
wm.enqueueUniqueWork(WORK_NAME_MANUAL, ExistingWorkPolicy.KEEP, request)
return true
}
fun stop(context: Context) {
val wm = WorkManager.getInstance(context)
val workQuery = WorkQuery.Builder.fromTags(listOf(TAG))
.addStates(listOf(WorkInfo.State.RUNNING))
.build()
wm.getWorkInfos(workQuery).get()
// Should only return one work but just in case
.forEach {
wm.cancelWorkById(it.id)
// Re-enqueue cancelled scheduled work
if (it.tags.contains(WORK_NAME_AUTO)) {
setupTask(context)
}
}
}
}
}

View File

@ -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<LibraryManga> = 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<Target>(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<Manga>()
val newUpdates = CopyOnWriteArrayList<Pair<Manga, Array<Chapter>>>()
val skippedUpdates = CopyOnWriteArrayList<Pair<Manga, String?>>()
val failedUpdates = CopyOnWriteArrayList<Pair<Manga, String?>>()
val hasDownloads = AtomicBoolean(false)
val loggedServices by lazy { trackManager.services.filter { it.isLogged } }
val restrictions = libraryPreferences.libraryUpdateMangaRestriction().get()
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<Chapter>) {
// We don't want to start downloading while the library is updating, because websites
// may don't like it and they could ban the user.
// 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<TrackService>): List<Chapter> {
val source = sourceManager.getOrStub(manga.source)
// Update manga metadata if needed
if (libraryPreferences.autoUpdateMetadata().get()) {
val networkManga = source.getMangaDetails(manga.toSManga())
updateManga.awaitUpdateFromSource(manga, networkManga, manualFetch = false, coverCache)
}
// 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<Manga>()
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<TrackService>) {
getTracks.await(manga.id)
.map { track ->
supervisorScope {
async {
val service = trackManager.getService(track.syncId)
if (service != null && service in loggedServices) {
try {
val updatedTrack = service.refresh(track.toDbTrack())
insertTrack.await(updatedTrack.toDomainTrack()!!)
if (service is EnhancedTrackService) {
val chapters = getChapterByMangaId.await(manga.id)
syncChaptersWithTrackServiceTwoWay.await(chapters, track, service)
}
} catch (e: Throwable) {
// Ignore errors and continue
logcat(LogPriority.ERROR, e)
}
}
}
}
}
.awaitAll()
}
private suspend fun withUpdateNotification(
updatingManga: CopyOnWriteArrayList<Manga>,
completed: AtomicInteger,
manga: Manga,
block: suspend () -> Unit,
) {
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<UnsortedPreferences>()
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<Pair<Manga, String?>>): 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"

View File

@ -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) {

View File

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

View File

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

View File

@ -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<Application>())
val started = LibraryUpdateJob.startNow(Injekt.get<Application>())
coroutineScope.launch {
_events.send(Event.LibraryUpdateTriggered(started))
}

View File

@ -515,6 +515,10 @@ object EXHMigrations {
val trackManager = Injekt.get<TrackManager>()
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