Retry tracker after errors

This commit is contained in:
Koitharu
2023-08-12 13:55:13 +03:00
parent c83538f66d
commit 1c4bd6da28
4 changed files with 89 additions and 30 deletions

View File

@@ -67,11 +67,15 @@ class Tracker @Inject constructor(
return result return result
} }
suspend fun getTracks(ids: Set<Long>): List<TrackingItem> {
return getAllTracks().filterTo(ArrayList(ids.size)) { x -> x.tracking.manga.id in ids }
}
suspend fun gc() { suspend fun gc() {
repository.gc() repository.gc()
} }
suspend fun fetchUpdates(track: MangaTracking, commit: Boolean): MangaUpdates { suspend fun fetchUpdates(track: MangaTracking, commit: Boolean): MangaUpdates.Success {
val manga = mangaRepositoryFactory.create(track.manga.source).getDetails(track.manga) val manga = mangaRepositoryFactory.create(track.manga.source).getDetails(track.manga)
val updates = compare(track, manga, getBranch(manga)) val updates = compare(track, manga, getBranch(manga))
if (commit) { if (commit) {
@@ -103,24 +107,24 @@ class Tracker @Inject constructor(
/** /**
* The main functionality of tracker: check new chapters in [manga] comparing to the [track] * The main functionality of tracker: check new chapters in [manga] comparing to the [track]
*/ */
private fun compare(track: MangaTracking, manga: Manga, branch: String?): MangaUpdates { private fun compare(track: MangaTracking, manga: Manga, branch: String?): MangaUpdates.Success {
if (track.isEmpty()) { if (track.isEmpty()) {
// first check or manga was empty on last check // first check or manga was empty on last check
return MangaUpdates(manga, emptyList(), isValid = false) return MangaUpdates.Success(manga, emptyList(), isValid = false)
} }
val chapters = requireNotNull(manga.getChapters(branch)) val chapters = requireNotNull(manga.getChapters(branch))
val newChapters = chapters.takeLastWhile { x -> x.id != track.lastChapterId } val newChapters = chapters.takeLastWhile { x -> x.id != track.lastChapterId }
return when { return when {
newChapters.isEmpty() -> { newChapters.isEmpty() -> {
MangaUpdates(manga, emptyList(), isValid = chapters.lastOrNull()?.id == track.lastChapterId) MangaUpdates.Success(manga, emptyList(), isValid = chapters.lastOrNull()?.id == track.lastChapterId)
} }
newChapters.size == chapters.size -> { newChapters.size == chapters.size -> {
MangaUpdates(manga, emptyList(), isValid = false) MangaUpdates.Success(manga, emptyList(), isValid = false)
} }
else -> { else -> {
MangaUpdates(manga, newChapters, isValid = true) MangaUpdates.Success(manga, newChapters, isValid = true)
} }
} }
} }

View File

@@ -118,7 +118,7 @@ class TrackingRepository @Inject constructor(
db.trackLogsDao.gc() db.trackLogsDao.gc()
} }
suspend fun saveUpdates(updates: MangaUpdates) { suspend fun saveUpdates(updates: MangaUpdates.Success) {
db.withTransaction { db.withTransaction {
val track = getOrCreateTrack(updates.manga.id).mergeWith(updates) val track = getOrCreateTrack(updates.manga.id).mergeWith(updates)
db.tracksDao.upsert(track) db.tracksDao.upsert(track)
@@ -199,7 +199,7 @@ class TrackingRepository @Inject constructor(
) )
} }
private suspend fun updatePercent(updates: MangaUpdates) { private suspend fun updatePercent(updates: MangaUpdates.Success) {
val history = db.historyDao.find(updates.manga.id) ?: return val history = db.historyDao.find(updates.manga.id) ?: return
val chapters = updates.manga.chapters val chapters = updates.manga.chapters
if (chapters.isNullOrEmpty()) { if (chapters.isNullOrEmpty()) {
@@ -214,7 +214,7 @@ class TrackingRepository @Inject constructor(
db.historyDao.update(history.copy(percent = newPercent)) db.historyDao.update(history.copy(percent = newPercent))
} }
private fun TrackEntity.mergeWith(updates: MangaUpdates): TrackEntity { private fun TrackEntity.mergeWith(updates: MangaUpdates.Success): TrackEntity {
val chapters = updates.manga.chapters.orEmpty() val chapters = updates.manga.chapters.orEmpty()
return TrackEntity( return TrackEntity(
mangaId = mangaId, mangaId = mangaId,

View File

@@ -1,13 +1,27 @@
package org.koitharu.kotatsu.tracker.domain.model package org.koitharu.kotatsu.tracker.domain.model
import org.koitharu.kotatsu.core.exceptions.TooManyRequestExceptions
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
class MangaUpdates( sealed interface MangaUpdates {
val manga: Manga,
val newChapters: List<MangaChapter>,
val isValid: Boolean,
) {
fun isNotEmpty() = newChapters.isNotEmpty() val manga: Manga
}
class Success(
override val manga: Manga,
val newChapters: List<MangaChapter>,
val isValid: Boolean,
) : MangaUpdates {
fun isNotEmpty() = newChapters.isNotEmpty()
}
class Failure(
override val manga: Manga,
val error: Throwable?,
) : MangaUpdates {
fun shouldRetry() = error is TooManyRequestExceptions
}
}

View File

@@ -10,6 +10,7 @@ import androidx.core.app.NotificationCompat.VISIBILITY_SECRET
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.core.app.PendingIntentCompat import androidx.core.app.PendingIntentCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.content.edit
import androidx.hilt.work.HiltWorker import androidx.hilt.work.HiltWorker
import androidx.lifecycle.asFlow import androidx.lifecycle.asFlow
import androidx.work.BackoffPolicy import androidx.work.BackoffPolicy
@@ -32,12 +33,14 @@ import coil.request.ImageRequest
import dagger.Reusable import dagger.Reusable
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.toList import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -54,6 +57,7 @@ import org.koitharu.kotatsu.core.util.ext.trySetForeground
import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.settings.work.PeriodicWorkScheduler import org.koitharu.kotatsu.settings.work.PeriodicWorkScheduler
import org.koitharu.kotatsu.tracker.domain.Tracker import org.koitharu.kotatsu.tracker.domain.Tracker
@@ -75,7 +79,7 @@ class TrackWorker @AssistedInject constructor(
override suspend fun doWork(): Result { override suspend fun doWork(): Result {
trySetForeground() trySetForeground()
logger.log("doWork()") logger.log("doWork(): attempt $runAttemptCount")
return try { return try {
doWorkImpl() doWorkImpl()
} catch (e: Throwable) { } catch (e: Throwable) {
@@ -93,7 +97,12 @@ class TrackWorker @AssistedInject constructor(
if (!settings.isTrackerEnabled) { if (!settings.isTrackerEnabled) {
return Result.success(workDataOf(0, 0)) return Result.success(workDataOf(0, 0))
} }
val tracks = tracker.getAllTracks() val retryIds = getRetryIds()
val tracks = if (retryIds.isNotEmpty()) {
tracker.getTracks(retryIds)
} else {
tracker.getAllTracks()
}
logger.log("Total ${tracks.size} tracks") logger.log("Total ${tracks.size} tracks")
if (tracks.isEmpty()) { if (tracks.isEmpty()) {
return Result.success(workDataOf(0, 0)) return Result.success(workDataOf(0, 0))
@@ -104,23 +113,32 @@ class TrackWorker @AssistedInject constructor(
var success = 0 var success = 0
var failed = 0 var failed = 0
val retry = HashSet<Long>()
results.forEach { x -> results.forEach { x ->
if (x == null) { when (x) {
failed++ is MangaUpdates.Success -> success++
} else { is MangaUpdates.Failure -> {
success++ failed++
if (x.shouldRetry()) {
retry += x.manga.id
}
}
} }
} }
logger.log("Result: success: $success, failed: $failed") if (runAttemptCount > MAX_ATTEMPTS) {
retry.clear()
}
setRetryIds(retry)
logger.log("Result: success: $success, failed: $failed, retry: ${retry.size}")
val resultData = workDataOf(success, failed) val resultData = workDataOf(success, failed)
return if (success == 0 && failed != 0) { return when {
Result.failure(resultData) retry.isNotEmpty() -> Result.retry()
} else { success == 0 && failed != 0 -> Result.failure(resultData)
Result.success(resultData) else -> Result.success(resultData)
} }
} }
private suspend fun checkUpdatesAsync(tracks: List<TrackingItem>): List<MangaUpdates?> { private suspend fun checkUpdatesAsync(tracks: List<TrackingItem>): List<MangaUpdates> {
val semaphore = Semaphore(MAX_PARALLELISM) val semaphore = Semaphore(MAX_PARALLELISM)
return channelFlow { return channelFlow {
for ((track, channelId) in tracks) { for ((track, channelId) in tracks) {
@@ -142,7 +160,12 @@ class TrackWorker @AssistedInject constructor(
newChapters = updates.newChapters, newChapters = updates.newChapters,
) )
} }
}.getOrNull(), }.getOrElse { error ->
MangaUpdates.Failure(
manga = track.manga,
error = error,
)
},
) )
} }
} }
@@ -238,6 +261,22 @@ class TrackWorker @AssistedInject constructor(
return ForegroundInfo(WORKER_NOTIFICATION_ID, notification) return ForegroundInfo(WORKER_NOTIFICATION_ID, notification)
} }
private suspend fun setRetryIds(ids: Set<Long>) = runInterruptible(Dispatchers.IO) {
val prefs = applicationContext.getSharedPreferences(TAG, Context.MODE_PRIVATE)
prefs.edit(commit = true) {
if (ids.isEmpty()) {
remove(KEY_RETRY_IDS)
} else {
putStringSet(KEY_RETRY_IDS, ids.mapToSet { it.toString() })
}
}
}
private fun getRetryIds(): Set<Long> {
val prefs = applicationContext.getSharedPreferences(TAG, Context.MODE_PRIVATE)
return prefs.getStringSet(KEY_RETRY_IDS, null)?.mapToSet { it.toLong() }.orEmpty()
}
private fun workDataOf(success: Int, failed: Int): Data { private fun workDataOf(success: Int, failed: Int): Data {
return Data.Builder() return Data.Builder()
.putInt(DATA_KEY_SUCCESS, success) .putInt(DATA_KEY_SUCCESS, success)
@@ -256,7 +295,7 @@ class TrackWorker @AssistedInject constructor(
val request = PeriodicWorkRequestBuilder<TrackWorker>(4, TimeUnit.HOURS) val request = PeriodicWorkRequestBuilder<TrackWorker>(4, TimeUnit.HOURS)
.setConstraints(constraints) .setConstraints(constraints)
.addTag(TAG) .addTag(TAG)
.setBackoffCriteria(BackoffPolicy.LINEAR, 30, TimeUnit.MINUTES) .setBackoffCriteria(BackoffPolicy.LINEAR, 5, TimeUnit.MINUTES)
.build() .build()
workManager workManager
.enqueueUniquePeriodicWork(TAG, ExistingPeriodicWorkPolicy.UPDATE, request) .enqueueUniquePeriodicWork(TAG, ExistingPeriodicWorkPolicy.UPDATE, request)
@@ -306,7 +345,9 @@ class TrackWorker @AssistedInject constructor(
const val TAG = "tracking" const val TAG = "tracking"
const val TAG_ONESHOT = "tracking_oneshot" const val TAG_ONESHOT = "tracking_oneshot"
const val MAX_PARALLELISM = 3 const val MAX_PARALLELISM = 3
const val MAX_ATTEMPTS = 4
const val DATA_KEY_SUCCESS = "success" const val DATA_KEY_SUCCESS = "success"
const val DATA_KEY_FAILED = "failed" const val DATA_KEY_FAILED = "failed"
const val KEY_RETRY_IDS = "retry"
} }
} }