From c90f3449108a8362fea1ee9070979b5a14be9be5 Mon Sep 17 00:00:00 2001 From: Quang Kieu Date: Sat, 27 May 2023 23:01:36 -0400 Subject: [PATCH] Add setting and calculate for update interval (#9399) * Add Grace Period value and settings * Add functions to calculate nextUpdate * update per review * Move more into SetMangaUpdateInterval, keep wrapper --- .../domain/manga/interactor/UpdateManga.kt | 19 +++ .../settings/screen/SettingsLibraryScreen.kt | 138 +++++++++++++++++- .../library/service/LibraryPreferences.kt | 15 ++ .../interactor/SetMangaUpdateInterval.kt | 108 ++++++++++++++ i18n/src/main/res/values/strings.xml | 27 ++++ 5 files changed, 302 insertions(+), 5 deletions(-) create mode 100644 domain/src/main/java/tachiyomi/domain/manga/interactor/SetMangaUpdateInterval.kt diff --git a/app/src/main/java/eu/kanade/domain/manga/interactor/UpdateManga.kt b/app/src/main/java/eu/kanade/domain/manga/interactor/UpdateManga.kt index 82a2e8819..cb1727aa2 100644 --- a/app/src/main/java/eu/kanade/domain/manga/interactor/UpdateManga.kt +++ b/app/src/main/java/eu/kanade/domain/manga/interactor/UpdateManga.kt @@ -3,12 +3,16 @@ package eu.kanade.domain.manga.interactor 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.model.Manga import tachiyomi.domain.manga.model.MangaUpdate import tachiyomi.domain.manga.repository.MangaRepository import tachiyomi.source.local.isLocal import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get +import java.time.ZonedDateTime import java.util.Date class UpdateManga( @@ -73,6 +77,21 @@ class UpdateManga( ) } + suspend fun awaitUpdateIntervalMeta( + manga: Manga, + chapters: List, + zonedDateTime: ZonedDateTime = ZonedDateTime.now(), + setCurrentFetchRange: Pair = getCurrentFetchRange(zonedDateTime), + ): Boolean { + val newMeta = updateIntervalMeta(manga, chapters, zonedDateTime, setCurrentFetchRange) + + return if (newMeta != null) { + mangaRepository.update(newMeta) + } else { + true + } + } + suspend fun awaitUpdateLastUpdate(mangaId: Long): Boolean { return mangaRepository.update(MangaUpdate(id = mangaId, lastUpdate = Date().time)) } diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsLibraryScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsLibraryScreen.kt index fde5d9fe1..675b2e21b 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsLibraryScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsLibraryScreen.kt @@ -1,6 +1,13 @@ package eu.kanade.presentation.more.settings.screen import androidx.annotation.StringRes +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.collectAsState @@ -10,9 +17,14 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope 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.platform.LocalContext import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastMap import androidx.core.content.ContextCompat import cafe.adriel.voyager.navigator.LocalNavigator @@ -39,6 +51,9 @@ 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.presentation.core.components.WheelPickerDefaults +import tachiyomi.presentation.core.components.WheelTextPicker import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get @@ -132,8 +147,8 @@ object SettingsLibraryScreen : SearchableSettings { val included by libraryUpdateCategoriesPref.collectAsState() val excluded by libraryUpdateCategoriesExcludePref.collectAsState() - var showDialog by rememberSaveable { mutableStateOf(false) } - if (showDialog) { + var showCategoriesDialog by rememberSaveable { mutableStateOf(false) } + if (showCategoriesDialog) { TriStateListDialog( title = stringResource(R.string.categories), message = stringResource(R.string.pref_library_update_categories_details), @@ -141,11 +156,27 @@ object SettingsLibraryScreen : SearchableSettings { initialChecked = included.mapNotNull { id -> allCategories.find { it.id.toString() == id } }, initialInversed = excluded.mapNotNull { id -> allCategories.find { it.id.toString() == id } }, itemLabel = { it.visualName }, - onDismissRequest = { showDialog = false }, + onDismissRequest = { showCategoriesDialog = false }, onValueChanged = { newIncluded, newExcluded -> libraryUpdateCategoriesPref.set(newIncluded.map { it.id.toString() }.toSet()) libraryUpdateCategoriesExcludePref.set(newExcluded.map { it.id.toString() }.toSet()) - showDialog = false + showCategoriesDialog = false + }, + ) + } + val leadRange by libraryPreferences.leadingExpectedDays().collectAsState() + val followRange by libraryPreferences.followingExpectedDays().collectAsState() + + var showFetchRangesDialog by rememberSaveable { mutableStateOf(false) } + if (showFetchRangesDialog) { + LibraryExpectedRangeDialog( + initialLead = leadRange, + initialFollow = followRange, + onDismissRequest = { showFetchRangesDialog = false }, + onValueChanged = { leadValue, followValue -> + libraryPreferences.leadingExpectedDays().set(leadValue) + libraryPreferences.followingExpectedDays().set(followValue) + showFetchRangesDialog = false }, ) } @@ -192,8 +223,27 @@ object SettingsLibraryScreen : SearchableSettings { 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_library_update_manga_restriction), + subtitle = setOf( + stringResource(R.string.pref_update_release_leading_days, leadRange), + stringResource(R.string.pref_update_release_following_days, followRange), + ) + .joinToString(";"), + onClick = { showFetchRangesDialog = true }, + ), + Preference.PreferenceItem.InfoPreference( + title = stringResource(R.string.pref_update_release_grace_period_info1), + ), + Preference.PreferenceItem.InfoPreference( + title = stringResource(R.string.pref_update_release_grace_period_info2), + ), + Preference.PreferenceItem.InfoPreference( + title = stringResource(R.string.pref_update_release_grace_period_info3), + ), Preference.PreferenceItem.TextPreference( title = stringResource(R.string.categories), subtitle = getCategoriesLabel( @@ -201,7 +251,7 @@ object SettingsLibraryScreen : SearchableSettings { included = included, excluded = excluded, ), - onClick = { showDialog = true }, + onClick = { showCategoriesDialog = true }, ), Preference.PreferenceItem.SwitchPreference( pref = libraryPreferences.autoUpdateMetadata(), @@ -248,4 +298,82 @@ object SettingsLibraryScreen : SearchableSettings { ), ) } + + @Composable + private fun LibraryExpectedRangeDialog( + initialLead: Int, + initialFollow: Int, + onDismissRequest: () -> Unit, + onValueChanged: (portrait: Int, landscape: Int) -> Unit, + ) { + val context = LocalContext.current + var leadValue by rememberSaveable { mutableStateOf(initialLead) } + var followValue by rememberSaveable { mutableStateOf(initialFollow) } + + AlertDialog( + onDismissRequest = onDismissRequest, + title = { Text(text = stringResource(R.string.pref_update_release_grace_period)) }, + text = { + Row { + Text( + modifier = Modifier.weight(1f), + text = stringResource(R.string.pref_update_release_leading_days, "x"), + textAlign = TextAlign.Center, + maxLines = 1, + style = MaterialTheme.typography.labelMedium, + ) + Text( + modifier = Modifier.weight(1f), + text = stringResource(R.string.pref_update_release_following_days, "x"), + textAlign = TextAlign.Center, + maxLines = 1, + style = MaterialTheme.typography.labelMedium, + ) + } + BoxWithConstraints( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + WheelPickerDefaults.Background(size = DpSize(maxWidth, maxHeight)) + + val size = DpSize(width = maxWidth / 2, height = 128.dp) + val items = (0..28).map { + if (it == 0) { + stringResource(R.string.label_default) + } else { + it.toString() + } + } + Row { + WheelTextPicker( + size = size, + items = items, + startIndex = leadValue, + onSelectionChanged = { + leadValue = it + }, + ) + WheelTextPicker( + size = size, + items = items, + startIndex = followValue, + onSelectionChanged = { + followValue = it + }, + ) + } + } + }, + dismissButton = { + TextButton(onClick = onDismissRequest) { + Text(text = stringResource(android.R.string.cancel)) + } + }, + confirmButton = { + TextButton(onClick = { onValueChanged(leadValue, followValue) }) { + Text(text = stringResource(android.R.string.ok)) + } + }, + ) + } } diff --git a/domain/src/main/java/tachiyomi/domain/library/service/LibraryPreferences.kt b/domain/src/main/java/tachiyomi/domain/library/service/LibraryPreferences.kt index d6f0ad141..de66117ad 100644 --- a/domain/src/main/java/tachiyomi/domain/library/service/LibraryPreferences.kt +++ b/domain/src/main/java/tachiyomi/domain/library/service/LibraryPreferences.kt @@ -34,9 +34,13 @@ class LibraryPreferences( MANGA_HAS_UNREAD, MANGA_NON_COMPLETED, MANGA_NON_READ, + MANGA_OUTSIDE_RELEASE_PERIOD, ), ) + fun leadingExpectedDays() = preferenceStore.getInt("pref_library_before_expect_key", 1) + fun followingExpectedDays() = preferenceStore.getInt("pref_library_after_expect_key", 1) + fun autoUpdateMetadata() = preferenceStore.getBoolean("auto_update_metadata", false) fun autoUpdateTrackers() = preferenceStore.getBoolean("auto_update_trackers", false) @@ -55,6 +59,16 @@ class LibraryPreferences( fun filterCompleted() = preferenceStore.getEnum("pref_filter_library_completed_v2", TriStateFilter.DISABLED) + fun filterIntervalCustom() = preferenceStore.getEnum("pref_filter_library_interval_custom", TriStateFilter.DISABLED) + + fun filterIntervalLong() = preferenceStore.getEnum("pref_filter_library_interval_long", TriStateFilter.DISABLED) + + fun filterIntervalLate() = preferenceStore.getEnum("pref_filter_library_interval_late", TriStateFilter.DISABLED) + + fun filterIntervalDropped() = preferenceStore.getEnum("pref_filter_library_interval_dropped", TriStateFilter.DISABLED) + + fun filterIntervalPassed() = preferenceStore.getEnum("pref_filter_library_interval_passed", TriStateFilter.DISABLED) + fun filterTracking(id: Int) = preferenceStore.getEnum("pref_filter_library_tracked_${id}_v2", TriStateFilter.DISABLED) // endregion @@ -142,5 +156,6 @@ class LibraryPreferences( const val MANGA_NON_COMPLETED = "manga_ongoing" const val MANGA_HAS_UNREAD = "manga_fully_read" const val MANGA_NON_READ = "manga_started" + const val MANGA_OUTSIDE_RELEASE_PERIOD = "manga_outside_release_period" } } diff --git a/domain/src/main/java/tachiyomi/domain/manga/interactor/SetMangaUpdateInterval.kt b/domain/src/main/java/tachiyomi/domain/manga/interactor/SetMangaUpdateInterval.kt new file mode 100644 index 000000000..8a401b028 --- /dev/null +++ b/domain/src/main/java/tachiyomi/domain/manga/interactor/SetMangaUpdateInterval.kt @@ -0,0 +1,108 @@ +package tachiyomi.domain.manga.interactor + +import tachiyomi.domain.chapter.model.Chapter +import tachiyomi.domain.library.service.LibraryPreferences +import tachiyomi.domain.manga.model.Manga +import tachiyomi.domain.manga.model.MangaUpdate +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get +import java.time.Instant +import java.time.ZonedDateTime +import java.time.temporal.ChronoUnit +import kotlin.math.absoluteValue + +fun updateIntervalMeta( + manga: Manga, + chapters: List, + zonedDateTime: ZonedDateTime = ZonedDateTime.now(), + setCurrentFetchRange: Pair = getCurrentFetchRange(zonedDateTime), +): MangaUpdate? { + val currentFetchRange = if (setCurrentFetchRange.first == 0L && setCurrentFetchRange.second == 0L) { + getCurrentFetchRange(ZonedDateTime.now()) + } else { + setCurrentFetchRange + } + val interval = manga.calculateInterval.takeIf { it < 0 } ?: calculateInterval(chapters, zonedDateTime) + val nextUpdate = calculateNextUpdate(manga, interval, zonedDateTime, currentFetchRange) + + return if (manga.nextUpdate == nextUpdate && manga.calculateInterval == interval) { + null + } else { MangaUpdate(id = manga.id, nextUpdate = nextUpdate, calculateInterval = interval) } +} +fun calculateInterval(chapters: List, zonedDateTime: ZonedDateTime): Int { + val sortChapters = + chapters.sortedWith(compareBy { it.dateUpload }.thenBy { it.dateFetch }) + .reversed().take(50) + val uploadDates = sortChapters.filter { it.dateUpload != 0L }.map { + ZonedDateTime.ofInstant(Instant.ofEpochMilli(it.dateUpload), zonedDateTime.zone).toLocalDate() + .atStartOfDay() + } + val uploadDateDistinct = uploadDates.distinctBy { it } + val fetchDates = sortChapters.map { + ZonedDateTime.ofInstant(Instant.ofEpochMilli(it.dateFetch), zonedDateTime.zone).toLocalDate() + .atStartOfDay() + } + val fetchDatesDistinct = fetchDates.distinctBy { it } + val newInterval = when { + // enough upload date from source + (uploadDateDistinct.size >= 3) -> { + val uploadDelta = uploadDateDistinct.last().until(uploadDateDistinct.first(), ChronoUnit.DAYS) + val uploadPeriod = uploadDates.indexOf(uploadDateDistinct.last()) + (uploadDelta).floorDiv(uploadPeriod).toInt() + } + // enough fetch date from client + (fetchDatesDistinct.size >= 3) -> { + val fetchDelta = fetchDatesDistinct.last().until(fetchDatesDistinct.first(), ChronoUnit.DAYS) + val uploadPeriod = fetchDates.indexOf(fetchDatesDistinct.last()) + (fetchDelta).floorDiv(uploadPeriod).toInt() + } + // default 7 days + else -> 7 + } + // min 1, max 28 days + return newInterval.coerceIn(1, 28) +} + +private fun calculateNextUpdate(manga: Manga, interval: Int, zonedDateTime: ZonedDateTime, currentFetchRange: Pair): Long { + return if (manga.nextUpdate !in currentFetchRange.first.rangeTo(currentFetchRange.second + 1) || + manga.calculateInterval == 0 + ) { + val latestDate = ZonedDateTime.ofInstant(Instant.ofEpochMilli(manga.lastUpdate), zonedDateTime.zone).toLocalDate().atStartOfDay() + val timeSinceLatest = ChronoUnit.DAYS.between(latestDate, zonedDateTime).toInt() + val cycle = timeSinceLatest.floorDiv(interval.absoluteValue.takeIf { interval < 0 } ?: doubleInterval(interval, timeSinceLatest, doubleWhenOver = 10, maxValue = 28)) + latestDate.plusDays((cycle + 1) * interval.toLong()).toEpochSecond(zonedDateTime.offset) * 1000 + } else { + manga.nextUpdate + } +} + +private fun doubleInterval(delta: Int, timeSinceLatest: Int, doubleWhenOver: Int, maxValue: Int): Int { + if (delta >= maxValue) return maxValue + val cycle = timeSinceLatest.floorDiv(delta) + 1 + // double delta again if missed more than 9 check in new delta + return if (cycle > doubleWhenOver) { + doubleInterval(delta * 2, timeSinceLatest, doubleWhenOver, maxValue) + } else { + delta + } +} + +fun getCurrentFetchRange( + timeToCal: ZonedDateTime, +): Pair { + val preferences: LibraryPreferences = Injekt.get() + + // lead range and the following range depend on if updateOnlyExpectedPeriod set. + var followRange = 0 + var leadRange = 0 + if (LibraryPreferences.MANGA_OUTSIDE_RELEASE_PERIOD in preferences.libraryUpdateMangaRestriction().get()) { + followRange = preferences.followingExpectedDays().get() + leadRange = preferences.leadingExpectedDays().get() + } + val startToday = timeToCal.toLocalDate().atStartOfDay(timeToCal.zone) + // revert math of (next_update + follow < now) become (next_update < now - follow) + // so (now - follow) become lower limit + val lowerRange = startToday.minusDays(followRange.toLong()) + val higherRange = startToday.plusDays(leadRange.toLong()) + return Pair(lowerRange.toEpochSecond() * 1000, higherRange.toEpochSecond() * 1000 - 1) +} diff --git a/i18n/src/main/res/values/strings.xml b/i18n/src/main/res/values/strings.xml index c68f38a03..ad750876c 100644 --- a/i18n/src/main/res/values/strings.xml +++ b/i18n/src/main/res/values/strings.xml @@ -44,9 +44,15 @@ Settings Menu Filter + Set interval Bookmarked Tracked Unread + Customized fetch interval + Fetch monthly (28 days) + Late 10+ check + Dropped? Late 20+ and 2 months + Passed check period Remove filter Alphabetically @@ -55,6 +61,7 @@ Last read Last update check Unread count + Next expected update Latest chapter Chapter fetch date Date added @@ -255,6 +262,16 @@ With \"Completed\" status That haven\'t been started Show unread count on Updates icon + Outside release period + + Grace release period: + Check %s day(s) before + Check %s day(s) after + It is recommended to keep small grace period to minimize stress on servers. + The more checks comic missed, the longer extend check interval (max at 28 day). + It is recommend to remove or migrate source if comic in Dropped status filter. + + Automatically refresh metadata Check for new cover and details when updating library Automatically refresh trackers @@ -593,6 +610,7 @@ Updating category From library Downloaded chapters + Intervals Overlay Tabs @@ -618,6 +636,10 @@ Invalid chapter format Order by Date + + 1 day + %d days + @@ -657,6 +679,10 @@ Chapter %1$s + Estimate every + Set to update every + Modify interval + Customize Interval Downloading (%1$d/%2$d) Error Paused @@ -855,6 +881,7 @@ Skipped because there are unread chapters Skipped because no chapters are read Skipped because series does not require updates + Skipped because no release was expected today Select cover image