瀏覽代碼

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 <[email protected]>
Quang Kieu 1 年之前
父節點
當前提交
cb639f4e90

+ 3 - 1
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<ReleaseService> { ReleaseServiceImpl(get(), get()) }

+ 14 - 0
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<SChapter>,
         manga: Manga,
         source: Source,
+        manualFetch: Boolean = false,
+        zoneDateTime: ZonedDateTime = ZonedDateTime.now(),
+        fetchRange: Pair<Long, Long> = Pair(0, 0),
     ): List<Chapter> {
         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

+ 7 - 8
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<Chapter>,
         zonedDateTime: ZonedDateTime = ZonedDateTime.now(),
-        setCurrentFetchRange: Pair<Long, Long> = getCurrentFetchRange(zonedDateTime),
+        fetchRange: Pair<Long, Long> = 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
         }

+ 16 - 0
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<Int, Int>?,
     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<Int, Int>?,
     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<Int, Int>?,
     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(

+ 60 - 0
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))
+            }
+        },
+    )
+}

+ 20 - 1
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<Int, Int>?,
+    isUserIntervalMode: Boolean,
     onAddToLibraryClicked: () -> Unit,
     onWebViewClicked: (() -> Unit)?,
     onWebViewLongClicked: (() -> Unit)?,
     onTrackingClicked: (() -> Unit)?,
+    onEditIntervalClicked: (() -> Unit)?,
     onEditCategory: (() -> Unit)?,
 ) {
+    val interval: Pair<Int, Int>? = 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) {

+ 6 - 9
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),

+ 21 - 3
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<BackupHistory>,
         tracks: List<Track>,
         backupCategories: List<BackupCategory>,
-    ) {
+    ): 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<BackupHistory>,
         tracks: List<Track>,
         backupCategories: List<BackupCategory>,
-    ) {
+    ): Manga {
         backupManager.restoreChapters(backupManga, chapters)
         restoreExtras(backupManga, categories, history, tracks, backupCategories)
+        return backupManga
     }
 
     private suspend fun restoreExtras(manga: Manga, categories: List<Int>, history: List<BackupHistory>, tracks: List<Track>, backupCategories: List<BackupCategory>) {

+ 14 - 3
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<Chapter> {
+    private suspend fun updateManga(manga: Manga, zoneDateTime: ZonedDateTime, fetchRange: Pair<Long, Long>): List<Chapter> {
         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() {

+ 12 - 1
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) },
+                )
+            }
         }
     }
 

+ 45 - 5
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<MangaScreenModel.State>(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<Int, Int>? {
+        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<CheckboxState<Category>>) : Dialog
         data class DeleteChapters(val chapters: List<Chapter>) : 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

+ 99 - 95
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<Chapter>,
-    zonedDateTime: ZonedDateTime = ZonedDateTime.now(),
-    setCurrentFetchRange: Pair<Long, Long> = 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)
-
-    return if (manga.nextUpdate == nextUpdate && manga.calculateInterval == interval) {
-        null
-    } else { MangaUpdate(id = manga.id, nextUpdate = nextUpdate, calculateInterval = interval) }
-}
-
-fun calculateInterval(chapters: List<Chapter>, zonedDateTime: ZonedDateTime): Int {
-    val sortedChapters = chapters
-        .sortedWith(compareByDescending<Chapter> { it.dateUpload }.thenByDescending { it.dateFetch })
-        .take(50)
+class SetMangaUpdateInterval(
+    private val libraryPreferences: LibraryPreferences = Injekt.get(),
+) {
 
-    val uploadDates = sortedChapters
-        .filter { it.dateUpload > 0L }
-        .map {
-            ZonedDateTime.ofInstant(Instant.ofEpochMilli(it.dateUpload), zonedDateTime.zone)
-                .toLocalDate()
-                .atStartOfDay()
+    fun updateInterval(
+        manga: Manga,
+        chapters: List<Chapter>,
+        zonedDateTime: ZonedDateTime,
+        fetchRange: Pair<Long, Long>,
+    ): 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()
-        }
-        .distinct()
+        val interval = manga.calculateInterval.takeIf { it < 0 } ?: calculateInterval(chapters, zonedDateTime)
+        val nextUpdate = calculateNextUpdate(manga, interval, zonedDateTime, currentFetchRange)
 
-    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()
-        }
-        // 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()
+        return if (manga.nextUpdate == nextUpdate && manga.calculateInterval == interval) {
+            null
+        } else {
+            MangaUpdate(id = manga.id, nextUpdate = nextUpdate, calculateInterval = interval)
         }
-        // Default to 7 days
-        else -> 7
     }
-    // Min 1, max 28 days
-    return newInterval.coerceIn(1, MAX_GRACE_PERIOD)
-}
 
-private fun calculateNextUpdate(
-    manga: Manga,
-    interval: Int,
-    zonedDateTime: ZonedDateTime,
-    currentFetchRange: Pair<Long, Long>,
-): 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
+    fun getCurrentFetchRange(timeToCal: ZonedDateTime): Pair<Long, Long> {
+        // 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()
+        }
+        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)
     }
-}
 
-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
+    internal fun calculateInterval(chapters: List<Chapter>, zonedDateTime: ZonedDateTime): Int {
+        val sortedChapters = chapters
+            .sortedWith(compareByDescending<Chapter> { 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
+        }
+        // Min 1, max 28 days
+        return interval.coerceIn(1, MAX_GRACE_PERIOD)
     }
-}
 
-fun getCurrentFetchRange(
-    timeToCal: ZonedDateTime,
-): Pair<Long, Long> {
-    val preferences: LibraryPreferences = Injekt.get()
+    private fun calculateNextUpdate(
+        manga: Manga,
+        interval: Int,
+        zonedDateTime: ZonedDateTime,
+        fetchRange: Pair<Long, Long>,
+    ): 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
+        }
+    }
 
-    // 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()
+    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
+        }
     }
-    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)
 }

+ 135 - 0
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<Chapter>()
+        (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<Chapter>()
+        (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<Chapter>()
+        (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<Chapter>()
+        (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<Chapter>()
+        (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<Chapter>()
+        (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<Chapter>()
+        (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<Chapter>()
+        (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<Chapter>()
+        (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
+    }
+}

+ 5 - 2
i18n/src/main/res/values/strings.xml

@@ -641,6 +641,10 @@
         <item quantity="one">1 day</item>
         <item quantity="other">%d days</item>
     </plurals>
+    <plurals name="range_interval_day">
+        <item quantity="one">%1$d - %2$d day</item>
+        <item quantity="other">%1$d - %2$d days</item>
+    </plurals>
 
     <!-- Manga info -->
     <plurals name="missing_chapters">
@@ -682,8 +686,7 @@
     <string name="display_mode_chapter">Chapter %1$s</string>
     <string name="manga_display_interval_title">Estimate every</string>
     <string name="manga_display_modified_interval_title">Set to update every</string>
-    <string name="manga_modify_interval_title">Modify interval</string>
-    <string name="manga_modify_calculated_interval_title">Customize Interval</string>
+    <string name="manga_modify_calculated_interval_title">Customize interval</string>
     <string name="chapter_downloading_progress">Downloading (%1$d/%2$d)</string>
     <string name="chapter_error">Error</string>
     <string name="chapter_paused">Paused</string>