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
This commit is contained in:
Quang Kieu 2023-05-27 23:01:36 -04:00 committed by GitHub
parent a458bd9fdb
commit c90f344910
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 302 additions and 5 deletions

View file

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

View file

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

View file

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

View file

@ -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<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 sortChapters =
chapters.sortedWith(compareBy<Chapter> { 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, 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
}
}
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

@ -44,9 +44,15 @@
<string name="action_settings">Settings</string>
<string name="action_menu">Menu</string>
<string name="action_filter">Filter</string>
<string name="action_set_interval">Set interval</string>
<string name="action_filter_bookmarked">Bookmarked</string>
<string name="action_filter_tracked">Tracked</string>
<string name="action_filter_unread">Unread</string>
<string name="action_filter_interval_custom">Customized fetch interval</string>
<string name="action_filter_interval_long">Fetch monthly (28 days)</string>
<string name="action_filter_interval_late">Late 10+ check</string>
<string name="action_filter_interval_dropped">Dropped? Late 20+ and 2 months</string>
<string name="action_filter_interval_passed">Passed check period</string>
<!-- reserved for #4048 -->
<string name="action_filter_empty">Remove filter</string>
<string name="action_sort_alpha">Alphabetically</string>
@ -55,6 +61,7 @@
<string name="action_sort_last_read">Last read</string>
<string name="action_sort_last_manga_update">Last update check</string>
<string name="action_sort_unread_count">Unread count</string>
<string name="action_sort_next_updated">Next expected update</string>
<string name="action_sort_latest_chapter">Latest chapter</string>
<string name="action_sort_chapter_fetch_date">Chapter fetch date</string>
<string name="action_sort_date_added">Date added</string>
@ -255,6 +262,16 @@
<string name="pref_update_only_non_completed">With \"Completed\" status</string>
<string name="pref_update_only_started">That haven\'t been started</string>
<string name="pref_library_update_show_tab_badge">Show unread count on Updates icon</string>
<string name="pref_update_only_in_release_period">Outside release period</string>
<string name="pref_update_release_grace_period">Grace release period:</string>
<string name="pref_update_release_leading_days">Check %s day(s) before</string>
<string name="pref_update_release_following_days">Check %s day(s) after</string>
<string name="pref_update_release_grace_period_info1">It is recommended to keep small grace period to minimize stress on servers.</string>
<string name="pref_update_release_grace_period_info2">The more checks comic missed, the longer extend check interval (max at 28 day).</string>
<string name="pref_update_release_grace_period_info3">It is recommend to remove or migrate source if comic in Dropped status filter.</string>
<string name="pref_library_update_refresh_metadata">Automatically refresh metadata</string>
<string name="pref_library_update_refresh_metadata_summary">Check for new cover and details when updating library</string>
<string name="pref_library_update_refresh_trackers">Automatically refresh trackers</string>
@ -593,6 +610,7 @@
<string name="updating_category">Updating category</string>
<string name="manga_from_library">From library</string>
<string name="downloaded_chapters">Downloaded chapters</string>
<string name="intervals_header">Intervals</string>
<!-- For badges/buttons on library covers. -->
<string name="overlay_header">Overlay</string>
<string name="tabs_header">Tabs</string>
@ -618,6 +636,10 @@
<string name="local_invalid_format">Invalid chapter format</string>
<string name="local_filter_order_by">Order by</string>
<string name="date">Date</string>
<plurals name="day">
<item quantity="one">1 day</item>
<item quantity="other">%d days</item>
</plurals>
<!-- Manga info -->
<plurals name="missing_chapters">
@ -657,6 +679,10 @@
<!-- Manga chapters -->
<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="chapter_downloading_progress">Downloading (%1$d/%2$d)</string>
<string name="chapter_error">Error</string>
<string name="chapter_paused">Paused</string>
@ -855,6 +881,7 @@
<string name="skipped_reason_not_caught_up">Skipped because there are unread chapters</string>
<string name="skipped_reason_not_started">Skipped because no chapters are read</string>
<string name="skipped_reason_not_always_update">Skipped because series does not require updates</string>
<string name="skipped_reason_not_in_release_period">Skipped because no release was expected today</string>
<!-- File Picker Titles -->
<string name="file_select_cover">Select cover image</string>