Selaa lähdekoodia

Implement scanlator filter (#8803)

* Implement scanlator filter

* Visual improvement to scanlator filter dialog

* Review changes + Bug fixes

Backup not containing filtered chapters and similar issue fix

* Review Changes + Fix SQL query

* Lint mamma mia
AntsyLich 1 vuosi sitten
vanhempi
commit
b97aa23548
26 muutettua tiedostoa jossa 462 lisäystä ja 33 poistoa
  1. 1 1
      app/build.gradle.kts
  2. 7 1
      app/src/main/java/eu/kanade/domain/DomainModule.kt
  3. 24 0
      app/src/main/java/eu/kanade/domain/chapter/interactor/GetAvailableScanlators.kt
  4. 7 1
      app/src/main/java/eu/kanade/domain/chapter/interactor/SyncChaptersWithSource.kt
  5. 24 0
      app/src/main/java/eu/kanade/domain/manga/interactor/GetExcludedScanlators.kt
  6. 22 0
      app/src/main/java/eu/kanade/domain/manga/interactor/SetExcludedScanlators.kt
  7. 49 0
      app/src/main/java/eu/kanade/presentation/manga/ChapterSettingsDialog.kt
  8. 2 3
      app/src/main/java/eu/kanade/presentation/manga/MangaScreen.kt
  9. 134 0
      app/src/main/java/eu/kanade/presentation/manga/components/ScanlatorFilterDialog.kt
  10. 9 3
      app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreator.kt
  11. 1 1
      app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryScreenModel.kt
  12. 16 0
      app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreen.kt
  13. 45 3
      app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreenModel.kt
  14. 1 1
      app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt
  15. 19 7
      data/src/main/java/tachiyomi/data/chapter/ChapterRepositoryImpl.kt
  16. 13 1
      data/src/main/sqldelight/tachiyomi/data/chapters.sq
  17. 22 0
      data/src/main/sqldelight/tachiyomi/data/excluded_scanlators.sq
  18. 1 1
      data/src/main/sqldelight/tachiyomi/migrations/23.sqm
  19. 44 0
      data/src/main/sqldelight/tachiyomi/migrations/26.sqm
  20. 4 0
      data/src/main/sqldelight/tachiyomi/view/libraryView.sq
  21. 2 2
      domain/src/main/java/tachiyomi/domain/chapter/interactor/GetChaptersByMangaId.kt
  22. 6 2
      domain/src/main/java/tachiyomi/domain/chapter/repository/ChapterRepository.kt
  23. 1 1
      domain/src/main/java/tachiyomi/domain/history/interactor/GetNextChapters.kt
  24. 1 1
      domain/src/main/java/tachiyomi/domain/manga/interactor/FetchInterval.kt
  25. 4 4
      domain/src/main/java/tachiyomi/domain/manga/interactor/GetMangaWithChapters.kt
  26. 3 0
      i18n/src/main/res/values/strings.xml

+ 1 - 1
app/build.gradle.kts

@@ -22,7 +22,7 @@ android {
     defaultConfig {
         applicationId = "eu.kanade.tachiyomi"
 
-        versionCode = 108
+        versionCode = 109
         versionName = "0.14.7"
 
         buildConfigField("String", "COMMIT_COUNT", "\"${getCommitCount()}\"")

+ 7 - 1
app/src/main/java/eu/kanade/domain/DomainModule.kt

@@ -1,11 +1,14 @@
 package eu.kanade.domain
 
+import eu.kanade.domain.chapter.interactor.GetAvailableScanlators
 import eu.kanade.domain.chapter.interactor.SetReadStatus
 import eu.kanade.domain.chapter.interactor.SyncChaptersWithSource
 import eu.kanade.domain.download.interactor.DeleteDownload
 import eu.kanade.domain.extension.interactor.GetExtensionLanguages
 import eu.kanade.domain.extension.interactor.GetExtensionSources
 import eu.kanade.domain.extension.interactor.GetExtensionsByType
+import eu.kanade.domain.manga.interactor.GetExcludedScanlators
+import eu.kanade.domain.manga.interactor.SetExcludedScanlators
 import eu.kanade.domain.manga.interactor.SetMangaViewerFlags
 import eu.kanade.domain.manga.interactor.UpdateManga
 import eu.kanade.domain.source.interactor.GetEnabledSources
@@ -112,6 +115,8 @@ class DomainModule : InjektModule {
         addFactory { NetworkToLocalManga(get()) }
         addFactory { UpdateManga(get(), get()) }
         addFactory { SetMangaCategories(get()) }
+        addFactory { GetExcludedScanlators(get()) }
+        addFactory { SetExcludedScanlators(get()) }
 
         addSingletonFactory<ReleaseService> { ReleaseServiceImpl(get(), get()) }
         addFactory { GetApplicationRelease(get(), get()) }
@@ -133,7 +138,8 @@ class DomainModule : InjektModule {
         addFactory { UpdateChapter(get()) }
         addFactory { SetReadStatus(get(), get(), get(), get()) }
         addFactory { ShouldUpdateDbChapter() }
-        addFactory { SyncChaptersWithSource(get(), get(), get(), get(), get(), get(), get()) }
+        addFactory { SyncChaptersWithSource(get(), get(), get(), get(), get(), get(), get(), get()) }
+        addFactory { GetAvailableScanlators(get()) }
 
         addSingletonFactory<HistoryRepository> { HistoryRepositoryImpl(get()) }
         addFactory { GetHistory(get()) }

+ 24 - 0
app/src/main/java/eu/kanade/domain/chapter/interactor/GetAvailableScanlators.kt

@@ -0,0 +1,24 @@
+package eu.kanade.domain.chapter.interactor
+
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+import tachiyomi.domain.chapter.repository.ChapterRepository
+
+class GetAvailableScanlators(
+    private val repository: ChapterRepository,
+) {
+
+    private fun List<String>.cleanupAvailableScanlators(): Set<String> {
+        return mapNotNull { it.ifBlank { null } }.toSet()
+    }
+
+    suspend fun await(mangaId: Long): Set<String> {
+        return repository.getScanlatorsByMangaId(mangaId)
+            .cleanupAvailableScanlators()
+    }
+
+    fun subscribe(mangaId: Long): Flow<Set<String>> {
+        return repository.getScanlatorsByMangaIdAsFlow(mangaId)
+            .map { it.cleanupAvailableScanlators() }
+    }
+}

+ 7 - 1
app/src/main/java/eu/kanade/domain/chapter/interactor/SyncChaptersWithSource.kt

@@ -2,6 +2,7 @@ package eu.kanade.domain.chapter.interactor
 
 import eu.kanade.domain.chapter.model.copyFromSChapter
 import eu.kanade.domain.chapter.model.toSChapter
+import eu.kanade.domain.manga.interactor.GetExcludedScanlators
 import eu.kanade.domain.manga.interactor.UpdateManga
 import eu.kanade.domain.manga.model.toSManga
 import eu.kanade.tachiyomi.data.download.DownloadManager
@@ -33,6 +34,7 @@ class SyncChaptersWithSource(
     private val updateManga: UpdateManga,
     private val updateChapter: UpdateChapter,
     private val getChaptersByMangaId: GetChaptersByMangaId,
+    private val getExcludedScanlators: GetExcludedScanlators,
 ) {
 
     /**
@@ -208,6 +210,10 @@ class SyncChaptersWithSource(
 
         val reAddedUrls = reAdded.map { it.url }.toHashSet()
 
-        return updatedToAdd.filterNot { it.url in reAddedUrls }
+        val excludedScanlators = getExcludedScanlators.await(manga.id).toHashSet()
+
+        return updatedToAdd.filterNot {
+            it.url in reAddedUrls || it.scanlator in excludedScanlators
+        }
     }
 }

+ 24 - 0
app/src/main/java/eu/kanade/domain/manga/interactor/GetExcludedScanlators.kt

@@ -0,0 +1,24 @@
+package eu.kanade.domain.manga.interactor
+
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+import tachiyomi.data.DatabaseHandler
+
+class GetExcludedScanlators(
+    private val handler: DatabaseHandler,
+) {
+
+    suspend fun await(mangaId: Long): Set<String> {
+        return handler.awaitList {
+            excluded_scanlatorsQueries.getExcludedScanlatorsByMangaId(mangaId)
+        }
+            .toSet()
+    }
+
+    fun subscribe(mangaId: Long): Flow<Set<String>> {
+        return handler.subscribeToList {
+            excluded_scanlatorsQueries.getExcludedScanlatorsByMangaId(mangaId)
+        }
+            .map { it.toSet() }
+    }
+}

+ 22 - 0
app/src/main/java/eu/kanade/domain/manga/interactor/SetExcludedScanlators.kt

@@ -0,0 +1,22 @@
+package eu.kanade.domain.manga.interactor
+
+import tachiyomi.data.DatabaseHandler
+
+class SetExcludedScanlators(
+    private val handler: DatabaseHandler,
+) {
+
+    suspend fun await(mangaId: Long, excludedScanlators: Set<String>) {
+        handler.await(inTransaction = true) {
+            val currentExcluded = handler.awaitList {
+                excluded_scanlatorsQueries.getExcludedScanlatorsByMangaId(mangaId)
+            }.toSet()
+            val toAdd = excludedScanlators.minus(currentExcluded)
+            for (scanlator in toAdd) {
+                excluded_scanlatorsQueries.insert(mangaId, scanlator)
+            }
+            val toRemove = currentExcluded.minus(excludedScanlators)
+            excluded_scanlatorsQueries.remove(mangaId, toRemove)
+        }
+    }
+}

+ 49 - 0
app/src/main/java/eu/kanade/presentation/manga/ChapterSettingsDialog.kt

@@ -1,13 +1,21 @@
 package eu.kanade.presentation.manga
 
+import androidx.compose.foundation.clickable
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.rememberScrollState
 import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.PeopleAlt
 import androidx.compose.material3.AlertDialog
 import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.Icon
+import androidx.compose.material3.LocalContentColor
+import androidx.compose.material3.MaterialTheme
 import androidx.compose.material3.Text
 import androidx.compose.material3.TextButton
 import androidx.compose.runtime.Composable
@@ -15,6 +23,7 @@ import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.saveable.rememberSaveable
 import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.unit.dp
@@ -29,6 +38,7 @@ import tachiyomi.presentation.core.components.LabeledCheckbox
 import tachiyomi.presentation.core.components.RadioItem
 import tachiyomi.presentation.core.components.SortItem
 import tachiyomi.presentation.core.components.TriStateItem
+import tachiyomi.presentation.core.theme.active
 
 @Composable
 fun ChapterSettingsDialog(
@@ -37,6 +47,8 @@ fun ChapterSettingsDialog(
     onDownloadFilterChanged: (TriState) -> Unit,
     onUnreadFilterChanged: (TriState) -> Unit,
     onBookmarkedFilterChanged: (TriState) -> Unit,
+    scanlatorFilterActive: Boolean,
+    onScanlatorFilterClicked: (() -> Unit),
     onSortModeChanged: (Long) -> Unit,
     onDisplayModeChanged: (Long) -> Unit,
     onSetAsDefault: (applyToExistingManga: Boolean) -> Unit,
@@ -89,6 +101,8 @@ fun ChapterSettingsDialog(
                         onUnreadFilterChanged = onUnreadFilterChanged,
                         bookmarkedFilter = manga?.bookmarkedFilter ?: TriState.DISABLED,
                         onBookmarkedFilterChanged = onBookmarkedFilterChanged,
+                        scanlatorFilterActive = scanlatorFilterActive,
+                        onScanlatorFilterClicked = onScanlatorFilterClicked,
                     )
                 }
                 1 -> {
@@ -117,6 +131,8 @@ private fun ColumnScope.FilterPage(
     onUnreadFilterChanged: (TriState) -> Unit,
     bookmarkedFilter: TriState,
     onBookmarkedFilterChanged: (TriState) -> Unit,
+    scanlatorFilterActive: Boolean,
+    onScanlatorFilterClicked: (() -> Unit),
 ) {
     TriStateItem(
         label = stringResource(R.string.label_downloaded),
@@ -133,6 +149,39 @@ private fun ColumnScope.FilterPage(
         state = bookmarkedFilter,
         onClick = onBookmarkedFilterChanged,
     )
+    ScanlatorFilterItem(
+        active = scanlatorFilterActive,
+        onClick = onScanlatorFilterClicked,
+    )
+}
+
+@Composable
+fun ScanlatorFilterItem(
+    active: Boolean,
+    onClick: () -> Unit,
+) {
+    Row(
+        modifier = Modifier
+            .clickable(onClick = onClick)
+            .fillMaxWidth()
+            .padding(horizontal = TabbedDialogPaddings.Horizontal, vertical = 12.dp),
+        verticalAlignment = Alignment.CenterVertically,
+        horizontalArrangement = Arrangement.spacedBy(24.dp),
+    ) {
+        Icon(
+            imageVector = Icons.Outlined.PeopleAlt,
+            contentDescription = null,
+            tint = if (active) {
+                MaterialTheme.colorScheme.active
+            } else {
+                LocalContentColor.current
+            },
+        )
+        Text(
+            text = stringResource(R.string.scanlator),
+            style = MaterialTheme.typography.bodyMedium,
+        )
+    }
 }
 
 @Composable

+ 2 - 3
app/src/main/java/eu/kanade/presentation/manga/MangaScreen.kt

@@ -48,7 +48,6 @@ import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.util.fastAll
 import androidx.compose.ui.util.fastAny
 import androidx.compose.ui.util.fastMap
-import eu.kanade.domain.manga.model.chaptersFiltered
 import eu.kanade.presentation.manga.components.ChapterDownloadAction
 import eu.kanade.presentation.manga.components.ChapterHeader
 import eu.kanade.presentation.manga.components.ExpandableMangaDescription
@@ -308,7 +307,7 @@ private fun MangaScreenSmallImpl(
                 title = state.manga.title,
                 titleAlphaProvider = { animatedTitleAlpha },
                 backgroundAlphaProvider = { animatedBgAlpha },
-                hasFilters = state.manga.chaptersFiltered(),
+                hasFilters = state.filterActive,
                 onBackClicked = internalOnBackPressed,
                 onClickFilter = onFilterClicked,
                 onClickShare = onShareClicked,
@@ -561,7 +560,7 @@ fun MangaScreenLargeImpl(
                     title = state.manga.title,
                     titleAlphaProvider = { if (isAnySelected) 1f else 0f },
                     backgroundAlphaProvider = { 1f },
-                    hasFilters = state.manga.chaptersFiltered(),
+                    hasFilters = state.filterActive,
                     onBackClicked = internalOnBackPressed,
                     onClickFilter = onFilterButtonClicked,
                     onClickShare = onShareClicked,

+ 134 - 0
app/src/main/java/eu/kanade/presentation/manga/components/ScanlatorFilterDialog.kt

@@ -0,0 +1,134 @@
+package eu.kanade.presentation.manga.components
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.CheckBoxOutlineBlank
+import androidx.compose.material.icons.rounded.DisabledByDefault
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.LocalContentColor
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.minimumInteractiveComponentSize
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.toMutableStateList
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.DialogProperties
+import eu.kanade.tachiyomi.R
+import tachiyomi.presentation.core.components.material.TextButton
+import tachiyomi.presentation.core.components.material.padding
+import tachiyomi.presentation.core.util.isScrolledToEnd
+import tachiyomi.presentation.core.util.isScrolledToStart
+
+@Composable
+fun ScanlatorFilterDialog(
+    availableScanlators: Set<String>,
+    excludedScanlators: Set<String>,
+    onDismissRequest: () -> Unit,
+    onConfirm: (Set<String>) -> Unit,
+) {
+    val sortedAvailableScanlators = remember(availableScanlators) {
+        availableScanlators.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it })
+    }
+    val mutableExcludedScanlators = remember(excludedScanlators) { excludedScanlators.toMutableStateList() }
+    AlertDialog(
+        onDismissRequest = onDismissRequest,
+        title = { Text(text = stringResource(R.string.exclude_scanlators)) },
+        text = textFunc@{
+            if (sortedAvailableScanlators.isEmpty()) {
+                Text(text = stringResource(R.string.no_scanlators_found))
+                return@textFunc
+            }
+            Box {
+                val state = rememberLazyListState()
+                LazyColumn(state = state) {
+                    sortedAvailableScanlators.forEach { scanlator ->
+                        item {
+                            val isExcluded = mutableExcludedScanlators.contains(scanlator)
+                            Row(
+                                verticalAlignment = Alignment.CenterVertically,
+                                modifier = Modifier
+                                    .clickable {
+                                        if (isExcluded) {
+                                            mutableExcludedScanlators.remove(scanlator)
+                                        } else {
+                                            mutableExcludedScanlators.add(scanlator)
+                                        }
+                                    }
+                                    .minimumInteractiveComponentSize()
+                                    .clip(MaterialTheme.shapes.small)
+                                    .fillMaxWidth()
+                                    .padding(horizontal = MaterialTheme.padding.small),
+                            ) {
+                                Icon(
+                                    imageVector = if (isExcluded) {
+                                        Icons.Rounded.DisabledByDefault
+                                    } else {
+                                        Icons.Rounded.CheckBoxOutlineBlank
+                                    },
+                                    tint = if (isExcluded) {
+                                        MaterialTheme.colorScheme.primary
+                                    } else {
+                                        LocalContentColor.current
+                                    },
+                                    contentDescription = null,
+                                )
+                                Text(
+                                    text = scanlator,
+                                    style = MaterialTheme.typography.bodyMedium,
+                                    modifier = Modifier.padding(start = 24.dp),
+                                )
+                            }
+                        }
+                    }
+                }
+                if (!state.isScrolledToStart()) HorizontalDivider(modifier = Modifier.align(Alignment.TopCenter))
+                if (!state.isScrolledToEnd()) HorizontalDivider(modifier = Modifier.align(Alignment.BottomCenter))
+            }
+        },
+        properties = DialogProperties(
+            usePlatformDefaultWidth = true,
+        ),
+        confirmButton = {
+            FlowRow {
+                if (sortedAvailableScanlators.isEmpty()) {
+                    TextButton(onClick = onDismissRequest) {
+                        Text(text = stringResource(R.string.action_cancel))
+                    }
+                    return@FlowRow
+                }
+                TextButton(onClick = mutableExcludedScanlators::clear) {
+                    Text(text = stringResource(R.string.action_reset))
+                }
+                Spacer(modifier = Modifier.weight(1f))
+                TextButton(onClick = onDismissRequest) {
+                    Text(text = stringResource(R.string.action_cancel))
+                }
+                TextButton(
+                    onClick = {
+                        onConfirm(mutableExcludedScanlators.toSet())
+                        onDismissRequest()
+                    },
+                ) {
+                    Text(text = stringResource(R.string.action_ok))
+                }
+            }
+        },
+    )
+}

+ 9 - 3
app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreator.kt

@@ -19,6 +19,7 @@ import eu.kanade.tachiyomi.data.backup.BackupConst.BACKUP_TRACK
 import eu.kanade.tachiyomi.data.backup.BackupConst.BACKUP_TRACK_MASK
 import eu.kanade.tachiyomi.data.backup.models.Backup
 import eu.kanade.tachiyomi.data.backup.models.BackupCategory
+import eu.kanade.tachiyomi.data.backup.models.BackupChapter
 import eu.kanade.tachiyomi.data.backup.models.BackupHistory
 import eu.kanade.tachiyomi.data.backup.models.BackupManga
 import eu.kanade.tachiyomi.data.backup.models.BackupPreference
@@ -189,10 +190,15 @@ class BackupCreator(
         // Check if user wants chapter information in backup
         if (options and BACKUP_CHAPTER_MASK == BACKUP_CHAPTER) {
             // Backup all the chapters
-            val chapters = handler.awaitList { chaptersQueries.getChaptersByMangaId(manga.id, backupChapterMapper) }
-            if (chapters.isNotEmpty()) {
-                mangaObject.chapters = chapters
+            handler.awaitList {
+                chaptersQueries.getChaptersByMangaId(
+                    mangaId = manga.id,
+                    applyScanlatorFilter = 0, // false
+                    mapper = backupChapterMapper,
+                )
             }
+                .takeUnless(List<BackupChapter>::isEmpty)
+                ?.let { mangaObject.chapters = it }
         }
 
         // Check if user wants category information in backup

+ 1 - 1
app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryScreenModel.kt

@@ -414,7 +414,7 @@ class LibraryScreenModel(
     }
 
     suspend fun getNextUnreadChapter(manga: Manga): Chapter? {
-        return getChaptersByMangaId.await(manga.id).getNextUnread(manga, downloadManager)
+        return getChaptersByMangaId.await(manga.id, applyScanlatorFilter = true).getNextUnread(manga, downloadManager)
     }
 
     /**

+ 16 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreen.kt

@@ -9,8 +9,10 @@ import androidx.compose.runtime.Composable
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.collectAsState
 import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.hapticfeedback.HapticFeedbackType
 import androidx.compose.ui.platform.LocalContext
@@ -30,6 +32,7 @@ import eu.kanade.presentation.manga.EditCoverAction
 import eu.kanade.presentation.manga.MangaScreen
 import eu.kanade.presentation.manga.components.DeleteChaptersDialog
 import eu.kanade.presentation.manga.components.MangaCoverDialog
+import eu.kanade.presentation.manga.components.ScanlatorFilterDialog
 import eu.kanade.presentation.manga.components.SetIntervalDialog
 import eu.kanade.presentation.util.AssistContentScreen
 import eu.kanade.presentation.util.Screen
@@ -152,6 +155,8 @@ class MangaScreen(
             onInvertSelection = screenModel::invertSelection,
         )
 
+        var showScanlatorsDialog by remember { mutableStateOf(false) }
+
         val onDismissRequest = { screenModel.dismissDialog() }
         when (val dialog = successState.dialog) {
             null -> {}
@@ -189,6 +194,8 @@ class MangaScreen(
                 onDisplayModeChanged = screenModel::setDisplayMode,
                 onSetAsDefault = screenModel::setCurrentSettingsAsDefault,
                 onResetToDefault = screenModel::resetToDefaultSettings,
+                scanlatorFilterActive = successState.scanlatorFilterActive,
+                onScanlatorFilterClicked = { showScanlatorsDialog = true },
             )
             MangaScreenModel.Dialog.TrackSheet -> {
                 NavigatorAdaptiveSheet(
@@ -235,6 +242,15 @@ class MangaScreen(
                 )
             }
         }
+
+        if (showScanlatorsDialog) {
+            ScanlatorFilterDialog(
+                availableScanlators = successState.availableScanlators,
+                excludedScanlators = successState.excludedScanlators,
+                onDismissRequest = { showScanlatorsDialog = false },
+                onConfirm = screenModel::setExcludedScanlators,
+            )
+        }
     }
 
     private fun continueReading(context: Context, unreadChapter: Chapter?) {

+ 45 - 3
app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreenModel.kt

@@ -11,9 +11,13 @@ import cafe.adriel.voyager.core.model.screenModelScope
 import eu.kanade.core.preference.asState
 import eu.kanade.core.util.addOrRemove
 import eu.kanade.core.util.insertSeparators
+import eu.kanade.domain.chapter.interactor.GetAvailableScanlators
 import eu.kanade.domain.chapter.interactor.SetReadStatus
 import eu.kanade.domain.chapter.interactor.SyncChaptersWithSource
+import eu.kanade.domain.manga.interactor.GetExcludedScanlators
+import eu.kanade.domain.manga.interactor.SetExcludedScanlators
 import eu.kanade.domain.manga.interactor.UpdateManga
+import eu.kanade.domain.manga.model.chaptersFiltered
 import eu.kanade.domain.manga.model.downloadedFilter
 import eu.kanade.domain.manga.model.toSManga
 import eu.kanade.domain.track.interactor.AddTracks
@@ -92,6 +96,9 @@ class MangaScreenModel(
     private val downloadCache: DownloadCache = Injekt.get(),
     private val getMangaAndChapters: GetMangaWithChapters = Injekt.get(),
     private val getDuplicateLibraryManga: GetDuplicateLibraryManga = Injekt.get(),
+    private val getAvailableScanlators: GetAvailableScanlators = Injekt.get(),
+    private val getExcludedScanlators: GetExcludedScanlators = Injekt.get(),
+    private val setExcludedScanlators: SetExcludedScanlators = Injekt.get(),
     private val setMangaChapterFlags: SetMangaChapterFlags = Injekt.get(),
     private val setMangaDefaultChapterFlags: SetMangaDefaultChapterFlags = Injekt.get(),
     private val setReadStatus: SetReadStatus = Injekt.get(),
@@ -154,7 +161,7 @@ class MangaScreenModel(
     init {
         screenModelScope.launchIO {
             combine(
-                getMangaAndChapters.subscribe(mangaId).distinctUntilChanged(),
+                getMangaAndChapters.subscribe(mangaId, applyScanlatorFilter = true).distinctUntilChanged(),
                 downloadCache.changes,
                 downloadManager.queueState,
             ) { mangaAndChapters, _, _ -> mangaAndChapters }
@@ -168,11 +175,31 @@ class MangaScreenModel(
                 }
         }
 
+        screenModelScope.launchIO {
+            getExcludedScanlators.subscribe(mangaId)
+                .distinctUntilChanged()
+                .collectLatest { excludedScanlators ->
+                    updateSuccessState {
+                        it.copy(excludedScanlators = excludedScanlators)
+                    }
+                }
+        }
+
+        screenModelScope.launchIO {
+            getAvailableScanlators.subscribe(mangaId)
+                .distinctUntilChanged()
+                .collectLatest { availableScanlators ->
+                    updateSuccessState {
+                        it.copy(availableScanlators = availableScanlators)
+                    }
+                }
+        }
+
         observeDownloads()
 
         screenModelScope.launchIO {
             val manga = getMangaAndChapters.awaitManga(mangaId)
-            val chapters = getMangaAndChapters.awaitChapters(mangaId)
+            val chapters = getMangaAndChapters.awaitChapters(mangaId, applyScanlatorFilter = true)
                 .toChapterListItems(manga)
 
             if (!manga.favorite) {
@@ -189,6 +216,8 @@ class MangaScreenModel(
                     source = Injekt.get<SourceManager>().getOrStub(manga.source),
                     isFromSource = isFromSource,
                     chapters = chapters,
+                    availableScanlators = getAvailableScanlators.await(mangaId),
+                    excludedScanlators = getExcludedScanlators.await(mangaId),
                     isRefreshingData = needRefreshInfo || needRefreshChapter,
                     dialog = null,
                 )
@@ -995,6 +1024,12 @@ class MangaScreenModel(
         updateSuccessState { it.copy(dialog = Dialog.FullCover) }
     }
 
+    fun setExcludedScanlators(excludedScanlators: Set<String>) {
+        screenModelScope.launchIO {
+            setExcludedScanlators.await(mangaId, excludedScanlators)
+        }
+    }
+
     sealed interface State {
         @Immutable
         data object Loading : State
@@ -1005,12 +1040,13 @@ class MangaScreenModel(
             val source: Source,
             val isFromSource: Boolean,
             val chapters: List<ChapterList.Item>,
+            val availableScanlators: Set<String>,
+            val excludedScanlators: Set<String>,
             val trackItems: List<TrackItem> = emptyList(),
             val isRefreshingData: Boolean = false,
             val dialog: Dialog? = null,
             val hasPromptedToAddBefore: Boolean = false,
         ) : State {
-
             val processedChapters by lazy {
                 chapters.applyFilters(manga).toList()
             }
@@ -1042,6 +1078,12 @@ class MangaScreenModel(
                 }
             }
 
+            val scanlatorFilterActive: Boolean
+                get() = excludedScanlators.intersect(availableScanlators).isNotEmpty()
+
+            val filterActive: Boolean
+                get() = scanlatorFilterActive || manga.chaptersFiltered()
+
             val trackingAvailable: Boolean
                 get() = trackItems.isNotEmpty()
 

+ 1 - 1
app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt

@@ -147,7 +147,7 @@ class ReaderViewModel @JvmOverloads constructor(
      */
     private val chapterList by lazy {
         val manga = manga!!
-        val chapters = runBlocking { getChaptersByMangaId.await(manga.id) }
+        val chapters = runBlocking { getChaptersByMangaId.await(manga.id, applyScanlatorFilter = true) }
 
         val selectedChapter = chapters.find { it.id == chapterId }
             ?: error("Requested chapter of id $chapterId not found in chapter list")

+ 19 - 7
data/src/main/java/tachiyomi/data/chapter/ChapterRepositoryImpl.kt

@@ -2,6 +2,7 @@ package tachiyomi.data.chapter
 
 import kotlinx.coroutines.flow.Flow
 import logcat.LogPriority
+import tachiyomi.core.util.lang.toLong
 import tachiyomi.core.util.system.logcat
 import tachiyomi.data.DatabaseHandler
 import tachiyomi.domain.chapter.model.Chapter
@@ -76,8 +77,22 @@ class ChapterRepositoryImpl(
         }
     }
 
-    override suspend fun getChapterByMangaId(mangaId: Long): List<Chapter> {
-        return handler.awaitList { chaptersQueries.getChaptersByMangaId(mangaId, ::mapChapter) }
+    override suspend fun getChapterByMangaId(mangaId: Long, applyScanlatorFilter: Boolean): List<Chapter> {
+        return handler.awaitList {
+            chaptersQueries.getChaptersByMangaId(mangaId, applyScanlatorFilter.toLong(), ::mapChapter)
+        }
+    }
+
+    override suspend fun getScanlatorsByMangaId(mangaId: Long): List<String> {
+        return handler.awaitList {
+            chaptersQueries.getScanlatorsByMangaId(mangaId) { it.orEmpty() }
+        }
+    }
+
+    override fun getScanlatorsByMangaIdAsFlow(mangaId: Long): Flow<List<String>> {
+        return handler.subscribeToList {
+            chaptersQueries.getScanlatorsByMangaId(mangaId) { it.orEmpty() }
+        }
     }
 
     override suspend fun getBookmarkedChaptersByMangaId(mangaId: Long): List<Chapter> {
@@ -93,12 +108,9 @@ class ChapterRepositoryImpl(
         return handler.awaitOneOrNull { chaptersQueries.getChapterById(id, ::mapChapter) }
     }
 
-    override suspend fun getChapterByMangaIdAsFlow(mangaId: Long): Flow<List<Chapter>> {
+    override suspend fun getChapterByMangaIdAsFlow(mangaId: Long, applyScanlatorFilter: Boolean): Flow<List<Chapter>> {
         return handler.subscribeToList {
-            chaptersQueries.getChaptersByMangaId(
-                mangaId,
-                ::mapChapter,
-            )
+            chaptersQueries.getChaptersByMangaId(mangaId, applyScanlatorFilter.toLong(), ::mapChapter)
         }
     }
 

+ 13 - 1
data/src/main/sqldelight/tachiyomi/data/chapters.sq

@@ -36,7 +36,19 @@ FROM chapters
 WHERE _id = :id;
 
 getChaptersByMangaId:
-SELECT *
+SELECT C.*
+FROM chapters C
+LEFT JOIN excluded_scanlators ES
+ON C.manga_id = ES.manga_id
+AND C.scanlator = ES.scanlator
+WHERE C.manga_id = :mangaId
+AND (
+    :applyScanlatorFilter = 0
+    OR ES.scanlator IS NULL
+);
+
+getScanlatorsByMangaId:
+SELECT scanlator
 FROM chapters
 WHERE manga_id = :mangaId;
 

+ 22 - 0
data/src/main/sqldelight/tachiyomi/data/excluded_scanlators.sq

@@ -0,0 +1,22 @@
+CREATE TABLE excluded_scanlators(
+    manga_id INTEGER NOT NULL,
+    scanlator TEXT NOT NULL,
+    FOREIGN KEY(manga_id) REFERENCES mangas (_id)
+    ON DELETE CASCADE
+);
+
+CREATE INDEX excluded_scanlators_manga_id_index ON excluded_scanlators(manga_id);
+
+insert:
+INSERT INTO excluded_scanlators(manga_id, scanlator)
+VALUES (:mangaId, :scanlator);
+
+remove:
+DELETE FROM excluded_scanlators
+WHERE manga_id = :mangaId
+AND scanlator IN :scanlators;
+
+getExcludedScanlatorsByMangaId:
+SELECT scanlator
+FROM excluded_scanlators
+WHERE manga_id = :mangaId;

+ 1 - 1
data/src/main/sqldelight/tachiyomi/migrations/23.sqm

@@ -20,4 +20,4 @@ FROM mangas JOIN chapters
 ON mangas._id = chapters.manga_id
 WHERE favorite = 1
 AND date_fetch > date_added
-ORDER BY date_fetch DESC;
+ORDER BY date_fetch DESC;

+ 44 - 0
data/src/main/sqldelight/tachiyomi/migrations/26.sqm

@@ -0,0 +1,44 @@
+CREATE TABLE excluded_scanlators(
+    manga_id INTEGER NOT NULL,
+    scanlator TEXT NOT NULL,
+    FOREIGN KEY(manga_id) REFERENCES mangas (_id)
+    ON DELETE CASCADE
+);
+
+CREATE INDEX excluded_scanlators_manga_id_index ON excluded_scanlators(manga_id);
+
+DROP VIEW IF EXISTS libraryView;
+
+CREATE VIEW libraryView AS
+SELECT
+    M.*,
+    coalesce(C.total, 0) AS totalCount,
+    coalesce(C.readCount, 0) AS readCount,
+    coalesce(C.latestUpload, 0) AS latestUpload,
+    coalesce(C.fetchedAt, 0) AS chapterFetchedAt,
+    coalesce(C.lastRead, 0) AS lastRead,
+    coalesce(C.bookmarkCount, 0) AS bookmarkCount,
+    coalesce(MC.category_id, 0) AS category
+FROM mangas M
+LEFT JOIN(
+    SELECT
+        chapters.manga_id,
+        count(*) AS total,
+        sum(read) AS readCount,
+        coalesce(max(chapters.date_upload), 0) AS latestUpload,
+        coalesce(max(history.last_read), 0) AS lastRead,
+        coalesce(max(chapters.date_fetch), 0) AS fetchedAt,
+        sum(chapters.bookmark) AS bookmarkCount
+    FROM chapters
+    LEFT JOIN excluded_scanlators
+    ON chapters.manga_id = excluded_scanlators.manga_id
+    AND chapters.scanlator = excluded_scanlators.scanlator
+    LEFT JOIN history
+    ON chapters._id = history.chapter_id
+    WHERE excluded_scanlators.scanlator IS NULL
+    GROUP BY chapters.manga_id
+) AS C
+ON M._id = C.manga_id
+LEFT JOIN mangas_categories AS MC
+ON MC.manga_id = M._id
+WHERE M.favorite = 1;

+ 4 - 0
data/src/main/sqldelight/tachiyomi/view/libraryView.sq

@@ -19,8 +19,12 @@ LEFT JOIN(
         coalesce(max(chapters.date_fetch), 0) AS fetchedAt,
         sum(chapters.bookmark) AS bookmarkCount
     FROM chapters
+    LEFT JOIN excluded_scanlators
+    ON chapters.manga_id = excluded_scanlators.manga_id
+    AND chapters.scanlator = excluded_scanlators.scanlator
     LEFT JOIN history
     ON chapters._id = history.chapter_id
+    WHERE excluded_scanlators.scanlator IS NULL
     GROUP BY chapters.manga_id
 ) AS C
 ON M._id = C.manga_id

+ 2 - 2
domain/src/main/java/tachiyomi/domain/chapter/interactor/GetChaptersByMangaId.kt

@@ -9,9 +9,9 @@ class GetChaptersByMangaId(
     private val chapterRepository: ChapterRepository,
 ) {
 
-    suspend fun await(mangaId: Long): List<Chapter> {
+    suspend fun await(mangaId: Long, applyScanlatorFilter: Boolean = false): List<Chapter> {
         return try {
-            chapterRepository.getChapterByMangaId(mangaId)
+            chapterRepository.getChapterByMangaId(mangaId, applyScanlatorFilter)
         } catch (e: Exception) {
             logcat(LogPriority.ERROR, e)
             emptyList()

+ 6 - 2
domain/src/main/java/tachiyomi/domain/chapter/repository/ChapterRepository.kt

@@ -14,13 +14,17 @@ interface ChapterRepository {
 
     suspend fun removeChaptersWithIds(chapterIds: List<Long>)
 
-    suspend fun getChapterByMangaId(mangaId: Long): List<Chapter>
+    suspend fun getChapterByMangaId(mangaId: Long, applyScanlatorFilter: Boolean = false): List<Chapter>
+
+    suspend fun getScanlatorsByMangaId(mangaId: Long): List<String>
+
+    fun getScanlatorsByMangaIdAsFlow(mangaId: Long): Flow<List<String>>
 
     suspend fun getBookmarkedChaptersByMangaId(mangaId: Long): List<Chapter>
 
     suspend fun getChapterById(id: Long): Chapter?
 
-    suspend fun getChapterByMangaIdAsFlow(mangaId: Long): Flow<List<Chapter>>
+    suspend fun getChapterByMangaIdAsFlow(mangaId: Long, applyScanlatorFilter: Boolean = false): Flow<List<Chapter>>
 
     suspend fun getChapterByUrlAndMangaId(url: String, mangaId: Long): Chapter?
 }

+ 1 - 1
domain/src/main/java/tachiyomi/domain/history/interactor/GetNextChapters.kt

@@ -20,7 +20,7 @@ class GetNextChapters(
 
     suspend fun await(mangaId: Long, onlyUnread: Boolean = true): List<Chapter> {
         val manga = getManga.await(mangaId) ?: return emptyList()
-        val chapters = getChaptersByMangaId.await(mangaId)
+        val chapters = getChaptersByMangaId.await(mangaId, applyScanlatorFilter = true)
             .sortedWith(getChapterSort(manga, sortDescending = false))
 
         return if (onlyUnread) {

+ 1 - 1
domain/src/main/java/tachiyomi/domain/manga/interactor/FetchInterval.kt

@@ -24,7 +24,7 @@ class FetchInterval(
         } else {
             window
         }
-        val chapters = getChaptersByMangaId.await(manga.id)
+        val chapters = getChaptersByMangaId.await(manga.id, applyScanlatorFilter = true)
         val interval = manga.fetchInterval.takeIf { it < 0 } ?: calculateInterval(
             chapters,
             dateTime.zone,

+ 4 - 4
domain/src/main/java/tachiyomi/domain/manga/interactor/GetMangaWithChapters.kt

@@ -12,10 +12,10 @@ class GetMangaWithChapters(
     private val chapterRepository: ChapterRepository,
 ) {
 
-    suspend fun subscribe(id: Long): Flow<Pair<Manga, List<Chapter>>> {
+    suspend fun subscribe(id: Long, applyScanlatorFilter: Boolean = false): Flow<Pair<Manga, List<Chapter>>> {
         return combine(
             mangaRepository.getMangaByIdAsFlow(id),
-            chapterRepository.getChapterByMangaIdAsFlow(id),
+            chapterRepository.getChapterByMangaIdAsFlow(id, applyScanlatorFilter),
         ) { manga, chapters ->
             Pair(manga, chapters)
         }
@@ -25,7 +25,7 @@ class GetMangaWithChapters(
         return mangaRepository.getMangaById(id)
     }
 
-    suspend fun awaitChapters(id: Long): List<Chapter> {
-        return chapterRepository.getChapterByMangaId(id)
+    suspend fun awaitChapters(id: Long, applyScanlatorFilter: Boolean = false): List<Chapter> {
+        return chapterRepository.getChapterByMangaId(id, applyScanlatorFilter)
     }
 }

+ 3 - 0
i18n/src/main/res/values/strings.xml

@@ -14,6 +14,7 @@
     <string name="track">Tracking</string>
     <string name="delete_downloaded">Delete downloaded</string>
     <string name="history">History</string>
+    <string name="scanlator">Scanlator</string>
 
     <!-- Screen titles -->
     <string name="label_more">More</string>
@@ -702,6 +703,8 @@
     <string name="set_chapter_settings_as_default">Set as default</string>
     <string name="no_chapters_error">No chapters found</string>
     <string name="are_you_sure">Are you sure?</string>
+    <string name="exclude_scanlators">Exclude scanlators</string>
+    <string name="no_scanlators_found">No scanlators found</string>
 
     <!-- Tracking Screen -->
     <string name="manga_tracking_tab">Tracking</string>