Apply chapter sync in one transaction

A chapter sync's removals, inserts and updates go through one ChapterRepository.updateFromRemote call, applied atomically. A failure no longer leaves some of a sync's changes written and others missing. Restore folds duplicate chapters in a backup into one before writing, and the chapter deep link looks up the synced chapter afterwards rather than relying on the sync's return value.
This commit is contained in:
AntsyLich
2026-09-27 22:41:55 +06:00
parent 4df0e6b168
commit 6ee529c5aa
6 changed files with 71 additions and 85 deletions
@@ -15,9 +15,7 @@ import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import tachiyomi.data.chapter.ChapterSanitizer
import tachiyomi.domain.chapter.interactor.GetChaptersByMangaId
import tachiyomi.domain.chapter.interactor.ShouldUpdateDbChapter
import tachiyomi.domain.chapter.interactor.UpdateChapter
import tachiyomi.domain.chapter.model.Chapter
import tachiyomi.domain.chapter.model.ChapterRemoteUpdate
import tachiyomi.domain.chapter.model.NoChaptersException
@@ -37,8 +35,6 @@ class SyncChaptersWithSource(
private val chapterRepository: ChapterRepository,
private val shouldUpdateDbChapter: ShouldUpdateDbChapter,
private val updateManga: UpdateManga,
private val updateChapter: UpdateChapter,
private val getChaptersByMangaId: GetChaptersByMangaId,
private val getExcludedScanlators: GetExcludedScanlators,
private val libraryPreferences: LibraryPreferences,
) {
@@ -75,15 +71,12 @@ class SyncChaptersWithSource(
.copy(mangaId = manga.id, sourceOrder = i.toLong())
}
val dbChapters = getChaptersByMangaId.await(manga.id)
val dbChapters = chapterRepository.getChapterByMangaId(manga.id)
val dbChaptersByUrl = dbChapters.associateBy { it.url }
val newChapters = mutableListOf<Chapter>()
val updatedChapters = mutableListOf<ChapterRemoteUpdate>()
val removedChapters = dbChapters.filterNot { dbChapter ->
sourceChapters.any { sourceChapter ->
dbChapter.url == sourceChapter.url
}
}
val sourceUrls = mutableSetOf<String>()
// Used to not set upload date of older chapters
// to a higher value than newer chapters
@@ -100,11 +93,13 @@ class SyncChaptersWithSource(
chapter = chapter.copyFromSChapter(sChapter)
}
if (!sourceUrls.add(chapter.url)) continue
// Recognize chapter number for the chapter.
val chapterNumber = ChapterRecognition.parseChapterNumber(manga.title, chapter.name, chapter.chapterNumber)
chapter = chapter.copy(chapterNumber = chapterNumber)
val dbChapter = dbChapters.find { it.url == chapter.url }
val dbChapter = dbChaptersByUrl[chapter.url]
if (dbChapter == null) {
val toAddChapter = if (chapter.dateUpload == 0L) {
@@ -145,6 +140,8 @@ class SyncChaptersWithSource(
}
}
val removedChapters = dbChapters.filterNot { it.url in sourceUrls }
// Return if there's nothing to add, delete, or update to avoid unnecessary db transactions.
if (newChapters.isEmpty() && removedChapters.isEmpty() && updatedChapters.isEmpty()) {
if (manualFetch || manga.fetchInterval == 0 || manga.nextUpdate < fetchWindow.first) {
@@ -185,7 +182,7 @@ class SyncChaptersWithSource(
// Date fetch is set in such a way that the upper ones will have bigger value than the lower ones
// Sources MUST return the chapters from most to less recent, which is common.
var itemCount = newChapters.size
var updatedToAdd = newChapters.map { toAddItem ->
val toAdd = newChapters.map { toAddItem ->
var chapter = toAddItem.copy(dateFetch = nowMillis + itemCount--)
if (chapter.chapterNumber in readChapterNumbers && markDuplicateAsRead) {
@@ -210,18 +207,11 @@ class SyncChaptersWithSource(
chapter
}
if (removedChapters.isNotEmpty()) {
val toDeleteIds = removedChapters.map { it.id }
chapterRepository.removeChaptersWithIds(toDeleteIds)
}
if (updatedToAdd.isNotEmpty()) {
updatedToAdd = chapterRepository.addAll(updatedToAdd)
}
if (updatedChapters.isNotEmpty()) {
updateChapter.awaitAllRemote(updatedChapters)
}
val added = chapterRepository.updateFromRemote(
removedIds = removedChapters.map { it.id },
added = toAdd,
updated = updatedChapters,
)
updateManga.awaitUpdateFetchInterval(manga, timeZone, now, fetchWindow)
// Set this manga as updated since chapters were changed
@@ -230,6 +220,6 @@ class SyncChaptersWithSource(
val excludedScanlators = getExcludedScanlators.await(manga.id).toHashSet()
return updatedToAdd.filterNot { it.url in changedOrDuplicateReadUrls || it.scanlator in excludedScanlators }
return added.filterNot { it.url in changedOrDuplicateReadUrls || it.scanlator in excludedScanlators }
}
}
@@ -78,11 +78,9 @@ class DeepLinkViewModel(
private suspend fun getChapterFromSChapter(sChapter: SChapter, manga: Manga, source: Source): Chapter? {
val localChapter = getChapterByUrlAndMangaId.await(sChapter.url, manga.id)
return localChapter
?: updateMangaFromRemote(manga, fetchChapters = true)
.getOrElse { return null }
.newChapters
.find { it.url == sChapter.url }
if (localChapter != null) return localChapter
updateMangaFromRemote(manga, fetchChapters = true).getOrElse { return null }
return getChapterByUrlAndMangaId.await(sChapter.url, manga.id)
}
sealed interface State {