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 <arkon@users.noreply.github.com>
This commit is contained in:
Quang Kieu 2023-07-23 18:12:01 -04:00 committed by arkon
parent 6d69caf59e
commit cb639f4e90
14 changed files with 460 additions and 131 deletions

View file

@ -57,6 +57,7 @@ import tachiyomi.domain.manga.interactor.GetMangaWithChapters
import tachiyomi.domain.manga.interactor.NetworkToLocalManga import tachiyomi.domain.manga.interactor.NetworkToLocalManga
import tachiyomi.domain.manga.interactor.ResetViewerFlags import tachiyomi.domain.manga.interactor.ResetViewerFlags
import tachiyomi.domain.manga.interactor.SetMangaChapterFlags import tachiyomi.domain.manga.interactor.SetMangaChapterFlags
import tachiyomi.domain.manga.interactor.SetMangaUpdateInterval
import tachiyomi.domain.manga.repository.MangaRepository import tachiyomi.domain.manga.repository.MangaRepository
import tachiyomi.domain.release.interactor.GetApplicationRelease import tachiyomi.domain.release.interactor.GetApplicationRelease
import tachiyomi.domain.release.service.ReleaseService import tachiyomi.domain.release.service.ReleaseService
@ -100,10 +101,11 @@ class DomainModule : InjektModule {
addFactory { GetNextChapters(get(), get(), get()) } addFactory { GetNextChapters(get(), get(), get()) }
addFactory { ResetViewerFlags(get()) } addFactory { ResetViewerFlags(get()) }
addFactory { SetMangaChapterFlags(get()) } addFactory { SetMangaChapterFlags(get()) }
addFactory { SetMangaUpdateInterval(get()) }
addFactory { SetMangaDefaultChapterFlags(get(), get(), get()) } addFactory { SetMangaDefaultChapterFlags(get(), get(), get()) }
addFactory { SetMangaViewerFlags(get()) } addFactory { SetMangaViewerFlags(get()) }
addFactory { NetworkToLocalManga(get()) } addFactory { NetworkToLocalManga(get()) }
addFactory { UpdateManga(get()) } addFactory { UpdateManga(get(), get()) }
addFactory { SetMangaCategories(get()) } addFactory { SetMangaCategories(get()) }
addSingletonFactory<ReleaseService> { ReleaseServiceImpl(get(), get()) } addSingletonFactory<ReleaseService> { ReleaseServiceImpl(get(), get()) }

View file

@ -23,6 +23,7 @@ import tachiyomi.source.local.isLocal
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import java.lang.Long.max import java.lang.Long.max
import java.time.ZonedDateTime
import java.util.Date import java.util.Date
import java.util.TreeSet import java.util.TreeSet
@ -48,6 +49,9 @@ class SyncChaptersWithSource(
rawSourceChapters: List<SChapter>, rawSourceChapters: List<SChapter>,
manga: Manga, manga: Manga,
source: Source, source: Source,
manualFetch: Boolean = false,
zoneDateTime: ZonedDateTime = ZonedDateTime.now(),
fetchRange: Pair<Long, Long> = Pair(0, 0),
): List<Chapter> { ): List<Chapter> {
if (rawSourceChapters.isEmpty() && !source.isLocal()) { if (rawSourceChapters.isEmpty() && !source.isLocal()) {
throw NoChaptersException() throw NoChaptersException()
@ -134,6 +138,14 @@ class SyncChaptersWithSource(
// Return if there's nothing to add, delete or change, avoiding unnecessary db transactions. // Return if there's nothing to add, delete or change, avoiding unnecessary db transactions.
if (toAdd.isEmpty() && toDelete.isEmpty() && toChange.isEmpty()) { if (toAdd.isEmpty() && toDelete.isEmpty() && toChange.isEmpty()) {
if (manualFetch || manga.calculateInterval == 0 || manga.nextUpdate < fetchRange.first) {
updateManga.awaitUpdateFetchInterval(
manga,
dbChapters,
zoneDateTime,
fetchRange,
)
}
return emptyList() return emptyList()
} }
@ -188,6 +200,8 @@ class SyncChaptersWithSource(
val chapterUpdates = toChange.map { it.toChapterUpdate() } val chapterUpdates = toChange.map { it.toChapterUpdate() }
updateChapter.awaitAll(chapterUpdates) updateChapter.awaitAll(chapterUpdates)
} }
val newChapters = chapterRepository.getChapterByMangaId(manga.id)
updateManga.awaitUpdateFetchInterval(manga, newChapters, zoneDateTime, fetchRange)
// Set this manga as updated since chapters were changed // Set this manga as updated since chapters were changed
// Note that last_update actually represents last time the chapter list changed at all // Note that last_update actually represents last time the chapter list changed at all

View file

@ -4,8 +4,7 @@ import eu.kanade.domain.manga.model.hasCustomCover
import eu.kanade.tachiyomi.data.cache.CoverCache import eu.kanade.tachiyomi.data.cache.CoverCache
import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.model.SManga
import tachiyomi.domain.chapter.model.Chapter import tachiyomi.domain.chapter.model.Chapter
import tachiyomi.domain.manga.interactor.getCurrentFetchRange import tachiyomi.domain.manga.interactor.SetMangaUpdateInterval
import tachiyomi.domain.manga.interactor.updateIntervalMeta
import tachiyomi.domain.manga.model.Manga import tachiyomi.domain.manga.model.Manga
import tachiyomi.domain.manga.model.MangaUpdate import tachiyomi.domain.manga.model.MangaUpdate
import tachiyomi.domain.manga.repository.MangaRepository import tachiyomi.domain.manga.repository.MangaRepository
@ -17,6 +16,7 @@ import java.util.Date
class UpdateManga( class UpdateManga(
private val mangaRepository: MangaRepository, private val mangaRepository: MangaRepository,
private val setMangaUpdateInterval: SetMangaUpdateInterval,
) { ) {
suspend fun await(mangaUpdate: MangaUpdate): Boolean { suspend fun await(mangaUpdate: MangaUpdate): Boolean {
@ -77,16 +77,15 @@ class UpdateManga(
) )
} }
suspend fun awaitUpdateIntervalMeta( suspend fun awaitUpdateFetchInterval(
manga: Manga, manga: Manga,
chapters: List<Chapter>, chapters: List<Chapter>,
zonedDateTime: ZonedDateTime = ZonedDateTime.now(), zonedDateTime: ZonedDateTime = ZonedDateTime.now(),
setCurrentFetchRange: Pair<Long, Long> = getCurrentFetchRange(zonedDateTime), fetchRange: Pair<Long, Long> = setMangaUpdateInterval.getCurrentFetchRange(zonedDateTime),
): Boolean { ): Boolean {
val newMeta = updateIntervalMeta(manga, chapters, zonedDateTime, setCurrentFetchRange) val updatedManga = setMangaUpdateInterval.updateInterval(manga, chapters, zonedDateTime, fetchRange)
return if (updatedManga != null) {
return if (newMeta != null) { mangaRepository.update(updatedManga)
mangaRepository.update(newMeta)
} else { } else {
true true
} }

View file

@ -85,6 +85,7 @@ fun MangaScreen(
state: MangaScreenModel.State.Success, state: MangaScreenModel.State.Success,
snackbarHostState: SnackbarHostState, snackbarHostState: SnackbarHostState,
dateRelativeTime: Int, dateRelativeTime: Int,
intervalDisplay: () -> Pair<Int, Int>?,
dateFormat: DateFormat, dateFormat: DateFormat,
isTabletUi: Boolean, isTabletUi: Boolean,
chapterSwipeStartAction: LibraryPreferences.ChapterSwipeAction, chapterSwipeStartAction: LibraryPreferences.ChapterSwipeAction,
@ -112,6 +113,7 @@ fun MangaScreen(
onShareClicked: (() -> Unit)?, onShareClicked: (() -> Unit)?,
onDownloadActionClicked: ((DownloadAction) -> Unit)?, onDownloadActionClicked: ((DownloadAction) -> Unit)?,
onEditCategoryClicked: (() -> Unit)?, onEditCategoryClicked: (() -> Unit)?,
onEditIntervalClicked: (() -> Unit)?,
onMigrateClicked: (() -> Unit)?, onMigrateClicked: (() -> Unit)?,
// For bottom action menu // For bottom action menu
@ -141,6 +143,7 @@ fun MangaScreen(
snackbarHostState = snackbarHostState, snackbarHostState = snackbarHostState,
dateRelativeTime = dateRelativeTime, dateRelativeTime = dateRelativeTime,
dateFormat = dateFormat, dateFormat = dateFormat,
intervalDisplay = intervalDisplay,
chapterSwipeStartAction = chapterSwipeStartAction, chapterSwipeStartAction = chapterSwipeStartAction,
chapterSwipeEndAction = chapterSwipeEndAction, chapterSwipeEndAction = chapterSwipeEndAction,
onBackClicked = onBackClicked, onBackClicked = onBackClicked,
@ -160,6 +163,7 @@ fun MangaScreen(
onShareClicked = onShareClicked, onShareClicked = onShareClicked,
onDownloadActionClicked = onDownloadActionClicked, onDownloadActionClicked = onDownloadActionClicked,
onEditCategoryClicked = onEditCategoryClicked, onEditCategoryClicked = onEditCategoryClicked,
onEditIntervalClicked = onEditIntervalClicked,
onMigrateClicked = onMigrateClicked, onMigrateClicked = onMigrateClicked,
onMultiBookmarkClicked = onMultiBookmarkClicked, onMultiBookmarkClicked = onMultiBookmarkClicked,
onMultiMarkAsReadClicked = onMultiMarkAsReadClicked, onMultiMarkAsReadClicked = onMultiMarkAsReadClicked,
@ -178,6 +182,7 @@ fun MangaScreen(
chapterSwipeStartAction = chapterSwipeStartAction, chapterSwipeStartAction = chapterSwipeStartAction,
chapterSwipeEndAction = chapterSwipeEndAction, chapterSwipeEndAction = chapterSwipeEndAction,
dateFormat = dateFormat, dateFormat = dateFormat,
intervalDisplay = intervalDisplay,
onBackClicked = onBackClicked, onBackClicked = onBackClicked,
onChapterClicked = onChapterClicked, onChapterClicked = onChapterClicked,
onDownloadChapter = onDownloadChapter, onDownloadChapter = onDownloadChapter,
@ -195,6 +200,7 @@ fun MangaScreen(
onShareClicked = onShareClicked, onShareClicked = onShareClicked,
onDownloadActionClicked = onDownloadActionClicked, onDownloadActionClicked = onDownloadActionClicked,
onEditCategoryClicked = onEditCategoryClicked, onEditCategoryClicked = onEditCategoryClicked,
onEditIntervalClicked = onEditIntervalClicked,
onMigrateClicked = onMigrateClicked, onMigrateClicked = onMigrateClicked,
onMultiBookmarkClicked = onMultiBookmarkClicked, onMultiBookmarkClicked = onMultiBookmarkClicked,
onMultiMarkAsReadClicked = onMultiMarkAsReadClicked, onMultiMarkAsReadClicked = onMultiMarkAsReadClicked,
@ -214,6 +220,7 @@ private fun MangaScreenSmallImpl(
snackbarHostState: SnackbarHostState, snackbarHostState: SnackbarHostState,
dateRelativeTime: Int, dateRelativeTime: Int,
dateFormat: DateFormat, dateFormat: DateFormat,
intervalDisplay: () -> Pair<Int, Int>?,
chapterSwipeStartAction: LibraryPreferences.ChapterSwipeAction, chapterSwipeStartAction: LibraryPreferences.ChapterSwipeAction,
chapterSwipeEndAction: LibraryPreferences.ChapterSwipeAction, chapterSwipeEndAction: LibraryPreferences.ChapterSwipeAction,
onBackClicked: () -> Unit, onBackClicked: () -> Unit,
@ -240,6 +247,7 @@ private fun MangaScreenSmallImpl(
onShareClicked: (() -> Unit)?, onShareClicked: (() -> Unit)?,
onDownloadActionClicked: ((DownloadAction) -> Unit)?, onDownloadActionClicked: ((DownloadAction) -> Unit)?,
onEditCategoryClicked: (() -> Unit)?, onEditCategoryClicked: (() -> Unit)?,
onEditIntervalClicked: (() -> Unit)?,
onMigrateClicked: (() -> Unit)?, onMigrateClicked: (() -> Unit)?,
// For bottom action menu // For bottom action menu
@ -383,10 +391,13 @@ private fun MangaScreenSmallImpl(
MangaActionRow( MangaActionRow(
favorite = state.manga.favorite, favorite = state.manga.favorite,
trackingCount = state.trackingCount, trackingCount = state.trackingCount,
intervalDisplay = intervalDisplay,
isUserIntervalMode = state.manga.calculateInterval < 0,
onAddToLibraryClicked = onAddToLibraryClicked, onAddToLibraryClicked = onAddToLibraryClicked,
onWebViewClicked = onWebViewClicked, onWebViewClicked = onWebViewClicked,
onWebViewLongClicked = onWebViewLongClicked, onWebViewLongClicked = onWebViewLongClicked,
onTrackingClicked = onTrackingClicked, onTrackingClicked = onTrackingClicked,
onEditIntervalClicked = onEditIntervalClicked,
onEditCategory = onEditCategoryClicked, onEditCategory = onEditCategoryClicked,
) )
} }
@ -440,6 +451,7 @@ fun MangaScreenLargeImpl(
snackbarHostState: SnackbarHostState, snackbarHostState: SnackbarHostState,
dateRelativeTime: Int, dateRelativeTime: Int,
dateFormat: DateFormat, dateFormat: DateFormat,
intervalDisplay: () -> Pair<Int, Int>?,
chapterSwipeStartAction: LibraryPreferences.ChapterSwipeAction, chapterSwipeStartAction: LibraryPreferences.ChapterSwipeAction,
chapterSwipeEndAction: LibraryPreferences.ChapterSwipeAction, chapterSwipeEndAction: LibraryPreferences.ChapterSwipeAction,
onBackClicked: () -> Unit, onBackClicked: () -> Unit,
@ -466,6 +478,7 @@ fun MangaScreenLargeImpl(
onShareClicked: (() -> Unit)?, onShareClicked: (() -> Unit)?,
onDownloadActionClicked: ((DownloadAction) -> Unit)?, onDownloadActionClicked: ((DownloadAction) -> Unit)?,
onEditCategoryClicked: (() -> Unit)?, onEditCategoryClicked: (() -> Unit)?,
onEditIntervalClicked: (() -> Unit)?,
onMigrateClicked: (() -> Unit)?, onMigrateClicked: (() -> Unit)?,
// For bottom action menu // For bottom action menu
@ -596,10 +609,13 @@ fun MangaScreenLargeImpl(
MangaActionRow( MangaActionRow(
favorite = state.manga.favorite, favorite = state.manga.favorite,
trackingCount = state.trackingCount, trackingCount = state.trackingCount,
intervalDisplay = intervalDisplay,
isUserIntervalMode = state.manga.calculateInterval < 0,
onAddToLibraryClicked = onAddToLibraryClicked, onAddToLibraryClicked = onAddToLibraryClicked,
onWebViewClicked = onWebViewClicked, onWebViewClicked = onWebViewClicked,
onWebViewLongClicked = onWebViewLongClicked, onWebViewLongClicked = onWebViewLongClicked,
onTrackingClicked = onTrackingClicked, onTrackingClicked = onTrackingClicked,
onEditIntervalClicked = onEditIntervalClicked,
onEditCategory = onEditCategoryClicked, onEditCategory = onEditCategoryClicked,
) )
ExpandableMangaDescription( ExpandableMangaDescription(

View file

@ -1,11 +1,23 @@
package eu.kanade.presentation.manga.components 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.AlertDialog
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable 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.res.stringResource
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import tachiyomi.domain.manga.interactor.MAX_GRACE_PERIOD
import tachiyomi.presentation.core.components.WheelTextPicker
@Composable @Composable
fun DeleteChaptersDialog( 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))
}
},
)
}

View file

@ -25,6 +25,7 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Favorite 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.filled.Warning
import androidx.compose.material.icons.outlined.AttachMoney import androidx.compose.material.icons.outlined.AttachMoney
import androidx.compose.material.icons.outlined.Block import androidx.compose.material.icons.outlined.Block
@ -164,14 +165,19 @@ fun MangaActionRow(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
favorite: Boolean, favorite: Boolean,
trackingCount: Int, trackingCount: Int,
intervalDisplay: () -> Pair<Int, Int>?,
isUserIntervalMode: Boolean,
onAddToLibraryClicked: () -> Unit, onAddToLibraryClicked: () -> Unit,
onWebViewClicked: (() -> Unit)?, onWebViewClicked: (() -> Unit)?,
onWebViewLongClicked: (() -> Unit)?, onWebViewLongClicked: (() -> Unit)?,
onTrackingClicked: (() -> Unit)?, onTrackingClicked: (() -> Unit)?,
onEditIntervalClicked: (() -> Unit)?,
onEditCategory: (() -> 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)) { Row(modifier = modifier.padding(start = 16.dp, top = 8.dp, end = 16.dp)) {
val defaultActionButtonColor = MaterialTheme.colorScheme.onSurface.copy(alpha = .38f)
MangaActionButton( MangaActionButton(
title = if (favorite) { title = if (favorite) {
stringResource(R.string.in_library) stringResource(R.string.in_library)
@ -183,6 +189,19 @@ fun MangaActionRow(
onClick = onAddToLibraryClicked, onClick = onAddToLibraryClicked,
onLongClick = onEditCategory, 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) { if (onTrackingClicked != null) {
MangaActionButton( MangaActionButton(
title = if (trackingCount == 0) { title = if (trackingCount == 0) {

View file

@ -241,18 +241,15 @@ object SettingsLibraryScreen : SearchableSettings {
title = stringResource(R.string.pref_library_update_refresh_trackers), title = stringResource(R.string.pref_library_update_refresh_trackers),
subtitle = stringResource(R.string.pref_library_update_refresh_trackers_summary), subtitle = stringResource(R.string.pref_library_update_refresh_trackers_summary),
), ),
// TODO: remove isDevFlavor checks once functionality is available
Preference.PreferenceItem.MultiSelectListPreference( Preference.PreferenceItem.MultiSelectListPreference(
pref = libraryUpdateMangaRestrictionPref, pref = libraryUpdateMangaRestrictionPref,
title = stringResource(R.string.pref_library_update_manga_restriction), title = stringResource(R.string.pref_library_update_manga_restriction),
entries = buildMap { entries = mapOf(
put(MANGA_HAS_UNREAD, stringResource(R.string.pref_update_only_completely_read)) MANGA_HAS_UNREAD to stringResource(R.string.pref_update_only_completely_read),
put(MANGA_NON_READ, stringResource(R.string.pref_update_only_started)) MANGA_NON_READ to stringResource(R.string.pref_update_only_started),
put(MANGA_NON_COMPLETED, stringResource(R.string.pref_update_only_non_completed)) MANGA_NON_COMPLETED to stringResource(R.string.pref_update_only_non_completed),
if (isDevFlavor) { MANGA_OUTSIDE_RELEASE_PERIOD to stringResource(R.string.pref_update_only_in_release_period),
put(MANGA_OUTSIDE_RELEASE_PERIOD, stringResource(R.string.pref_update_only_in_release_period)) ),
}
},
), ),
Preference.PreferenceItem.TextPreference( Preference.PreferenceItem.TextPreference(
title = stringResource(R.string.pref_update_release_grace_period), title = stringResource(R.string.pref_update_release_grace_period),

View file

@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.data.backup
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import eu.kanade.domain.manga.interactor.UpdateManga
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.backup.models.BackupCategory import eu.kanade.tachiyomi.data.backup.models.BackupCategory
import eu.kanade.tachiyomi.data.backup.models.BackupHistory 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.coroutineScope
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import tachiyomi.domain.chapter.model.Chapter 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.manga.model.Manga
import tachiyomi.domain.track.model.Track import tachiyomi.domain.track.model.Track
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.io.File import java.io.File
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.time.ZonedDateTime
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
@ -23,6 +29,12 @@ class BackupRestorer(
private val context: Context, private val context: Context,
private val notifier: BackupNotifier, 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) private var backupManager = BackupManager(context)
@ -90,6 +102,8 @@ class BackupRestorer(
// Store source mapping for error messages // Store source mapping for error messages
val backupMaps = backup.backupBrokenSources.map { BackupSource(it.name, it.sourceId) } + backup.backupSources val backupMaps = backup.backupBrokenSources.map { BackupSource(it.name, it.sourceId) } + backup.backupSources
sourceMapping = backupMaps.associate { it.sourceId to it.name } sourceMapping = backupMaps.associate { it.sourceId to it.name }
zonedDateTime = ZonedDateTime.now()
currentRange = setMangaUpdateInterval.getCurrentFetchRange(zonedDateTime)
return coroutineScope { return coroutineScope {
// Restore individual manga // Restore individual manga
@ -122,7 +136,7 @@ class BackupRestorer(
try { try {
val dbManga = backupManager.getMangaFromDatabase(manga.url, manga.source) val dbManga = backupManager.getMangaFromDatabase(manga.url, manga.source)
if (dbManga == null) { val restoredManga = if (dbManga == null) {
// Manga not in database // Manga not in database
restoreExistingManga(manga, chapters, categories, history, tracks, backupCategories) restoreExistingManga(manga, chapters, categories, history, tracks, backupCategories)
} else { } else {
@ -132,6 +146,8 @@ class BackupRestorer(
// Fetch rest of manga information // Fetch rest of manga information
restoreNewManga(updatedManga, chapters, categories, history, tracks, backupCategories) restoreNewManga(updatedManga, chapters, categories, history, tracks, backupCategories)
} }
val updatedChapters = chapterRepository.getChapterByMangaId(restoredManga.id)
updateManga.awaitUpdateFetchInterval(restoredManga, updatedChapters, zonedDateTime, currentRange)
} catch (e: Exception) { } catch (e: Exception) {
val sourceName = sourceMapping[manga.source] ?: manga.source.toString() val sourceName = sourceMapping[manga.source] ?: manga.source.toString()
errors.add(Date() to "${manga.title} [$sourceName]: ${e.message}") errors.add(Date() to "${manga.title} [$sourceName]: ${e.message}")
@ -159,10 +175,11 @@ class BackupRestorer(
history: List<BackupHistory>, history: List<BackupHistory>,
tracks: List<Track>, tracks: List<Track>,
backupCategories: List<BackupCategory>, backupCategories: List<BackupCategory>,
) { ): Manga {
val fetchedManga = backupManager.restoreNewManga(manga) val fetchedManga = backupManager.restoreNewManga(manga)
backupManager.restoreChapters(fetchedManga, chapters) backupManager.restoreChapters(fetchedManga, chapters)
restoreExtras(fetchedManga, categories, history, tracks, backupCategories) restoreExtras(fetchedManga, categories, history, tracks, backupCategories)
return fetchedManga
} }
private suspend fun restoreNewManga( private suspend fun restoreNewManga(
@ -172,9 +189,10 @@ class BackupRestorer(
history: List<BackupHistory>, history: List<BackupHistory>,
tracks: List<Track>, tracks: List<Track>,
backupCategories: List<BackupCategory>, backupCategories: List<BackupCategory>,
) { ): Manga {
backupManager.restoreChapters(backupManga, chapters) backupManager.restoreChapters(backupManga, chapters)
restoreExtras(backupManga, categories, history, tracks, backupCategories) 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>) { private suspend fun restoreExtras(manga: Manga, categories: List<Int>, history: List<BackupHistory>, tracks: List<Track>, backupCategories: List<BackupCategory>) {

View file

@ -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_HAS_UNREAD
import tachiyomi.domain.library.service.LibraryPreferences.Companion.MANGA_NON_COMPLETED 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_NON_READ
import tachiyomi.domain.library.service.LibraryPreferences.Companion.MANGA_OUTSIDE_RELEASE_PERIOD
import tachiyomi.domain.manga.interactor.GetLibraryManga import tachiyomi.domain.manga.interactor.GetLibraryManga
import tachiyomi.domain.manga.interactor.GetManga import tachiyomi.domain.manga.interactor.GetManga
import tachiyomi.domain.manga.interactor.SetMangaUpdateInterval
import tachiyomi.domain.manga.model.Manga import tachiyomi.domain.manga.model.Manga
import tachiyomi.domain.manga.model.toMangaUpdate import tachiyomi.domain.manga.model.toMangaUpdate
import tachiyomi.domain.source.model.SourceNotInstalledException 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.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import java.io.File import java.io.File
import java.time.ZonedDateTime
import java.util.Date import java.util.Date
import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.TimeUnit 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 getTracks: GetTracks = Injekt.get()
private val insertTrack: InsertTrack = Injekt.get() private val insertTrack: InsertTrack = Injekt.get()
private val syncChaptersWithTrackServiceTwoWay: SyncChaptersWithTrackServiceTwoWay = Injekt.get() private val syncChaptersWithTrackServiceTwoWay: SyncChaptersWithTrackServiceTwoWay = Injekt.get()
private val setMangaUpdateInterval: SetMangaUpdateInterval = Injekt.get()
private val notifier = LibraryUpdateNotifier(context) private val notifier = LibraryUpdateNotifier(context)
@ -227,6 +231,10 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet
val hasDownloads = AtomicBoolean(false) val hasDownloads = AtomicBoolean(false)
val restrictions = libraryPreferences.libraryUpdateMangaRestriction().get() val restrictions = libraryPreferences.libraryUpdateMangaRestriction().get()
val now = ZonedDateTime.now()
val fetchRange = setMangaUpdateInterval.getCurrentFetchRange(now)
val higherLimit = fetchRange.second
coroutineScope { coroutineScope {
mangaToUpdate.groupBy { it.manga.source }.values mangaToUpdate.groupBy { it.manga.source }.values
.map { mangaInSource -> .map { mangaInSource ->
@ -247,6 +255,9 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet
manga, manga,
) { ) {
when { 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 -> MANGA_NON_COMPLETED in restrictions && manga.status.toInt() == SManga.COMPLETED ->
skippedUpdates.add(manga to context.getString(R.string.skipped_reason_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 -> { else -> {
try { try {
val newChapters = updateManga(manga) val newChapters = updateManga(manga, now, fetchRange)
.sortedByDescending { it.sourceOrder } .sortedByDescending { it.sourceOrder }
if (newChapters.isNotEmpty()) { if (newChapters.isNotEmpty()) {
@ -333,7 +344,7 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet
* @param manga the manga to update. * @param manga the manga to update.
* @return a pair of the inserted and removed chapters. * @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) val source = sourceManager.getOrStub(manga.source)
// Update manga metadata if needed // 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 // to get latest data so it doesn't get overwritten later on
val dbManga = getManga.await(manga.id)?.takeIf { it.favorite } ?: return emptyList() 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() { private suspend fun updateCovers() {

View file

@ -30,6 +30,7 @@ import eu.kanade.presentation.manga.EditCoverAction
import eu.kanade.presentation.manga.MangaScreen import eu.kanade.presentation.manga.MangaScreen
import eu.kanade.presentation.manga.components.DeleteChaptersDialog import eu.kanade.presentation.manga.components.DeleteChaptersDialog
import eu.kanade.presentation.manga.components.MangaCoverDialog 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.AssistContentScreen
import eu.kanade.presentation.util.Screen import eu.kanade.presentation.util.Screen
import eu.kanade.presentation.util.isTabletUi import eu.kanade.presentation.util.isTabletUi
@ -53,6 +54,7 @@ import logcat.LogPriority
import tachiyomi.core.util.lang.withIOContext import tachiyomi.core.util.lang.withIOContext
import tachiyomi.core.util.system.logcat import tachiyomi.core.util.system.logcat
import tachiyomi.domain.chapter.model.Chapter 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.domain.manga.model.Manga
import tachiyomi.presentation.core.screens.LoadingScreen import tachiyomi.presentation.core.screens.LoadingScreen
@ -100,6 +102,7 @@ class MangaScreen(
snackbarHostState = screenModel.snackbarHostState, snackbarHostState = screenModel.snackbarHostState,
dateRelativeTime = screenModel.relativeTime, dateRelativeTime = screenModel.relativeTime,
dateFormat = screenModel.dateFormat, dateFormat = screenModel.dateFormat,
intervalDisplay = screenModel::intervalDisplay,
isTabletUi = isTabletUi(), isTabletUi = isTabletUi(),
chapterSwipeStartAction = screenModel.chapterSwipeStartAction, chapterSwipeStartAction = screenModel.chapterSwipeStartAction,
chapterSwipeEndAction = screenModel.chapterSwipeEndAction, chapterSwipeEndAction = screenModel.chapterSwipeEndAction,
@ -121,7 +124,8 @@ class MangaScreen(
onCoverClicked = screenModel::showCoverDialog, onCoverClicked = screenModel::showCoverDialog,
onShareClicked = { shareManga(context, screenModel.manga, screenModel.source) }.takeIf { isHttpSource }, onShareClicked = { shareManga(context, screenModel.manga, screenModel.source) }.takeIf { isHttpSource },
onDownloadActionClicked = screenModel::runDownloadAction.takeIf { !successState.source.isLocalOrStub() }, 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 }, onMigrateClicked = { navigator.push(MigrateSearchScreen(successState.manga.id)) }.takeIf { successState.manga.favorite },
onMultiBookmarkClicked = screenModel::bookmarkChapters, onMultiBookmarkClicked = screenModel::bookmarkChapters,
onMultiMarkAsReadClicked = screenModel::markChaptersRead, onMultiMarkAsReadClicked = screenModel::markChaptersRead,
@ -207,6 +211,13 @@ class MangaScreen(
LoadingScreen(Modifier.systemBarsPadding()) 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) },
)
}
} }
} }

View file

@ -69,20 +69,22 @@ import tachiyomi.domain.manga.interactor.GetMangaWithChapters
import tachiyomi.domain.manga.interactor.SetMangaChapterFlags import tachiyomi.domain.manga.interactor.SetMangaChapterFlags
import tachiyomi.domain.manga.model.Manga import tachiyomi.domain.manga.model.Manga
import tachiyomi.domain.manga.model.applyFilter import tachiyomi.domain.manga.model.applyFilter
import tachiyomi.domain.manga.repository.MangaRepository
import tachiyomi.domain.source.service.SourceManager import tachiyomi.domain.source.service.SourceManager
import tachiyomi.domain.track.interactor.GetTracks import tachiyomi.domain.track.interactor.GetTracks
import tachiyomi.source.local.isLocal import tachiyomi.source.local.isLocal
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import kotlin.math.absoluteValue
class MangaScreenModel( class MangaScreenModel(
val context: Context, val context: Context,
val mangaId: Long, val mangaId: Long,
private val isFromSource: Boolean, private val isFromSource: Boolean,
private val downloadPreferences: DownloadPreferences = Injekt.get(), private val downloadPreferences: DownloadPreferences = Injekt.get(),
private val libraryPreferences: LibraryPreferences = Injekt.get(), val libraryPreferences: LibraryPreferences = Injekt.get(),
readerPreferences: ReaderPreferences = Injekt.get(), val readerPreferences: ReaderPreferences = Injekt.get(),
uiPreferences: UiPreferences = Injekt.get(), val uiPreferences: UiPreferences = Injekt.get(),
private val trackManager: TrackManager = Injekt.get(), private val trackManager: TrackManager = Injekt.get(),
private val downloadManager: DownloadManager = Injekt.get(), private val downloadManager: DownloadManager = Injekt.get(),
private val downloadCache: DownloadCache = Injekt.get(), private val downloadCache: DownloadCache = Injekt.get(),
@ -97,6 +99,7 @@ class MangaScreenModel(
private val getCategories: GetCategories = Injekt.get(), private val getCategories: GetCategories = Injekt.get(),
private val getTracks: GetTracks = Injekt.get(), private val getTracks: GetTracks = Injekt.get(),
private val setMangaCategories: SetMangaCategories = Injekt.get(), private val setMangaCategories: SetMangaCategories = Injekt.get(),
private val mangaRepository: MangaRepository = Injekt.get(),
val snackbarHostState: SnackbarHostState = SnackbarHostState(), val snackbarHostState: SnackbarHostState = SnackbarHostState(),
) : StateScreenModel<MangaScreenModel.State>(State.Loading) { ) : StateScreenModel<MangaScreenModel.State>(State.Loading) {
@ -307,7 +310,7 @@ class MangaScreenModel(
} }
// Choose a category // Choose a category
else -> promptChangeCategories() else -> showChangeCategoryDialog()
} }
// Finally match with enhanced tracking when available // Finally match with enhanced tracking when available
@ -333,7 +336,7 @@ class MangaScreenModel(
} }
} }
fun promptChangeCategories() { fun showChangeCategoryDialog() {
val manga = successState?.manga ?: return val manga = successState?.manga ?: return
coroutineScope.launch { coroutineScope.launch {
val categories = getCategories() 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. * Returns true if the manga has any downloads.
*/ */
@ -502,6 +538,7 @@ class MangaScreenModel(
chapters, chapters,
state.manga, state.manga,
state.source, state.source,
manualFetch,
) )
if (manualFetch) { if (manualFetch) {
@ -519,6 +556,8 @@ class MangaScreenModel(
coroutineScope.launch { coroutineScope.launch {
snackbarHostState.showSnackbar(message = message) 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 ChangeCategory(val manga: Manga, val initialSelection: List<CheckboxState<Category>>) : Dialog
data class DeleteChapters(val chapters: List<Chapter>) : Dialog data class DeleteChapters(val chapters: List<Chapter>) : Dialog
data class DuplicateManga(val manga: Manga, val duplicate: Manga) : Dialog data class DuplicateManga(val manga: Manga, val duplicate: Manga) : Dialog
data class SetMangaInterval(val manga: Manga) : Dialog
data object SettingsSheet : Dialog data object SettingsSheet : Dialog
data object TrackSheet : Dialog data object TrackSheet : Dialog
data object FullCover : Dialog data object FullCover : Dialog

View file

@ -13,111 +13,115 @@ import kotlin.math.absoluteValue
const val MAX_GRACE_PERIOD = 28 const val MAX_GRACE_PERIOD = 28
fun updateIntervalMeta( class SetMangaUpdateInterval(
manga: Manga, private val libraryPreferences: LibraryPreferences = Injekt.get(),
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) { fun updateInterval(
null manga: Manga,
} else { MangaUpdate(id = manga.id, nextUpdate = nextUpdate, calculateInterval = interval) } chapters: List<Chapter>,
} zonedDateTime: ZonedDateTime,
fetchRange: Pair<Long, Long>,
fun calculateInterval(chapters: List<Chapter>, zonedDateTime: ZonedDateTime): Int { ): MangaUpdate? {
val sortedChapters = chapters val currentFetchRange = if (fetchRange.first == 0L && fetchRange.second == 0L) {
.sortedWith(compareByDescending<Chapter> { it.dateUpload }.thenByDescending { it.dateFetch }) getCurrentFetchRange(ZonedDateTime.now())
.take(50) } else {
fetchRange
val uploadDates = sortedChapters
.filter { it.dateUpload > 0L }
.map {
ZonedDateTime.ofInstant(Instant.ofEpochMilli(it.dateUpload), zonedDateTime.zone)
.toLocalDate()
.atStartOfDay()
} }
.distinct() val interval = manga.calculateInterval.takeIf { it < 0 } ?: calculateInterval(chapters, zonedDateTime)
val fetchDates = sortedChapters val nextUpdate = calculateNextUpdate(manga, interval, zonedDateTime, currentFetchRange)
.map {
ZonedDateTime.ofInstant(Instant.ofEpochMilli(it.dateFetch), zonedDateTime.zone) return if (manga.nextUpdate == nextUpdate && manga.calculateInterval == interval) {
.toLocalDate() null
.atStartOfDay() } else {
MangaUpdate(id = manga.id, nextUpdate = nextUpdate, calculateInterval = interval)
} }
.distinct() }
val newInterval = when { fun getCurrentFetchRange(timeToCal: ZonedDateTime): Pair<Long, Long> {
// Enough upload date from source // lead range and the following range depend on if updateOnlyExpectedPeriod set.
uploadDates.size >= 3 -> { var followRange = 0
val uploadDelta = uploadDates.last().until(uploadDates.first(), ChronoUnit.DAYS) var leadRange = 0
val uploadPeriod = uploadDates.indexOf(uploadDates.last()) if (LibraryPreferences.MANGA_OUTSIDE_RELEASE_PERIOD in libraryPreferences.libraryUpdateMangaRestriction().get()) {
uploadDelta.floorDiv(uploadPeriod).toInt() followRange = libraryPreferences.followingExpectedDays().get()
leadRange = libraryPreferences.leadingExpectedDays().get()
} }
// Enough fetch date from client val startToday = timeToCal.toLocalDate().atStartOfDay(timeToCal.zone)
fetchDates.size >= 3 -> { // revert math of (next_update + follow < now) become (next_update < now - follow)
val fetchDelta = fetchDates.last().until(fetchDates.first(), ChronoUnit.DAYS) // so (now - follow) become lower limit
val uploadPeriod = fetchDates.indexOf(fetchDates.last()) val lowerRange = startToday.minusDays(followRange.toLong())
fetchDelta.floorDiv(uploadPeriod).toInt() val higherRange = startToday.plusDays(leadRange.toLong())
return Pair(lowerRange.toEpochSecond() * 1000, higherRange.toEpochSecond() * 1000 - 1)
}
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
} }
// Default to 7 days // Min 1, max 28 days
else -> 7 return interval.coerceIn(1, MAX_GRACE_PERIOD)
} }
// Min 1, max 28 days
return newInterval.coerceIn(1, MAX_GRACE_PERIOD)
}
private fun calculateNextUpdate( private fun calculateNextUpdate(
manga: Manga, manga: Manga,
interval: Int, interval: Int,
zonedDateTime: ZonedDateTime, zonedDateTime: ZonedDateTime,
currentFetchRange: Pair<Long, Long>, fetchRange: Pair<Long, Long>,
): Long { ): Long {
return if (manga.nextUpdate !in currentFetchRange.first.rangeTo(currentFetchRange.second + 1) || return if (
manga.calculateInterval == 0 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 latestDate = ZonedDateTime.ofInstant(Instant.ofEpochMilli(manga.lastUpdate), zonedDateTime.zone).toLocalDate().atStartOfDay()
val cycle = timeSinceLatest.floorDiv(interval.absoluteValue.takeIf { interval < 0 } ?: doubleInterval(interval, timeSinceLatest, doubleWhenOver = 10, maxValue = 28)) val timeSinceLatest = ChronoUnit.DAYS.between(latestDate, zonedDateTime).toInt()
latestDate.plusDays((cycle + 1) * interval.toLong()).toEpochSecond(zonedDateTime.offset) * 1000 val cycle = timeSinceLatest.floorDiv(interval.absoluteValue.takeIf { interval < 0 } ?: doubleInterval(interval, timeSinceLatest, doubleWhenOver = 10, maxValue = 28))
} else { latestDate.plusDays((cycle + 1) * interval.toLong()).toEpochSecond(zonedDateTime.offset) * 1000
manga.nextUpdate } 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<Long, Long> {
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)
}

View file

@ -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
}
}

View file

@ -641,6 +641,10 @@
<item quantity="one">1 day</item> <item quantity="one">1 day</item>
<item quantity="other">%d days</item> <item quantity="other">%d days</item>
</plurals> </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 --> <!-- Manga info -->
<plurals name="missing_chapters"> <plurals name="missing_chapters">
@ -682,8 +686,7 @@
<string name="display_mode_chapter">Chapter %1$s</string> <string name="display_mode_chapter">Chapter %1$s</string>
<string name="manga_display_interval_title">Estimate every</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_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_downloading_progress">Downloading (%1$d/%2$d)</string>
<string name="chapter_error">Error</string> <string name="chapter_error">Error</string>
<string name="chapter_paused">Paused</string> <string name="chapter_paused">Paused</string>