feat: Mark duplicate read chapters as read
Some checks are pending
Build app / Build app (push) Waiting to run
Mirror Repository / mirror (push) Waiting to run

This also refactor how chapters progress are saved. Chapters' progress now save when user "flipped" the page.

Closes GH-409

Co-authored-by: AntsyLich <59261191+AntsyLich@users.noreply.github.com>
This commit is contained in:
Ahmad Ansori Palembani 2025-05-27 21:47:41 +07:00
parent d3050d5799
commit 850151720b
Signed by: null2264
GPG key ID: BA64F8B60AF3EFB6
7 changed files with 134 additions and 81 deletions

View file

@ -14,10 +14,12 @@ The format is simplified version of [Keep a Changelog](https://keepachangelog.co
- Add random library sort
- Add the ability to save search queries
- Add toggle to enable/disable hide source on swipe (@Hiirbaf)
- Add the ability to mark duplicate read chapters as read (@AntsyLich)
### Changes
- Temporarily disable log file
- Categories' header now show filtered count when you search the library when you have "Show number of items" enabled (@LeeSF03)
- Chapter progress now saved everything the page is changed
### Fixes
- Allow users to bypass onboarding's permission step if Shizuku is installed

View file

@ -148,6 +148,14 @@ import eu.kanade.tachiyomi.util.view.setMessage
import eu.kanade.tachiyomi.util.view.snack
import eu.kanade.tachiyomi.widget.doOnEnd
import eu.kanade.tachiyomi.widget.doOnStart
import java.io.ByteArrayOutputStream
import java.text.DecimalFormat
import java.text.DecimalFormatSymbols
import java.util.Collections
import java.util.Locale
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.roundToInt
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
@ -167,13 +175,6 @@ import yokai.domain.ui.settings.ReaderPreferences
import yokai.domain.ui.settings.ReaderPreferences.LandscapeCutoutBehaviour
import yokai.i18n.MR
import yokai.util.lang.getString
import java.io.ByteArrayOutputStream
import java.text.DecimalFormat
import java.text.DecimalFormatSymbols
import java.util.*
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.roundToInt
import android.R as AR
/**
@ -512,7 +513,6 @@ class ReaderActivity : BaseActivity<ReaderActivityBinding>() {
}
}
}
viewModel.onSaveInstanceState()
super.onSaveInstanceState(outState)
}
@ -1304,13 +1304,13 @@ class ReaderActivity : BaseActivity<ReaderActivityBinding>() {
}
override fun onPause() {
viewModel.saveCurrentChapterReadingProgress()
viewModel.flushReadTimer()
super.onPause()
}
override fun onResume() {
super.onResume()
viewModel.setReadStartTime()
viewModel.restartReadTimer()
}
fun reloadChapters(doublePages: Boolean, force: Boolean = false) {

View file

@ -55,9 +55,7 @@ import eu.kanade.tachiyomi.util.system.withIOContext
import eu.kanade.tachiyomi.util.system.withUIContext
import java.util.Date
import java.util.concurrent.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
@ -81,6 +79,7 @@ import yokai.domain.chapter.models.ChapterUpdate
import yokai.domain.download.DownloadPreferences
import yokai.domain.history.interactor.GetHistory
import yokai.domain.history.interactor.UpsertHistory
import yokai.domain.library.LibraryPreferences
import yokai.domain.manga.interactor.GetManga
import yokai.domain.manga.interactor.InsertManga
import yokai.domain.manga.interactor.UpdateManga
@ -102,6 +101,7 @@ class ReaderViewModel(
private val chapterFilter: ChapterFilter = Injekt.get(),
private val storageManager: StorageManager = Injekt.get(),
private val downloadPreferences: DownloadPreferences = Injekt.get(),
private val libraryPreferences: LibraryPreferences = Injekt.get(),
) : ViewModel() {
private val getCategories: GetCategories by injectLazy()
private val getChapter: GetChapter by injectLazy()
@ -160,8 +160,6 @@ class ReaderViewModel(
private var chapterItems = emptyList<ReaderChapterItem>()
private var scope = CoroutineScope(Job() + Dispatchers.Default)
private var hasTrackers: Boolean = false
private suspend fun checkTrackers(manga: Manga) = getTrack.awaitAllByMangaId(manga.id).isNotEmpty()
@ -192,24 +190,12 @@ class ReaderViewModel(
val currentChapters = state.value.viewerChapters
if (currentChapters != null) {
currentChapters.unref()
saveReadingProgress(currentChapters.currChapter)
chapterToDownload?.let {
downloadManager.addDownloadsToStartOfQueue(listOf(it))
}
}
}
/**
* Called when the activity is saved and not changing configurations. It updates the database
* to persist the current progress of the active chapter.
*/
fun onSaveInstanceState() {
val currentChapter = getCurrentChapter() ?: return
viewModelScope.launchNonCancellableIO {
saveChapterProgress(currentChapter)
}
}
/**
* Whether this presenter is initialized yet.
*/
@ -375,12 +361,15 @@ class ReaderViewModel(
* Called when the user changed to the given [chapter] when changing pages from the viewer.
* It's used only to set this chapter as active.
*/
private suspend fun loadNewChapter(chapter: ReaderChapter) {
private fun loadNewChapter(chapter: ReaderChapter) {
val loader = loader ?: return
Logger.d { "Loading ${chapter.chapter.url}" }
viewModelScope.launchIO {
Logger.d { "Loading ${chapter.chapter.url}" }
flushReadTimer()
restartReadTimer()
withIOContext {
try {
loadChapter(loader, chapter)
} catch (e: Throwable) {
@ -511,28 +500,15 @@ class ReaderViewModel(
val selectedChapter = page.chapter
// Save last page read and mark as read if needed
selectedChapter.chapter.last_page_read = page.index
selectedChapter.chapter.pages_left =
(selectedChapter.pages?.size ?: page.index) - page.index
val shouldTrack = !preferences.incognitoMode().get() || hasTrackers
if (shouldTrack &&
// For double pages, check if the second to last page is doubled up
(
(selectedChapter.pages?.lastIndex == page.index && page.firstHalf != true) ||
(hasExtraPage && selectedChapter.pages?.lastIndex?.minus(1) == page.index)
)
) {
selectedChapter.chapter.read = true
updateTrackChapterAfterReading(selectedChapter)
deleteChapterIfNeeded(selectedChapter)
viewModelScope.launchNonCancellableIO {
saveChapterProgress(selectedChapter, page, hasExtraPage)
}
if (selectedChapter != currentChapters.currChapter) {
Logger.d { "Setting ${selectedChapter.chapter.url} as active" }
saveReadingProgress(currentChapters.currChapter)
setReadStartTime()
scope.launch { loadNewChapter(selectedChapter) }
loadNewChapter(selectedChapter)
}
val pages = page.chapter.pages ?: return
val inDownloadRange = page.number.toDouble() / pages.size > 0.2
if (inDownloadRange) {
@ -620,28 +596,28 @@ class ReaderViewModel(
}
}
/**
* Called when reader chapter is changed in reader or when activity is paused.
*/
private fun saveReadingProgress(readerChapter: ReaderChapter) {
viewModelScope.launchNonCancellableIO {
saveChapterProgress(readerChapter)
saveChapterHistory(readerChapter)
}
}
fun saveCurrentChapterReadingProgress() = getCurrentChapter()?.let { saveReadingProgress(it) }
/**
* Saves this [readerChapter]'s progress (last read page and whether it's read).
* If incognito mode isn't on or has at least 1 tracker
*/
private suspend fun saveChapterProgress(readerChapter: ReaderChapter) {
private suspend fun saveChapterProgress(readerChapter: ReaderChapter, page: ReaderPage, hasExtraPage: Boolean) {
readerChapter.requestedPage = readerChapter.chapter.last_page_read
getChapter.awaitById(readerChapter.chapter.id!!)?.let { dbChapter ->
readerChapter.chapter.bookmark = dbChapter.bookmark
}
if (!preferences.incognitoMode().get() || hasTrackers) {
val shouldTrack = !preferences.incognitoMode().get() || hasTrackers
if (shouldTrack && page.status != Page.State.ERROR) {
readerChapter.chapter.last_page_read = page.index
readerChapter.chapter.pages_left = (readerChapter.pages?.size ?: page.index) - page.index
// For double pages, check if the second to last page is doubled up
if (
(readerChapter.pages?.lastIndex == page.index && page.firstHalf != true) ||
(hasExtraPage && readerChapter.pages?.lastIndex?.minus(1) == page.index)
) {
onChapterReadComplete(readerChapter)
}
updateChapter.await(
ChapterUpdate(
id = readerChapter.chapter.id!!,
@ -654,24 +630,57 @@ class ReaderViewModel(
}
}
private suspend fun onChapterReadComplete(readerChapter: ReaderChapter) {
readerChapter.chapter.read = true
updateTrackChapterAfterReading(readerChapter)
deleteChapterIfNeeded(readerChapter)
val markDuplicateAsRead = libraryPreferences.markDuplicateReadChapterAsRead().get()
.contains(LibraryPreferences.MARK_DUPLICATE_READ_CHAPTER_READ_EXISTING)
if (!markDuplicateAsRead) return
val duplicateUnreadChapters = chapterList
.mapNotNull {
val chapter = it.chapter
if (
!chapter.read &&
chapter.isRecognizedNumber &&
chapter.chapter_number == readerChapter.chapter.chapter_number
) {
ChapterUpdate(id = chapter.id!!, read = true)
} else {
null
}
}
updateChapter.awaitAll(duplicateUnreadChapters)
}
fun restartReadTimer() {
chapterReadStartTime = Date().time
}
fun flushReadTimer() {
getCurrentChapter()?.let {
viewModelScope.launchNonCancellableIO {
saveChapterHistory(it)
}
}
}
/**
* Saves this [readerChapter] last read history.
*/
private suspend fun saveChapterHistory(readerChapter: ReaderChapter) {
if (!preferences.incognitoMode().get()) {
val readAt = Date().time
val sessionReadDuration = chapterReadStartTime?.let { readAt - it } ?: 0
val history = History.create(readerChapter.chapter).apply {
last_read = readAt
time_read = sessionReadDuration
}
upsertHistory.await(history)
chapterReadStartTime = null
}
}
if (preferences.incognitoMode().get()) return
fun setReadStartTime() {
chapterReadStartTime = Date().time
val endTime = Date().time
val sessionReadDuration = chapterReadStartTime?.let { endTime - it } ?: 0
val history = History.create(readerChapter.chapter).apply {
last_read = endTime
time_read = sessionReadDuration
}
upsertHistory.await(history)
chapterReadStartTime = null
}
/**
@ -876,7 +885,7 @@ class ReaderViewModel(
}
fun saveImages(firstPage: ReaderPage, secondPage: ReaderPage, isLTR: Boolean, @ColorInt bg: Int) {
scope.launch {
viewModelScope.launch {
if (firstPage.status != Page.State.READY) return@launch
if (secondPage.status != Page.State.READY) return@launch
val manga = manga ?: return@launch
@ -925,7 +934,7 @@ class ReaderViewModel(
}
fun shareImages(firstPage: ReaderPage, secondPage: ReaderPage, isLTR: Boolean, @ColorInt bg: Int) {
scope.launch {
viewModelScope.launch {
if (firstPage.status != Page.State.READY) return@launch
if (secondPage.status != Page.State.READY) return@launch
val manga = manga ?: return@launch

View file

@ -34,6 +34,7 @@ import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
import yokai.domain.category.interactor.GetCategories
import yokai.domain.library.LibraryPreferences
import yokai.domain.manga.interactor.GetLibraryManga
import yokai.domain.ui.UiPreferences
import yokai.i18n.MR
@ -48,6 +49,7 @@ class SettingsLibraryController : SettingsLegacyController() {
private val getCategories: GetCategories by injectLazy()
private val uiPreferences: UiPreferences by injectLazy()
private val libraryPreferences: LibraryPreferences by injectLazy()
override fun setupPreferenceScreen(screen: PreferenceScreen) = screen.apply {
titleRes = MR.strings.library
@ -212,12 +214,25 @@ class SettingsLibraryController : SettingsLegacyController() {
}
preferenceCategory {
titleRes = MR.strings.chapters
titleRes = MR.strings.pref_behavior
switchPreference {
bindTo(uiPreferences.enableChapterSwipeAction())
titleRes = MR.strings.enable_chapter_swipe_action
}
multiSelectListPreferenceMat(activity) {
bindTo(preferences.libraryUpdateMangaRestriction())
titleRes = MR.strings.pref_mark_as_read_duplicate_read_chapter
val entries = mapOf(
MR.strings.pref_mark_as_read_duplicate_read_chapter_existing to
LibraryPreferences.MARK_DUPLICATE_READ_CHAPTER_READ_EXISTING,
MR.strings.pref_mark_as_read_duplicate_read_chapter_new to
LibraryPreferences.MARK_DUPLICATE_READ_CHAPTER_READ_NEW,
)
entriesRes = entries.keys.toTypedArray()
entryValues = entries.values.toList()
noSelectionRes = MR.strings.none
}
}
}
}

View file

@ -17,6 +17,7 @@ import yokai.domain.chapter.interactor.InsertChapter
import yokai.domain.chapter.interactor.UpdateChapter
import yokai.domain.chapter.models.ChapterUpdate
import yokai.domain.chapter.services.ChapterRecognition
import yokai.domain.library.LibraryPreferences
import yokai.domain.manga.interactor.UpdateManga
import yokai.domain.manga.models.MangaUpdate
@ -39,6 +40,7 @@ suspend fun syncChaptersWithSource(
updateChapter: UpdateChapter = Injekt.get(),
updateManga: UpdateManga = Injekt.get(),
handler: DatabaseHandler = Injekt.get(),
libraryPreferences: LibraryPreferences = Injekt.get(),
): Pair<List<Chapter>, List<Chapter>> {
if (rawSourceChapters.isEmpty()) {
throw Exception("No chapters found")
@ -122,11 +124,18 @@ suspend fun syncChaptersWithSource(
return Pair(emptyList(), emptyList())
}
val reAdded = mutableListOf<Chapter>()
val changedOrDuplicateReadUrls = mutableSetOf<String>()
val deletedChapterNumbers = TreeSet<Float>()
val deletedReadChapterNumbers = TreeSet<Float>()
val deletedBookmarkedChapterNumbers = TreeSet<Float>()
val readChapterNumbers = dbChapters
.asSequence()
.filter { it.read && it.isRecognizedNumber }
.map { it.chapter_number }
.toSet()
toDelete.forEach {
if (it.read) deletedReadChapterNumbers.add(it.chapter_number)
if (it.bookmark) deletedBookmarkedChapterNumbers.add(it.chapter_number)
@ -135,6 +144,9 @@ suspend fun syncChaptersWithSource(
val now = Date().time
val markDuplicateAsRead = libraryPreferences.markDuplicateReadChapterAsRead().get()
.contains(LibraryPreferences.MARK_DUPLICATE_READ_CHAPTER_READ_NEW)
// Date fetch is set in such a way that the upper ones will have bigger value than the lower ones
// Sources MUST return the chapters from most to less recent, which is common.
var itemCount = toAdd.size
@ -143,6 +155,11 @@ suspend fun syncChaptersWithSource(
chapter.date_fetch = now + itemCount--
if (chapter.chapter_number in readChapterNumbers && markDuplicateAsRead) {
changedOrDuplicateReadUrls.add(chapter.url)
chapter.read = true
}
if (!chapter.isRecognizedNumber || chapter.chapter_number !in deletedChapterNumbers) return@map chapter
chapter.read = chapter.chapter_number in deletedReadChapterNumbers
@ -154,7 +171,7 @@ suspend fun syncChaptersWithSource(
chapter.date_fetch = it.date_fetch
}
reAdded.add(chapter)
changedOrDuplicateReadUrls.add(chapter.url)
chapter
}
@ -192,14 +209,13 @@ suspend fun syncChaptersWithSource(
manga.last_update = Date().time
updateManga.await(MangaUpdate(manga.id!!, lastUpdate = manga.last_update))
val reAddedUrls = reAdded.map { it.url }.toHashSet()
val filteredScanlators = ChapterUtil.getScanlators(manga.filtered_scanlators).toHashSet()
return Pair(
updatedToAdd.filterNot {
it.url in reAddedUrls || it.scanlator in filteredScanlators
it.url in changedOrDuplicateReadUrls || it.scanlator in filteredScanlators
},
toDelete.filterNot { it.url in reAddedUrls },
toDelete.filterNot { it.url in changedOrDuplicateReadUrls },
)
}

View file

@ -4,4 +4,11 @@ import eu.kanade.tachiyomi.core.preference.PreferenceStore
class LibraryPreferences(private val preferenceStore: PreferenceStore) {
fun randomSortSeed() = preferenceStore.getInt("library_random_sort_seed", 0)
fun markDuplicateReadChapterAsRead() = preferenceStore.getStringSet("mark_duplicate_read_chapter_read", emptySet())
companion object {
const val MARK_DUPLICATE_READ_CHAPTER_READ_NEW = "new"
const val MARK_DUPLICATE_READ_CHAPTER_READ_EXISTING = "existing"
}
}

View file

@ -167,6 +167,10 @@
<string name="ungrouped">Ungrouped</string>
<string name="pref_use_compose_library">Use experimental compose library</string>
<string name="pref_behavior">Behavior</string>
<string name="pref_mark_as_read_duplicate_read_chapter">Mark duplicate read chapter as read</string>
<string name="pref_mark_as_read_duplicate_read_chapter_existing">After reading a chapter</string>
<string name="pref_mark_as_read_duplicate_read_chapter_new">After fetching new chapter</string>
<!-- Library Sort -->
<string name="sort_by">Sort by</string>