diff --git a/app/src/main/java/eu/kanade/domain/chapter/interactor/SyncChaptersWithSource.kt b/app/src/main/java/eu/kanade/domain/chapter/interactor/SyncChaptersWithSource.kt index 9231cbac465..1980e9afc0a 100644 --- a/app/src/main/java/eu/kanade/domain/chapter/interactor/SyncChaptersWithSource.kt +++ b/app/src/main/java/eu/kanade/domain/chapter/interactor/SyncChaptersWithSource.kt @@ -79,12 +79,31 @@ class SyncChaptersWithSource( val newChapters = mutableListOf() val updatedChapters = mutableListOf() - val removedChapters = dbChapters.filterNot { dbChapter -> + val chaptersMissingFromSource = dbChapters.filterNot { dbChapter -> sourceChapters.any { sourceChapter -> dbChapter.url == sourceChapter.url } } + // A queued chapter that no longer exists at the source cannot make progress. + val orphanedDownloads = chaptersMissingFromSource.mapNotNull { chapter -> + downloadManager.getQueuedDownloadOrNull(chapter.id) + } + if (orphanedDownloads.isNotEmpty()) { + downloadManager.cancelQueuedDownloads(orphanedDownloads) + } + + // Keep completed downloads accessible even after the source stops listing them. + val removedChapters = chaptersMissingFromSource.filterNot { chapter -> + downloadManager.isChapterDownloaded( + chapter.name, + chapter.scanlator, + chapter.url, + manga.title, + manga.source, + ) + } + // Used to not set upload date of older chapters // to a higher value than newer chapters var maxSeenUploadDate = 0L diff --git a/app/src/test/java/eu/kanade/domain/chapter/interactor/SyncChaptersWithSourceTest.kt b/app/src/test/java/eu/kanade/domain/chapter/interactor/SyncChaptersWithSourceTest.kt new file mode 100644 index 00000000000..e725bd6625e --- /dev/null +++ b/app/src/test/java/eu/kanade/domain/chapter/interactor/SyncChaptersWithSourceTest.kt @@ -0,0 +1,201 @@ +package eu.kanade.domain.chapter.interactor + +import eu.kanade.domain.manga.interactor.GetExcludedScanlators +import eu.kanade.domain.manga.interactor.UpdateManga +import eu.kanade.tachiyomi.data.download.DownloadManager +import eu.kanade.tachiyomi.data.download.DownloadProvider +import eu.kanade.tachiyomi.data.download.model.Download +import eu.kanade.tachiyomi.source.Source +import eu.kanade.tachiyomi.source.model.SChapter +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.coVerifyOrder +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +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.repository.ChapterRepository +import tachiyomi.domain.library.service.LibraryPreferences +import tachiyomi.domain.manga.model.Manga + +class SyncChaptersWithSourceTest { + + private lateinit var downloadManager: DownloadManager + private lateinit var chapterRepository: ChapterRepository + private lateinit var getChaptersByMangaId: GetChaptersByMangaId + private lateinit var syncChaptersWithSource: SyncChaptersWithSource + + private val manga = Manga.create().copy( + id = MANGA_ID, + source = SOURCE_ID, + title = "Test Manga", + fetchInterval = 1, + nextUpdate = Long.MAX_VALUE, + ) + private val source = mockk { + every { id } returns SOURCE_ID + } + private val presentChapter = chapter( + id = 1, + url = "/chapter-1", + name = "Chapter 1", + number = 1.0, + sourceOrder = 0, + ) + private val removedChapter = chapter( + id = 2, + url = "/chapter-2", + name = "Chapter 2", + number = 2.0, + sourceOrder = 1, + ) + private val sourceChapters = listOf( + sourceChapter( + url = presentChapter.url, + name = presentChapter.name, + number = presentChapter.chapterNumber.toFloat(), + ), + ) + + @BeforeEach + fun setUp() { + downloadManager = mockk(relaxed = true) + chapterRepository = mockk(relaxed = true) + getChaptersByMangaId = mockk() + + val libraryPreferences = mockk() + every { libraryPreferences.markDuplicateReadChapterAsRead.get() } returns emptySet() + + val getExcludedScanlators = mockk() + coEvery { getExcludedScanlators.await(any()) } returns emptySet() + + every { downloadManager.getQueuedDownloadOrNull(any()) } returns null + every { + downloadManager.isChapterDownloaded(any(), any(), any(), any(), any(), any()) + } returns false + coEvery { getChaptersByMangaId.await(MANGA_ID) } returns listOf(presentChapter, removedChapter) + + syncChaptersWithSource = SyncChaptersWithSource( + downloadManager = downloadManager, + downloadProvider = mockk(relaxed = true), + chapterRepository = chapterRepository, + shouldUpdateDbChapter = ShouldUpdateDbChapter(), + updateManga = mockk(relaxed = true), + updateChapter = mockk(relaxed = true), + getChaptersByMangaId = getChaptersByMangaId, + getExcludedScanlators = getExcludedScanlators, + libraryPreferences = libraryPreferences, + ) + } + + @Test + fun `keeps a downloaded chapter that is missing from the source`() = runTest { + every { + downloadManager.isChapterDownloaded( + removedChapter.name, + removedChapter.scanlator, + removedChapter.url, + manga.title, + manga.source, + false, + ) + } returns true + + syncChaptersWithSource.await(sourceChapters, manga, source) + + coVerify(exactly = 0) { chapterRepository.removeChaptersWithIds(any()) } + verify(exactly = 0) { downloadManager.cancelQueuedDownloads(any()) } + } + + @Test + fun `cancels a queued chapter before removing it when it is missing from the source`() = runTest { + val download = mockk() + every { downloadManager.getQueuedDownloadOrNull(removedChapter.id) } returns download + + syncChaptersWithSource.await(sourceChapters, manga, source) + + coVerifyOrder { + downloadManager.cancelQueuedDownloads(listOf(download)) + chapterRepository.removeChaptersWithIds(listOf(removedChapter.id)) + } + } + + @Test + fun `removes a missing chapter that is neither downloaded nor queued`() = runTest { + syncChaptersWithSource.await(sourceChapters, manga, source) + + coVerify { chapterRepository.removeChaptersWithIds(listOf(removedChapter.id)) } + verify(exactly = 0) { downloadManager.cancelQueuedDownloads(any()) } + } + + @Test + fun `keeps downloaded chapters while canceling and removing queued chapters in the same sync`() = runTest { + val queuedChapter = chapter( + id = 3, + url = "/chapter-3", + name = "Chapter 3", + number = 3.0, + sourceOrder = 2, + ) + val queuedDownload = mockk() + coEvery { + getChaptersByMangaId.await(MANGA_ID) + } returns listOf(presentChapter, removedChapter, queuedChapter) + every { downloadManager.getQueuedDownloadOrNull(queuedChapter.id) } returns queuedDownload + every { + downloadManager.isChapterDownloaded( + removedChapter.name, + removedChapter.scanlator, + removedChapter.url, + manga.title, + manga.source, + false, + ) + } returns true + + syncChaptersWithSource.await(sourceChapters, manga, source) + + coVerifyOrder { + downloadManager.cancelQueuedDownloads(listOf(queuedDownload)) + chapterRepository.removeChaptersWithIds(listOf(queuedChapter.id)) + } + } + + private companion object { + const val MANGA_ID = 1L + const val SOURCE_ID = 2L + + fun chapter( + id: Long, + url: String, + name: String, + number: Double, + sourceOrder: Long, + ) = Chapter.create().copy( + id = id, + mangaId = MANGA_ID, + url = url, + name = name, + chapterNumber = number, + dateUpload = 1_000, + sourceOrder = sourceOrder, + ) + + fun sourceChapter( + url: String, + name: String, + number: Float, + ) = SChapter.create().apply { + this.url = url + this.name = name + chapter_number = number + date_upload = 1_000 + } + } +}