From cb639f4e90121b98221ba7358108200c46b6657f Mon Sep 17 00:00:00 2001 From: Quang Kieu Date: Sun, 23 Jul 2023 18:12:01 -0400 Subject: [PATCH] Update Manga in Expected Period (#5734) * Add Predict Interval Test * Get mangas next update and interval in library update * Get next update and interval in backup restore * Display and set intervals, nextUpdate in Manga Info * Move logic function to MangeScreen and InfoHeader Update per suggestion --------- Co-authored-by: arkon --- .../java/eu/kanade/domain/DomainModule.kt | 4 +- .../interactor/SyncChaptersWithSource.kt | 14 ++ .../domain/manga/interactor/UpdateManga.kt | 15 +- .../kanade/presentation/manga/MangaScreen.kt | 16 ++ .../manga/components/MangaDialogs.kt | 60 ++++++ .../manga/components/MangaInfoHeader.kt | 21 +- .../settings/screen/SettingsLibraryScreen.kt | 15 +- .../tachiyomi/data/backup/BackupRestorer.kt | 24 ++- .../data/library/LibraryUpdateJob.kt | 17 +- .../kanade/tachiyomi/ui/manga/MangaScreen.kt | 13 +- .../tachiyomi/ui/manga/MangaScreenModel.kt | 50 ++++- .../interactor/SetMangaUpdateInterval.kt | 200 +++++++++--------- .../interactor/SetMangaUpdateIntervalTest.kt | 135 ++++++++++++ i18n/src/main/res/values/strings.xml | 7 +- 14 files changed, 460 insertions(+), 131 deletions(-) create mode 100644 domain/src/test/java/tachiyomi/domain/manga/interactor/SetMangaUpdateIntervalTest.kt diff --git a/app/src/main/java/eu/kanade/domain/DomainModule.kt b/app/src/main/java/eu/kanade/domain/DomainModule.kt index 58faf29148..cba97c942a 100644 --- a/app/src/main/java/eu/kanade/domain/DomainModule.kt +++ b/app/src/main/java/eu/kanade/domain/DomainModule.kt @@ -57,6 +57,7 @@ import tachiyomi.domain.manga.interactor.GetMangaWithChapters import tachiyomi.domain.manga.interactor.NetworkToLocalManga import tachiyomi.domain.manga.interactor.ResetViewerFlags import tachiyomi.domain.manga.interactor.SetMangaChapterFlags +import tachiyomi.domain.manga.interactor.SetMangaUpdateInterval import tachiyomi.domain.manga.repository.MangaRepository import tachiyomi.domain.release.interactor.GetApplicationRelease import tachiyomi.domain.release.service.ReleaseService @@ -100,10 +101,11 @@ class DomainModule : InjektModule { addFactory { GetNextChapters(get(), get(), get()) } addFactory { ResetViewerFlags(get()) } addFactory { SetMangaChapterFlags(get()) } + addFactory { SetMangaUpdateInterval(get()) } addFactory { SetMangaDefaultChapterFlags(get(), get(), get()) } addFactory { SetMangaViewerFlags(get()) } addFactory { NetworkToLocalManga(get()) } - addFactory { UpdateManga(get()) } + addFactory { UpdateManga(get(), get()) } addFactory { SetMangaCategories(get()) } addSingletonFactory { ReleaseServiceImpl(get(), get()) } 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 41520adddb..24d0bb32a1 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 @@ -23,6 +23,7 @@ import tachiyomi.source.local.isLocal import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get import java.lang.Long.max +import java.time.ZonedDateTime import java.util.Date import java.util.TreeSet @@ -48,6 +49,9 @@ class SyncChaptersWithSource( rawSourceChapters: List, manga: Manga, source: Source, + manualFetch: Boolean = false, + zoneDateTime: ZonedDateTime = ZonedDateTime.now(), + fetchRange: Pair = Pair(0, 0), ): List { if (rawSourceChapters.isEmpty() && !source.isLocal()) { throw NoChaptersException() @@ -134,6 +138,14 @@ class SyncChaptersWithSource( // Return if there's nothing to add, delete or change, avoiding unnecessary db transactions. if (toAdd.isEmpty() && toDelete.isEmpty() && toChange.isEmpty()) { + if (manualFetch || manga.calculateInterval == 0 || manga.nextUpdate < fetchRange.first) { + updateManga.awaitUpdateFetchInterval( + manga, + dbChapters, + zoneDateTime, + fetchRange, + ) + } return emptyList() } @@ -188,6 +200,8 @@ class SyncChaptersWithSource( val chapterUpdates = toChange.map { it.toChapterUpdate() } updateChapter.awaitAll(chapterUpdates) } + val newChapters = chapterRepository.getChapterByMangaId(manga.id) + updateManga.awaitUpdateFetchInterval(manga, newChapters, zoneDateTime, fetchRange) // Set this manga as updated since chapters were changed // Note that last_update actually represents last time the chapter list changed at all diff --git a/app/src/main/java/eu/kanade/domain/manga/interactor/UpdateManga.kt b/app/src/main/java/eu/kanade/domain/manga/interactor/UpdateManga.kt index cb1727aa22..da610baa37 100644 --- a/app/src/main/java/eu/kanade/domain/manga/interactor/UpdateManga.kt +++ b/app/src/main/java/eu/kanade/domain/manga/interactor/UpdateManga.kt @@ -4,8 +4,7 @@ import eu.kanade.domain.manga.model.hasCustomCover import eu.kanade.tachiyomi.data.cache.CoverCache import eu.kanade.tachiyomi.source.model.SManga import tachiyomi.domain.chapter.model.Chapter -import tachiyomi.domain.manga.interactor.getCurrentFetchRange -import tachiyomi.domain.manga.interactor.updateIntervalMeta +import tachiyomi.domain.manga.interactor.SetMangaUpdateInterval import tachiyomi.domain.manga.model.Manga import tachiyomi.domain.manga.model.MangaUpdate import tachiyomi.domain.manga.repository.MangaRepository @@ -17,6 +16,7 @@ import java.util.Date class UpdateManga( private val mangaRepository: MangaRepository, + private val setMangaUpdateInterval: SetMangaUpdateInterval, ) { suspend fun await(mangaUpdate: MangaUpdate): Boolean { @@ -77,16 +77,15 @@ class UpdateManga( ) } - suspend fun awaitUpdateIntervalMeta( + suspend fun awaitUpdateFetchInterval( manga: Manga, chapters: List, zonedDateTime: ZonedDateTime = ZonedDateTime.now(), - setCurrentFetchRange: Pair = getCurrentFetchRange(zonedDateTime), + fetchRange: Pair = setMangaUpdateInterval.getCurrentFetchRange(zonedDateTime), ): Boolean { - val newMeta = updateIntervalMeta(manga, chapters, zonedDateTime, setCurrentFetchRange) - - return if (newMeta != null) { - mangaRepository.update(newMeta) + val updatedManga = setMangaUpdateInterval.updateInterval(manga, chapters, zonedDateTime, fetchRange) + return if (updatedManga != null) { + mangaRepository.update(updatedManga) } else { true } diff --git a/app/src/main/java/eu/kanade/presentation/manga/MangaScreen.kt b/app/src/main/java/eu/kanade/presentation/manga/MangaScreen.kt index 03030cb4e1..fe906c8fd1 100644 --- a/app/src/main/java/eu/kanade/presentation/manga/MangaScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/manga/MangaScreen.kt @@ -85,6 +85,7 @@ fun MangaScreen( state: MangaScreenModel.State.Success, snackbarHostState: SnackbarHostState, dateRelativeTime: Int, + intervalDisplay: () -> Pair?, dateFormat: DateFormat, isTabletUi: Boolean, chapterSwipeStartAction: LibraryPreferences.ChapterSwipeAction, @@ -112,6 +113,7 @@ fun MangaScreen( onShareClicked: (() -> Unit)?, onDownloadActionClicked: ((DownloadAction) -> Unit)?, onEditCategoryClicked: (() -> Unit)?, + onEditIntervalClicked: (() -> Unit)?, onMigrateClicked: (() -> Unit)?, // For bottom action menu @@ -141,6 +143,7 @@ fun MangaScreen( snackbarHostState = snackbarHostState, dateRelativeTime = dateRelativeTime, dateFormat = dateFormat, + intervalDisplay = intervalDisplay, chapterSwipeStartAction = chapterSwipeStartAction, chapterSwipeEndAction = chapterSwipeEndAction, onBackClicked = onBackClicked, @@ -160,6 +163,7 @@ fun MangaScreen( onShareClicked = onShareClicked, onDownloadActionClicked = onDownloadActionClicked, onEditCategoryClicked = onEditCategoryClicked, + onEditIntervalClicked = onEditIntervalClicked, onMigrateClicked = onMigrateClicked, onMultiBookmarkClicked = onMultiBookmarkClicked, onMultiMarkAsReadClicked = onMultiMarkAsReadClicked, @@ -178,6 +182,7 @@ fun MangaScreen( chapterSwipeStartAction = chapterSwipeStartAction, chapterSwipeEndAction = chapterSwipeEndAction, dateFormat = dateFormat, + intervalDisplay = intervalDisplay, onBackClicked = onBackClicked, onChapterClicked = onChapterClicked, onDownloadChapter = onDownloadChapter, @@ -195,6 +200,7 @@ fun MangaScreen( onShareClicked = onShareClicked, onDownloadActionClicked = onDownloadActionClicked, onEditCategoryClicked = onEditCategoryClicked, + onEditIntervalClicked = onEditIntervalClicked, onMigrateClicked = onMigrateClicked, onMultiBookmarkClicked = onMultiBookmarkClicked, onMultiMarkAsReadClicked = onMultiMarkAsReadClicked, @@ -214,6 +220,7 @@ private fun MangaScreenSmallImpl( snackbarHostState: SnackbarHostState, dateRelativeTime: Int, dateFormat: DateFormat, + intervalDisplay: () -> Pair?, chapterSwipeStartAction: LibraryPreferences.ChapterSwipeAction, chapterSwipeEndAction: LibraryPreferences.ChapterSwipeAction, onBackClicked: () -> Unit, @@ -240,6 +247,7 @@ private fun MangaScreenSmallImpl( onShareClicked: (() -> Unit)?, onDownloadActionClicked: ((DownloadAction) -> Unit)?, onEditCategoryClicked: (() -> Unit)?, + onEditIntervalClicked: (() -> Unit)?, onMigrateClicked: (() -> Unit)?, // For bottom action menu @@ -383,10 +391,13 @@ private fun MangaScreenSmallImpl( MangaActionRow( favorite = state.manga.favorite, trackingCount = state.trackingCount, + intervalDisplay = intervalDisplay, + isUserIntervalMode = state.manga.calculateInterval < 0, onAddToLibraryClicked = onAddToLibraryClicked, onWebViewClicked = onWebViewClicked, onWebViewLongClicked = onWebViewLongClicked, onTrackingClicked = onTrackingClicked, + onEditIntervalClicked = onEditIntervalClicked, onEditCategory = onEditCategoryClicked, ) } @@ -440,6 +451,7 @@ fun MangaScreenLargeImpl( snackbarHostState: SnackbarHostState, dateRelativeTime: Int, dateFormat: DateFormat, + intervalDisplay: () -> Pair?, chapterSwipeStartAction: LibraryPreferences.ChapterSwipeAction, chapterSwipeEndAction: LibraryPreferences.ChapterSwipeAction, onBackClicked: () -> Unit, @@ -466,6 +478,7 @@ fun MangaScreenLargeImpl( onShareClicked: (() -> Unit)?, onDownloadActionClicked: ((DownloadAction) -> Unit)?, onEditCategoryClicked: (() -> Unit)?, + onEditIntervalClicked: (() -> Unit)?, onMigrateClicked: (() -> Unit)?, // For bottom action menu @@ -596,10 +609,13 @@ fun MangaScreenLargeImpl( MangaActionRow( favorite = state.manga.favorite, trackingCount = state.trackingCount, + intervalDisplay = intervalDisplay, + isUserIntervalMode = state.manga.calculateInterval < 0, onAddToLibraryClicked = onAddToLibraryClicked, onWebViewClicked = onWebViewClicked, onWebViewLongClicked = onWebViewLongClicked, onTrackingClicked = onTrackingClicked, + onEditIntervalClicked = onEditIntervalClicked, onEditCategory = onEditCategoryClicked, ) ExpandableMangaDescription( diff --git a/app/src/main/java/eu/kanade/presentation/manga/components/MangaDialogs.kt b/app/src/main/java/eu/kanade/presentation/manga/components/MangaDialogs.kt index 9b23e87df7..fac4dfd370 100644 --- a/app/src/main/java/eu/kanade/presentation/manga/components/MangaDialogs.kt +++ b/app/src/main/java/eu/kanade/presentation/manga/components/MangaDialogs.kt @@ -1,11 +1,23 @@ package eu.kanade.presentation.manga.components +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.material3.AlertDialog import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp import eu.kanade.tachiyomi.R +import tachiyomi.domain.manga.interactor.MAX_GRACE_PERIOD +import tachiyomi.presentation.core.components.WheelTextPicker @Composable fun DeleteChaptersDialog( @@ -37,3 +49,51 @@ fun DeleteChaptersDialog( }, ) } + +@Composable +fun SetIntervalDialog( + interval: Int, + onDismissRequest: () -> Unit, + onValueChanged: (Int) -> Unit, +) { + var intervalValue by rememberSaveable { mutableIntStateOf(interval) } + + AlertDialog( + onDismissRequest = onDismissRequest, + title = { Text(text = stringResource(R.string.manga_modify_calculated_interval_title)) }, + text = { + BoxWithConstraints( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + val size = DpSize(width = maxWidth / 2, height = 128.dp) + val items = (0..MAX_GRACE_PERIOD).map { + if (it == 0) { + stringResource(R.string.label_default) + } else { + it.toString() + } + } + WheelTextPicker( + size = size, + items = items, + startIndex = intervalValue, + onSelectionChanged = { intervalValue = it }, + ) + } + }, + dismissButton = { + TextButton(onClick = onDismissRequest) { + Text(text = stringResource(R.string.action_cancel)) + } + }, + confirmButton = { + TextButton(onClick = { + onValueChanged(intervalValue) + onDismissRequest() + },) { + Text(text = stringResource(R.string.action_ok)) + } + }, + ) +} diff --git a/app/src/main/java/eu/kanade/presentation/manga/components/MangaInfoHeader.kt b/app/src/main/java/eu/kanade/presentation/manga/components/MangaInfoHeader.kt index a66861a227..01eaec9454 100644 --- a/app/src/main/java/eu/kanade/presentation/manga/components/MangaInfoHeader.kt +++ b/app/src/main/java/eu/kanade/presentation/manga/components/MangaInfoHeader.kt @@ -25,6 +25,7 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.HourglassEmpty import androidx.compose.material.icons.filled.Warning import androidx.compose.material.icons.outlined.AttachMoney import androidx.compose.material.icons.outlined.Block @@ -164,14 +165,19 @@ fun MangaActionRow( modifier: Modifier = Modifier, favorite: Boolean, trackingCount: Int, + intervalDisplay: () -> Pair?, + isUserIntervalMode: Boolean, onAddToLibraryClicked: () -> Unit, onWebViewClicked: (() -> Unit)?, onWebViewLongClicked: (() -> Unit)?, onTrackingClicked: (() -> Unit)?, + onEditIntervalClicked: (() -> Unit)?, onEditCategory: (() -> Unit)?, ) { + val interval: Pair? = intervalDisplay() + val defaultActionButtonColor = MaterialTheme.colorScheme.onSurface.copy(alpha = .38f) + Row(modifier = modifier.padding(start = 16.dp, top = 8.dp, end = 16.dp)) { - val defaultActionButtonColor = MaterialTheme.colorScheme.onSurface.copy(alpha = .38f) MangaActionButton( title = if (favorite) { stringResource(R.string.in_library) @@ -183,6 +189,19 @@ fun MangaActionRow( onClick = onAddToLibraryClicked, onLongClick = onEditCategory, ) + if (onEditIntervalClicked != null && interval != null) { + MangaActionButton( + title = + if (interval.first == interval.second) { + pluralStringResource(id = R.plurals.day, count = interval.second, interval.second) + } else { + pluralStringResource(id = R.plurals.range_interval_day, count = interval.second, interval.first, interval.second) + }, + icon = Icons.Default.HourglassEmpty, + color = if (isUserIntervalMode) MaterialTheme.colorScheme.primary else defaultActionButtonColor, + onClick = onEditIntervalClicked, + ) + } if (onTrackingClicked != null) { MangaActionButton( title = if (trackingCount == 0) { diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsLibraryScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsLibraryScreen.kt index e48bd5cdde..84bd7deb1e 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsLibraryScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsLibraryScreen.kt @@ -241,18 +241,15 @@ object SettingsLibraryScreen : SearchableSettings { title = stringResource(R.string.pref_library_update_refresh_trackers), subtitle = stringResource(R.string.pref_library_update_refresh_trackers_summary), ), - // TODO: remove isDevFlavor checks once functionality is available Preference.PreferenceItem.MultiSelectListPreference( pref = libraryUpdateMangaRestrictionPref, title = stringResource(R.string.pref_library_update_manga_restriction), - entries = buildMap { - put(MANGA_HAS_UNREAD, stringResource(R.string.pref_update_only_completely_read)) - put(MANGA_NON_READ, stringResource(R.string.pref_update_only_started)) - put(MANGA_NON_COMPLETED, stringResource(R.string.pref_update_only_non_completed)) - if (isDevFlavor) { - put(MANGA_OUTSIDE_RELEASE_PERIOD, stringResource(R.string.pref_update_only_in_release_period)) - } - }, + entries = mapOf( + MANGA_HAS_UNREAD to stringResource(R.string.pref_update_only_completely_read), + MANGA_NON_READ to stringResource(R.string.pref_update_only_started), + MANGA_NON_COMPLETED to stringResource(R.string.pref_update_only_non_completed), + MANGA_OUTSIDE_RELEASE_PERIOD to stringResource(R.string.pref_update_only_in_release_period), + ), ), Preference.PreferenceItem.TextPreference( title = stringResource(R.string.pref_update_release_grace_period), diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupRestorer.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupRestorer.kt index cb4d9a5801..bd568e476a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupRestorer.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupRestorer.kt @@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.data.backup import android.content.Context import android.net.Uri +import eu.kanade.domain.manga.interactor.UpdateManga import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.backup.models.BackupCategory import eu.kanade.tachiyomi.data.backup.models.BackupHistory @@ -12,10 +13,15 @@ import eu.kanade.tachiyomi.util.system.createFileInCacheDir import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.isActive import tachiyomi.domain.chapter.model.Chapter +import tachiyomi.domain.chapter.repository.ChapterRepository +import tachiyomi.domain.manga.interactor.SetMangaUpdateInterval import tachiyomi.domain.manga.model.Manga import tachiyomi.domain.track.model.Track +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get import java.io.File import java.text.SimpleDateFormat +import java.time.ZonedDateTime import java.util.Date import java.util.Locale @@ -23,6 +29,12 @@ class BackupRestorer( private val context: Context, private val notifier: BackupNotifier, ) { + private val updateManga: UpdateManga = Injekt.get() + private val chapterRepository: ChapterRepository = Injekt.get() + private val setMangaUpdateInterval: SetMangaUpdateInterval = Injekt.get() + + private var zonedDateTime = ZonedDateTime.now() + private var currentRange = setMangaUpdateInterval.getCurrentFetchRange(zonedDateTime) private var backupManager = BackupManager(context) @@ -90,6 +102,8 @@ class BackupRestorer( // Store source mapping for error messages val backupMaps = backup.backupBrokenSources.map { BackupSource(it.name, it.sourceId) } + backup.backupSources sourceMapping = backupMaps.associate { it.sourceId to it.name } + zonedDateTime = ZonedDateTime.now() + currentRange = setMangaUpdateInterval.getCurrentFetchRange(zonedDateTime) return coroutineScope { // Restore individual manga @@ -122,7 +136,7 @@ class BackupRestorer( try { val dbManga = backupManager.getMangaFromDatabase(manga.url, manga.source) - if (dbManga == null) { + val restoredManga = if (dbManga == null) { // Manga not in database restoreExistingManga(manga, chapters, categories, history, tracks, backupCategories) } else { @@ -132,6 +146,8 @@ class BackupRestorer( // Fetch rest of manga information restoreNewManga(updatedManga, chapters, categories, history, tracks, backupCategories) } + val updatedChapters = chapterRepository.getChapterByMangaId(restoredManga.id) + updateManga.awaitUpdateFetchInterval(restoredManga, updatedChapters, zonedDateTime, currentRange) } catch (e: Exception) { val sourceName = sourceMapping[manga.source] ?: manga.source.toString() errors.add(Date() to "${manga.title} [$sourceName]: ${e.message}") @@ -159,10 +175,11 @@ class BackupRestorer( history: List, tracks: List, backupCategories: List, - ) { + ): Manga { val fetchedManga = backupManager.restoreNewManga(manga) backupManager.restoreChapters(fetchedManga, chapters) restoreExtras(fetchedManga, categories, history, tracks, backupCategories) + return fetchedManga } private suspend fun restoreNewManga( @@ -172,9 +189,10 @@ class BackupRestorer( history: List, tracks: List, backupCategories: List, - ) { + ): Manga { backupManager.restoreChapters(backupManga, chapters) restoreExtras(backupManga, categories, history, tracks, backupCategories) + return backupManga } private suspend fun restoreExtras(manga: Manga, categories: List, history: List, tracks: List, backupCategories: List) { 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 bd7a39f46f..1d027c1965 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateJob.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateJob.kt @@ -66,8 +66,10 @@ import tachiyomi.domain.library.service.LibraryPreferences.Companion.DEVICE_ONLY import tachiyomi.domain.library.service.LibraryPreferences.Companion.MANGA_HAS_UNREAD import tachiyomi.domain.library.service.LibraryPreferences.Companion.MANGA_NON_COMPLETED import tachiyomi.domain.library.service.LibraryPreferences.Companion.MANGA_NON_READ +import tachiyomi.domain.library.service.LibraryPreferences.Companion.MANGA_OUTSIDE_RELEASE_PERIOD import tachiyomi.domain.manga.interactor.GetLibraryManga import tachiyomi.domain.manga.interactor.GetManga +import tachiyomi.domain.manga.interactor.SetMangaUpdateInterval import tachiyomi.domain.manga.model.Manga import tachiyomi.domain.manga.model.toMangaUpdate import tachiyomi.domain.source.model.SourceNotInstalledException @@ -77,6 +79,7 @@ import tachiyomi.domain.track.interactor.InsertTrack import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get import java.io.File +import java.time.ZonedDateTime import java.util.Date import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.TimeUnit @@ -101,6 +104,7 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet private val getTracks: GetTracks = Injekt.get() private val insertTrack: InsertTrack = Injekt.get() private val syncChaptersWithTrackServiceTwoWay: SyncChaptersWithTrackServiceTwoWay = Injekt.get() + private val setMangaUpdateInterval: SetMangaUpdateInterval = Injekt.get() private val notifier = LibraryUpdateNotifier(context) @@ -227,6 +231,10 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet val hasDownloads = AtomicBoolean(false) val restrictions = libraryPreferences.libraryUpdateMangaRestriction().get() + val now = ZonedDateTime.now() + val fetchRange = setMangaUpdateInterval.getCurrentFetchRange(now) + val higherLimit = fetchRange.second + coroutineScope { mangaToUpdate.groupBy { it.manga.source }.values .map { mangaInSource -> @@ -247,6 +255,9 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet manga, ) { when { + MANGA_OUTSIDE_RELEASE_PERIOD in restrictions && manga.nextUpdate > higherLimit -> + skippedUpdates.add(manga to context.getString(R.string.skipped_reason_not_in_release_period)) + MANGA_NON_COMPLETED in restrictions && manga.status.toInt() == SManga.COMPLETED -> skippedUpdates.add(manga to context.getString(R.string.skipped_reason_completed)) @@ -261,7 +272,7 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet else -> { try { - val newChapters = updateManga(manga) + val newChapters = updateManga(manga, now, fetchRange) .sortedByDescending { it.sourceOrder } if (newChapters.isNotEmpty()) { @@ -333,7 +344,7 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet * @param manga the manga to update. * @return a pair of the inserted and removed chapters. */ - private suspend fun updateManga(manga: Manga): List { + private suspend fun updateManga(manga: Manga, zoneDateTime: ZonedDateTime, fetchRange: Pair): List { val source = sourceManager.getOrStub(manga.source) // Update manga metadata if needed @@ -348,7 +359,7 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet // 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) + return syncChaptersWithSource.await(chapters, dbManga, source, false, zoneDateTime, fetchRange) } private suspend fun updateCovers() { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreen.kt index 2f21f7ef10..30a5d80295 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreen.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreen.kt @@ -30,6 +30,7 @@ import eu.kanade.presentation.manga.EditCoverAction import eu.kanade.presentation.manga.MangaScreen import eu.kanade.presentation.manga.components.DeleteChaptersDialog import eu.kanade.presentation.manga.components.MangaCoverDialog +import eu.kanade.presentation.manga.components.SetIntervalDialog import eu.kanade.presentation.util.AssistContentScreen import eu.kanade.presentation.util.Screen import eu.kanade.presentation.util.isTabletUi @@ -53,6 +54,7 @@ import logcat.LogPriority import tachiyomi.core.util.lang.withIOContext import tachiyomi.core.util.system.logcat import tachiyomi.domain.chapter.model.Chapter +import tachiyomi.domain.library.service.LibraryPreferences.Companion.MANGA_OUTSIDE_RELEASE_PERIOD import tachiyomi.domain.manga.model.Manga import tachiyomi.presentation.core.screens.LoadingScreen @@ -100,6 +102,7 @@ class MangaScreen( snackbarHostState = screenModel.snackbarHostState, dateRelativeTime = screenModel.relativeTime, dateFormat = screenModel.dateFormat, + intervalDisplay = screenModel::intervalDisplay, isTabletUi = isTabletUi(), chapterSwipeStartAction = screenModel.chapterSwipeStartAction, chapterSwipeEndAction = screenModel.chapterSwipeEndAction, @@ -121,7 +124,8 @@ class MangaScreen( onCoverClicked = screenModel::showCoverDialog, onShareClicked = { shareManga(context, screenModel.manga, screenModel.source) }.takeIf { isHttpSource }, onDownloadActionClicked = screenModel::runDownloadAction.takeIf { !successState.source.isLocalOrStub() }, - onEditCategoryClicked = screenModel::promptChangeCategories.takeIf { successState.manga.favorite }, + onEditCategoryClicked = screenModel::showChangeCategoryDialog.takeIf { successState.manga.favorite }, + onEditIntervalClicked = screenModel::showSetMangaIntervalDialog.takeIf { MANGA_OUTSIDE_RELEASE_PERIOD in screenModel.libraryPreferences.libraryUpdateMangaRestriction().get() && successState.manga.favorite }, onMigrateClicked = { navigator.push(MigrateSearchScreen(successState.manga.id)) }.takeIf { successState.manga.favorite }, onMultiBookmarkClicked = screenModel::bookmarkChapters, onMultiMarkAsReadClicked = screenModel::markChaptersRead, @@ -207,6 +211,13 @@ class MangaScreen( LoadingScreen(Modifier.systemBarsPadding()) } } + is MangaScreenModel.Dialog.SetMangaInterval -> { + SetIntervalDialog( + interval = if (dialog.manga.calculateInterval < 0) -dialog.manga.calculateInterval else 0, + onDismissRequest = onDismissRequest, + onValueChanged = { screenModel.setFetchRangeInterval(dialog.manga, it) }, + ) + } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreenModel.kt index 06b4a70882..398177da63 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreenModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreenModel.kt @@ -69,20 +69,22 @@ import tachiyomi.domain.manga.interactor.GetMangaWithChapters import tachiyomi.domain.manga.interactor.SetMangaChapterFlags import tachiyomi.domain.manga.model.Manga import tachiyomi.domain.manga.model.applyFilter +import tachiyomi.domain.manga.repository.MangaRepository import tachiyomi.domain.source.service.SourceManager import tachiyomi.domain.track.interactor.GetTracks import tachiyomi.source.local.isLocal import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get +import kotlin.math.absoluteValue class MangaScreenModel( val context: Context, val mangaId: Long, private val isFromSource: Boolean, private val downloadPreferences: DownloadPreferences = Injekt.get(), - private val libraryPreferences: LibraryPreferences = Injekt.get(), - readerPreferences: ReaderPreferences = Injekt.get(), - uiPreferences: UiPreferences = Injekt.get(), + val libraryPreferences: LibraryPreferences = Injekt.get(), + val readerPreferences: ReaderPreferences = Injekt.get(), + val uiPreferences: UiPreferences = Injekt.get(), private val trackManager: TrackManager = Injekt.get(), private val downloadManager: DownloadManager = Injekt.get(), private val downloadCache: DownloadCache = Injekt.get(), @@ -97,6 +99,7 @@ class MangaScreenModel( private val getCategories: GetCategories = Injekt.get(), private val getTracks: GetTracks = Injekt.get(), private val setMangaCategories: SetMangaCategories = Injekt.get(), + private val mangaRepository: MangaRepository = Injekt.get(), val snackbarHostState: SnackbarHostState = SnackbarHostState(), ) : StateScreenModel(State.Loading) { @@ -307,7 +310,7 @@ class MangaScreenModel( } // Choose a category - else -> promptChangeCategories() + else -> showChangeCategoryDialog() } // Finally match with enhanced tracking when available @@ -333,7 +336,7 @@ class MangaScreenModel( } } - fun promptChangeCategories() { + fun showChangeCategoryDialog() { val manga = successState?.manga ?: return coroutineScope.launch { val categories = getCategories() @@ -349,6 +352,39 @@ class MangaScreenModel( } } + fun showSetMangaIntervalDialog() { + val manga = successState?.manga ?: return + updateSuccessState { + it.copy(dialog = Dialog.SetMangaInterval(manga)) + } + } + + // TODO: this should be in the state/composables + fun intervalDisplay(): Pair? { + val state = successState ?: return null + val leadDay = libraryPreferences.leadingExpectedDays().get() + val followDay = libraryPreferences.followingExpectedDays().get() + val effInterval = state.manga.calculateInterval + return 1.coerceAtLeast(effInterval.absoluteValue - leadDay) to (effInterval.absoluteValue + followDay) + } + + fun setFetchRangeInterval(manga: Manga, newInterval: Int) { + val interval = when (newInterval) { + // reset interval 0 default to trigger recalculation + // only reset if interval is custom, which is negative + 0 -> if (manga.calculateInterval < 0) 0 else manga.calculateInterval + else -> -newInterval + } + coroutineScope.launchIO { + updateManga.awaitUpdateFetchInterval( + manga.copy(calculateInterval = interval), + successState?.chapters?.map { it.chapter }.orEmpty(), + ) + val newManga = mangaRepository.getMangaById(mangaId) + updateSuccessState { it.copy(manga = newManga) } + } + } + /** * Returns true if the manga has any downloads. */ @@ -502,6 +538,7 @@ class MangaScreenModel( chapters, state.manga, state.source, + manualFetch, ) if (manualFetch) { @@ -519,6 +556,8 @@ class MangaScreenModel( coroutineScope.launch { snackbarHostState.showSnackbar(message = message) } + val newManga = mangaRepository.getMangaById(mangaId) + updateSuccessState { it.copy(manga = newManga, isRefreshingData = false) } } } @@ -943,6 +982,7 @@ class MangaScreenModel( data class ChangeCategory(val manga: Manga, val initialSelection: List>) : Dialog data class DeleteChapters(val chapters: List) : Dialog data class DuplicateManga(val manga: Manga, val duplicate: Manga) : Dialog + data class SetMangaInterval(val manga: Manga) : Dialog data object SettingsSheet : Dialog data object TrackSheet : Dialog data object FullCover : Dialog diff --git a/domain/src/main/java/tachiyomi/domain/manga/interactor/SetMangaUpdateInterval.kt b/domain/src/main/java/tachiyomi/domain/manga/interactor/SetMangaUpdateInterval.kt index ac2f9f91c7..5ae2ce01c4 100644 --- a/domain/src/main/java/tachiyomi/domain/manga/interactor/SetMangaUpdateInterval.kt +++ b/domain/src/main/java/tachiyomi/domain/manga/interactor/SetMangaUpdateInterval.kt @@ -13,111 +13,115 @@ import kotlin.math.absoluteValue const val MAX_GRACE_PERIOD = 28 -fun updateIntervalMeta( - manga: Manga, - chapters: List, - zonedDateTime: ZonedDateTime = ZonedDateTime.now(), - setCurrentFetchRange: Pair = getCurrentFetchRange(zonedDateTime), -): MangaUpdate? { - val currentFetchRange = if (setCurrentFetchRange.first == 0L && setCurrentFetchRange.second == 0L) { - getCurrentFetchRange(ZonedDateTime.now()) - } else { - setCurrentFetchRange - } - val interval = manga.calculateInterval.takeIf { it < 0 } ?: calculateInterval(chapters, zonedDateTime) - val nextUpdate = calculateNextUpdate(manga, interval, zonedDateTime, currentFetchRange) +class SetMangaUpdateInterval( + private val libraryPreferences: LibraryPreferences = Injekt.get(), +) { - return if (manga.nextUpdate == nextUpdate && manga.calculateInterval == interval) { - null - } else { MangaUpdate(id = manga.id, nextUpdate = nextUpdate, calculateInterval = interval) } -} - -fun calculateInterval(chapters: List, zonedDateTime: ZonedDateTime): Int { - val sortedChapters = chapters - .sortedWith(compareByDescending { it.dateUpload }.thenByDescending { it.dateFetch }) - .take(50) - - val uploadDates = sortedChapters - .filter { it.dateUpload > 0L } - .map { - ZonedDateTime.ofInstant(Instant.ofEpochMilli(it.dateUpload), zonedDateTime.zone) - .toLocalDate() - .atStartOfDay() + fun updateInterval( + manga: Manga, + chapters: List, + zonedDateTime: ZonedDateTime, + fetchRange: Pair, + ): MangaUpdate? { + val currentFetchRange = if (fetchRange.first == 0L && fetchRange.second == 0L) { + getCurrentFetchRange(ZonedDateTime.now()) + } else { + fetchRange } - .distinct() - val fetchDates = sortedChapters - .map { - ZonedDateTime.ofInstant(Instant.ofEpochMilli(it.dateFetch), zonedDateTime.zone) - .toLocalDate() - .atStartOfDay() + val interval = manga.calculateInterval.takeIf { it < 0 } ?: calculateInterval(chapters, zonedDateTime) + val nextUpdate = calculateNextUpdate(manga, interval, zonedDateTime, currentFetchRange) + + return if (manga.nextUpdate == nextUpdate && manga.calculateInterval == interval) { + null + } else { + MangaUpdate(id = manga.id, nextUpdate = nextUpdate, calculateInterval = interval) } - .distinct() + } - val newInterval = when { - // Enough upload date from source - uploadDates.size >= 3 -> { - val uploadDelta = uploadDates.last().until(uploadDates.first(), ChronoUnit.DAYS) - val uploadPeriod = uploadDates.indexOf(uploadDates.last()) - uploadDelta.floorDiv(uploadPeriod).toInt() + fun getCurrentFetchRange(timeToCal: ZonedDateTime): Pair { + // lead range and the following range depend on if updateOnlyExpectedPeriod set. + var followRange = 0 + var leadRange = 0 + if (LibraryPreferences.MANGA_OUTSIDE_RELEASE_PERIOD in libraryPreferences.libraryUpdateMangaRestriction().get()) { + followRange = libraryPreferences.followingExpectedDays().get() + leadRange = libraryPreferences.leadingExpectedDays().get() } - // Enough fetch date from client - fetchDates.size >= 3 -> { - val fetchDelta = fetchDates.last().until(fetchDates.first(), ChronoUnit.DAYS) - val uploadPeriod = fetchDates.indexOf(fetchDates.last()) - fetchDelta.floorDiv(uploadPeriod).toInt() + val startToday = timeToCal.toLocalDate().atStartOfDay(timeToCal.zone) + // revert math of (next_update + follow < now) become (next_update < now - follow) + // so (now - follow) become lower limit + val lowerRange = startToday.minusDays(followRange.toLong()) + val higherRange = startToday.plusDays(leadRange.toLong()) + return Pair(lowerRange.toEpochSecond() * 1000, higherRange.toEpochSecond() * 1000 - 1) + } + + internal fun calculateInterval(chapters: List, zonedDateTime: ZonedDateTime): Int { + val sortedChapters = chapters + .sortedWith(compareByDescending { it.dateUpload }.thenByDescending { it.dateFetch }) + .take(50) + + val uploadDates = sortedChapters + .filter { it.dateUpload > 0L } + .map { + ZonedDateTime.ofInstant(Instant.ofEpochMilli(it.dateUpload), zonedDateTime.zone) + .toLocalDate() + .atStartOfDay() + } + .distinct() + val fetchDates = sortedChapters + .map { + ZonedDateTime.ofInstant(Instant.ofEpochMilli(it.dateFetch), zonedDateTime.zone) + .toLocalDate() + .atStartOfDay() + } + .distinct() + + val interval = when { + // Enough upload date from source + uploadDates.size >= 3 -> { + val uploadDelta = uploadDates.last().until(uploadDates.first(), ChronoUnit.DAYS) + val uploadPeriod = uploadDates.indexOf(uploadDates.last()) + uploadDelta.floorDiv(uploadPeriod).toInt() + } + // Enough fetch date from client + fetchDates.size >= 3 -> { + val fetchDelta = fetchDates.last().until(fetchDates.first(), ChronoUnit.DAYS) + val uploadPeriod = fetchDates.indexOf(fetchDates.last()) + fetchDelta.floorDiv(uploadPeriod).toInt() + } + // Default to 7 days + else -> 7 } - // Default to 7 days - else -> 7 + // Min 1, max 28 days + return interval.coerceIn(1, MAX_GRACE_PERIOD) } - // Min 1, max 28 days - return newInterval.coerceIn(1, MAX_GRACE_PERIOD) -} -private fun calculateNextUpdate( - manga: Manga, - interval: Int, - zonedDateTime: ZonedDateTime, - currentFetchRange: Pair, -): Long { - return if (manga.nextUpdate !in currentFetchRange.first.rangeTo(currentFetchRange.second + 1) || - manga.calculateInterval == 0 - ) { - val latestDate = ZonedDateTime.ofInstant(Instant.ofEpochMilli(manga.lastUpdate), zonedDateTime.zone).toLocalDate().atStartOfDay() - val timeSinceLatest = ChronoUnit.DAYS.between(latestDate, zonedDateTime).toInt() - val cycle = timeSinceLatest.floorDiv(interval.absoluteValue.takeIf { interval < 0 } ?: doubleInterval(interval, timeSinceLatest, doubleWhenOver = 10, maxValue = 28)) - latestDate.plusDays((cycle + 1) * interval.toLong()).toEpochSecond(zonedDateTime.offset) * 1000 - } else { - manga.nextUpdate + private fun calculateNextUpdate( + manga: Manga, + interval: Int, + zonedDateTime: ZonedDateTime, + fetchRange: Pair, + ): Long { + return if ( + manga.nextUpdate !in fetchRange.first.rangeTo(fetchRange.second + 1) || + manga.calculateInterval == 0 + ) { + val latestDate = ZonedDateTime.ofInstant(Instant.ofEpochMilli(manga.lastUpdate), zonedDateTime.zone).toLocalDate().atStartOfDay() + val timeSinceLatest = ChronoUnit.DAYS.between(latestDate, zonedDateTime).toInt() + val cycle = timeSinceLatest.floorDiv(interval.absoluteValue.takeIf { interval < 0 } ?: doubleInterval(interval, timeSinceLatest, doubleWhenOver = 10, maxValue = 28)) + latestDate.plusDays((cycle + 1) * interval.toLong()).toEpochSecond(zonedDateTime.offset) * 1000 + } else { + manga.nextUpdate + } + } + + private fun doubleInterval(delta: Int, timeSinceLatest: Int, doubleWhenOver: Int, maxValue: Int): Int { + if (delta >= maxValue) return maxValue + val cycle = timeSinceLatest.floorDiv(delta) + 1 + // double delta again if missed more than 9 check in new delta + return if (cycle > doubleWhenOver) { + doubleInterval(delta * 2, timeSinceLatest, doubleWhenOver, maxValue) + } else { + delta + } } } - -private fun doubleInterval(delta: Int, timeSinceLatest: Int, doubleWhenOver: Int, maxValue: Int): Int { - if (delta >= maxValue) return maxValue - val cycle = timeSinceLatest.floorDiv(delta) + 1 - // double delta again if missed more than 9 check in new delta - return if (cycle > doubleWhenOver) { - doubleInterval(delta * 2, timeSinceLatest, doubleWhenOver, maxValue) - } else { - delta - } -} - -fun getCurrentFetchRange( - timeToCal: ZonedDateTime, -): Pair { - val preferences: LibraryPreferences = Injekt.get() - - // lead range and the following range depend on if updateOnlyExpectedPeriod set. - var followRange = 0 - var leadRange = 0 - if (LibraryPreferences.MANGA_OUTSIDE_RELEASE_PERIOD in preferences.libraryUpdateMangaRestriction().get()) { - followRange = preferences.followingExpectedDays().get() - leadRange = preferences.leadingExpectedDays().get() - } - val startToday = timeToCal.toLocalDate().atStartOfDay(timeToCal.zone) - // revert math of (next_update + follow < now) become (next_update < now - follow) - // so (now - follow) become lower limit - val lowerRange = startToday.minusDays(followRange.toLong()) - val higherRange = startToday.plusDays(leadRange.toLong()) - return Pair(lowerRange.toEpochSecond() * 1000, higherRange.toEpochSecond() * 1000 - 1) -} diff --git a/domain/src/test/java/tachiyomi/domain/manga/interactor/SetMangaUpdateIntervalTest.kt b/domain/src/test/java/tachiyomi/domain/manga/interactor/SetMangaUpdateIntervalTest.kt new file mode 100644 index 0000000000..6cfe8ceb9a --- /dev/null +++ b/domain/src/test/java/tachiyomi/domain/manga/interactor/SetMangaUpdateIntervalTest.kt @@ -0,0 +1,135 @@ +package tachiyomi.domain.manga.interactor + +import io.kotest.matchers.shouldBe +import io.mockk.mockk +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.parallel.Execution +import org.junit.jupiter.api.parallel.ExecutionMode +import tachiyomi.domain.chapter.model.Chapter +import java.time.Duration +import java.time.ZonedDateTime + +@Execution(ExecutionMode.CONCURRENT) +class SetMangaUpdateIntervalTest { + private val testTime = ZonedDateTime.parse("2020-01-01T00:00:00Z") + private var chapter = Chapter.create().copy( + dateFetch = testTime.toEpochSecond() * 1000, + dateUpload = testTime.toEpochSecond() * 1000, + ) + + private val setMangaUpdateInterval = SetMangaUpdateInterval(mockk()) + + private fun chapterAddTime(chapter: Chapter, duration: Duration): Chapter { + val newTime = testTime.plus(duration).toEpochSecond() * 1000 + return chapter.copy(dateFetch = newTime, dateUpload = newTime) + } + + // default 7 when less than 3 distinct day + @Test + fun `calculateInterval returns 7 when 1 chapters in 1 day`() { + val chapters = mutableListOf() + (1..1).forEach { + val duration = Duration.ofHours(10) + val newChapter = chapterAddTime(chapter, duration) + chapters.add(newChapter) + } + setMangaUpdateInterval.calculateInterval(chapters, testTime) shouldBe 7 + } + + @Test + fun `calculateInterval returns 7 when 5 chapters in 1 day`() { + val chapters = mutableListOf() + (1..5).forEach { + val duration = Duration.ofHours(10) + val newChapter = chapterAddTime(chapter, duration) + chapters.add(newChapter) + } + setMangaUpdateInterval.calculateInterval(chapters, testTime) shouldBe 7 + } + + @Test + fun `calculateInterval returns 7 when 7 chapters in 48 hours, 2 day`() { + val chapters = mutableListOf() + (1..2).forEach { + val duration = Duration.ofHours(24L) + val newChapter = chapterAddTime(chapter, duration) + chapters.add(newChapter) + } + (1..5).forEach { + val duration = Duration.ofHours(48L) + val newChapter = chapterAddTime(chapter, duration) + chapters.add(newChapter) + } + setMangaUpdateInterval.calculateInterval(chapters, testTime) shouldBe 7 + } + + // Default 1 if interval less than 1 + @Test + fun `calculateInterval returns 1 when 5 chapters in 75 hours, 3 days`() { + val chapters = mutableListOf() + (1..5).forEach { + val duration = Duration.ofHours(15L * it) + val newChapter = chapterAddTime(chapter, duration) + chapters.add(newChapter) + } + setMangaUpdateInterval.calculateInterval(chapters, testTime) shouldBe 1 + } + + // Normal interval calculation + @Test + fun `calculateInterval returns 1 when 5 chapters in 120 hours, 5 days`() { + val chapters = mutableListOf() + (1..5).forEach { + val duration = Duration.ofHours(24L * it) + val newChapter = chapterAddTime(chapter, duration) + chapters.add(newChapter) + } + setMangaUpdateInterval.calculateInterval(chapters, testTime) shouldBe 1 + } + + @Test + fun `calculateInterval returns 2 when 5 chapters in 240 hours, 10 days`() { + val chapters = mutableListOf() + (1..5).forEach { + val duration = Duration.ofHours(48L * it) + val newChapter = chapterAddTime(chapter, duration) + chapters.add(newChapter) + } + setMangaUpdateInterval.calculateInterval(chapters, testTime) shouldBe 2 + } + + // If interval is decimal, floor to closest integer + @Test + fun `calculateInterval returns 1 when 5 chapters in 125 hours, 5 days`() { + val chapters = mutableListOf() + (1..5).forEach { + val duration = Duration.ofHours(25L * it) + val newChapter = chapterAddTime(chapter, duration) + chapters.add(newChapter) + } + setMangaUpdateInterval.calculateInterval(chapters, testTime) shouldBe 1 + } + + @Test + fun `calculateInterval returns 1 when 5 chapters in 215 hours, 5 days`() { + val chapters = mutableListOf() + (1..5).forEach { + val duration = Duration.ofHours(43L * it) + val newChapter = chapterAddTime(chapter, duration) + chapters.add(newChapter) + } + setMangaUpdateInterval.calculateInterval(chapters, testTime) shouldBe 1 + } + + // Use fetch time if upload time not available + @Test + fun `calculateInterval returns 1 when 5 chapters in 125 hours, 5 days of dateFetch`() { + val chapters = mutableListOf() + (1..5).forEach { + val duration = Duration.ofHours(25L * it) + val newChapter = chapterAddTime(chapter, duration).copy(dateUpload = 0L) + chapters.add(newChapter) + } + setMangaUpdateInterval.calculateInterval(chapters, testTime) shouldBe 1 + } +} diff --git a/i18n/src/main/res/values/strings.xml b/i18n/src/main/res/values/strings.xml index 2829d51a24..73ee53059c 100644 --- a/i18n/src/main/res/values/strings.xml +++ b/i18n/src/main/res/values/strings.xml @@ -641,6 +641,10 @@ 1 day %d days + + %1$d - %2$d day + %1$d - %2$d days + @@ -682,8 +686,7 @@ Chapter %1$s Estimate every Set to update every - Modify interval - Customize Interval + Customize interval Downloading (%1$d/%2$d) Error Paused