Restrict raw database access to the data module

app no longer touches the database directly. Backup restore's queries move into a new RestoreRepository in :data, which restores a batch of entries all or nothing, stopping between entries if cancelled, as before. The remaining direct calls (excluded scanlators, clear database, backup creation, store and category restore) go through their repositories, and the database bindings move from AppBindings into :data.
This commit is contained in:
AntsyLich
2026-09-27 22:30:51 +06:00
parent a4abb6292b
commit 306befb214
29 changed files with 580 additions and 607 deletions
-4
View File
@@ -233,14 +233,10 @@ dependencies {
implementation(libs.androidx.paging.runtime)
implementation(libs.androidx.paging.compose)
implementation(libs.androidx.sqlite.bundled)
implementation(libs.kotlin.reflect)
implementation(libs.bundles.kotlinx.coroutines)
implementation(libs.sqldelight.async)
implementation(libs.kotlinx.datetime)
// AndroidX libraries
@@ -1,28 +1,19 @@
package eu.kanade.domain.manga.interactor
import app.cash.sqldelight.async.coroutines.awaitAsList
import dev.zacsweers.metro.Inject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import tachiyomi.data.Database
import tachiyomi.data.subscribeToList
import tachiyomi.domain.manga.repository.MangaRepository
@Inject
class GetExcludedScanlators(
private val database: Database,
private val mangaRepository: MangaRepository,
) {
suspend fun await(mangaId: Long): Set<String> {
return database.excluded_scanlatorsQueries
.getExcludedScanlatorsByMangaId(mangaId)
.awaitAsList()
.toSet()
return mangaRepository.getExcludedScanlators(mangaId)
}
fun subscribe(mangaId: Long): Flow<Set<String>> {
return database.excluded_scanlatorsQueries
.getExcludedScanlatorsByMangaId(mangaId)
.subscribeToList()
.map { it.toSet() }
return mangaRepository.getExcludedScanlatorsAsFlow(mangaId)
}
}
@@ -1,26 +1,14 @@
package eu.kanade.domain.manga.interactor
import app.cash.sqldelight.async.coroutines.awaitAsList
import dev.zacsweers.metro.Inject
import tachiyomi.data.Database
import tachiyomi.domain.manga.repository.MangaRepository
@Inject
class SetExcludedScanlators(
private val database: Database,
private val mangaRepository: MangaRepository,
) {
suspend fun await(mangaId: Long, excludedScanlators: Set<String>) {
database.transaction {
val currentExcluded = database.excluded_scanlatorsQueries
.getExcludedScanlatorsByMangaId(mangaId)
.awaitAsList()
.toSet()
val toAdd = excludedScanlators.minus(currentExcluded)
for (scanlator in toAdd) {
database.excluded_scanlatorsQueries.insert(mangaId, scanlator)
}
val toRemove = currentExcluded.minus(excludedScanlators)
database.excluded_scanlatorsQueries.remove(mangaId, toRemove)
}
mangaRepository.setExcludedScanlators(mangaId, excludedScanlators)
}
}
@@ -51,9 +51,9 @@ import mihon.icons.materialsymbols.rounded.FlipToBack
import mihon.icons.materialsymbols.rounded.SelectAll
import tachiyomi.core.common.util.lang.launchIO
import tachiyomi.core.common.util.lang.launchUI
import tachiyomi.core.common.util.lang.toLong
import tachiyomi.core.common.util.lang.withNonCancellableContext
import tachiyomi.data.Database
import tachiyomi.domain.history.repository.HistoryRepository
import tachiyomi.domain.manga.repository.MangaRepository
import tachiyomi.domain.source.interactor.GetSourcesWithNonLibraryManga
import tachiyomi.domain.source.model.Source
import tachiyomi.domain.source.model.SourceWithCount
@@ -229,7 +229,8 @@ class ClearDatabaseScreen : Screen() {
@ViewModelKey
@ContributesIntoMap(AppScope::class, binding = binding<ViewModel>())
class ClearDatabaseViewModel(
private val database: Database,
private val mangaRepository: MangaRepository,
private val historyRepository: HistoryRepository,
private val getSourcesWithNonLibraryManga: GetSourcesWithNonLibraryManga,
) : ViewModel() {
@@ -253,8 +254,8 @@ class ClearDatabaseViewModel(
suspend fun removeMangaBySourceId(keepReadManga: Boolean) = withNonCancellableContext {
val state = state.value as? State.Ready ?: return@withNonCancellableContext
database.mangasQueries.deleteNonLibraryManga(state.selection, keepReadManga.toLong())
database.historyQueries.removeResettedHistory()
mangaRepository.deleteNonLibraryManga(state.selection, keepReadManga)
historyRepository.deleteResetHistory()
}
fun toggleSelection(source: Source) = state.update { state ->
@@ -1,24 +1,25 @@
package eu.kanade.tachiyomi.data.backup.create.creators
import app.cash.sqldelight.async.coroutines.awaitAsList
import app.cash.sqldelight.async.coroutines.awaitAsOne
import dev.zacsweers.metro.Inject
import eu.kanade.tachiyomi.data.backup.create.BackupOptions
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.backupChapterMapper
import eu.kanade.tachiyomi.data.backup.models.backupTrackMapper
import eu.kanade.tachiyomi.data.backup.models.toBackupChapter
import eu.kanade.tachiyomi.data.backup.models.toBackupTracking
import eu.kanade.tachiyomi.ui.reader.setting.ReadingMode
import tachiyomi.data.Database
import tachiyomi.data.MemoColumnAdapter
import mihon.core.common.extensions.toByteArray
import tachiyomi.domain.category.interactor.GetCategories
import tachiyomi.domain.chapter.repository.ChapterRepository
import tachiyomi.domain.history.interactor.GetHistory
import tachiyomi.domain.manga.model.Manga
import tachiyomi.domain.manga.repository.MangaRepository
import tachiyomi.domain.track.repository.TrackRepository
@Inject
class MangaBackupCreator(
private val database: Database,
private val mangaRepository: MangaRepository,
private val chapterRepository: ChapterRepository,
private val trackRepository: TrackRepository,
private val getCategories: GetCategories,
private val getHistory: GetHistory,
) {
@@ -33,20 +34,13 @@ class MangaBackupCreator(
// Entry for this manga
val mangaObject = manga.toBackupManga()
mangaObject.excludedScanlators = database.excluded_scanlatorsQueries
.getExcludedScanlatorsByMangaId(manga.id)
.awaitAsList()
mangaObject.excludedScanlators = mangaRepository.getExcludedScanlators(manga.id).toList()
if (options.chapters) {
// Backup all the chapters
database.chaptersQueries
.getChaptersByMangaId(
mangaId = manga.id,
applyScanlatorFilter = 0, // false
mapper = backupChapterMapper,
)
.awaitAsList()
.takeUnless(List<BackupChapter>::isEmpty)
chapterRepository.getChapterByMangaId(manga.id, applyScanlatorFilter = false)
.map { it.toBackupChapter() }
.takeUnless { it.isEmpty() }
?.let { mangaObject.chapters = it }
}
@@ -59,9 +53,7 @@ class MangaBackupCreator(
}
if (options.tracking) {
val tracks = database.manga_syncQueries
.getTracksByMangaId(manga.id, backupTrackMapper)
.awaitAsList()
val tracks = trackRepository.getTracksByMangaId(manga.id).map { it.toBackupTracking() }
if (tracks.isNotEmpty()) {
mangaObject.tracking = tracks
}
@@ -71,9 +63,7 @@ class MangaBackupCreator(
val historyByMangaId = getHistory.await(manga.id)
if (historyByMangaId.isNotEmpty()) {
val history = historyByMangaId.map { history ->
val chapter = database.chaptersQueries
.getChapterById(history.chapterId)
.awaitAsOne()
val chapter = checkNotNull(chapterRepository.getChapterById(history.chapterId))
BackupHistory(chapter.url, history.readAt?.time ?: 0L, history.readDuration)
}
if (history.isNotEmpty()) {
@@ -106,5 +96,5 @@ private fun Manga.toBackupManga() =
favoriteModifiedAt = this.favoriteModifiedAt,
notes = this.notes,
initialized = this.initialized,
memo = MemoColumnAdapter.encode(this.memo),
memo = this.memo.toByteArray(),
)
@@ -1,10 +1,10 @@
package eu.kanade.tachiyomi.data.backup.models
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.protobuf.ProtoNumber
import mihon.core.common.extensions.JsonObjectEmptyBytes
import tachiyomi.data.MemoColumnAdapter
import mihon.core.common.extensions.toByteArray
import mihon.core.common.extensions.toJsonObject
import tachiyomi.domain.chapter.model.Chapter
@Serializable
@@ -39,27 +39,12 @@ class BackupChapter(
dateFetch = this@BackupChapter.dateFetch,
dateUpload = this@BackupChapter.dateUpload,
sourceOrder = this@BackupChapter.sourceOrder,
memo = MemoColumnAdapter.decode(this@BackupChapter.memo),
memo = this@BackupChapter.memo.toJsonObject(),
)
}
}
val backupChapterMapper = {
_: Long,
_: Long,
url: String,
name: String,
scanlator: String?,
read: Boolean,
bookmark: Boolean,
lastPageRead: Long,
chapterNumber: Double,
sourceOrder: Long,
dateFetch: Long,
dateUpload: Long,
memo: JsonObject,
->
BackupChapter(
fun Chapter.toBackupChapter() = BackupChapter(
url = url,
name = name,
chapterNumber = chapterNumber.toFloat(),
@@ -70,6 +55,5 @@ val backupChapterMapper = {
dateFetch = dateFetch,
dateUpload = dateUpload,
sourceOrder = sourceOrder,
memo = MemoColumnAdapter.encode(memo),
memo = memo.toByteArray(),
)
}
@@ -4,7 +4,7 @@ import eu.kanade.tachiyomi.source.model.UpdateStrategy
import kotlinx.serialization.Serializable
import kotlinx.serialization.protobuf.ProtoNumber
import mihon.core.common.extensions.JsonObjectEmptyBytes
import tachiyomi.data.MemoColumnAdapter
import mihon.core.common.extensions.toJsonObject
import tachiyomi.domain.manga.model.Manga
@Suppress("DEPRECATION")
@@ -66,7 +66,7 @@ class BackupManga(
favoriteModifiedAt = this@BackupManga.favoriteModifiedAt,
notes = this@BackupManga.notes,
initialized = this@BackupManga.initialized,
memo = MemoColumnAdapter.decode(this@BackupManga.memo),
memo = this@BackupManga.memo.toJsonObject(),
)
}
}
@@ -54,25 +54,9 @@ data class BackupTracking(
}
}
val backupTrackMapper = {
_: Long,
_: Long,
syncId: Long,
mediaId: Long,
libraryId: Long?,
title: String,
lastChapterRead: Double,
totalChapters: Long,
status: Long,
score: Double,
remoteUrl: String,
startDate: Long,
finishDate: Long,
private: Boolean,
->
BackupTracking(
syncId = syncId.toInt(),
mediaId = mediaId,
fun Track.toBackupTracking() = BackupTracking(
syncId = trackerId.toInt(),
mediaId = remoteId,
// forced not null so its compatible with 1.x backup system
libraryId = libraryId ?: 0,
title = title,
@@ -85,4 +69,3 @@ val backupTrackMapper = {
trackingUrl = remoteUrl,
private = private,
)
}
@@ -26,7 +26,6 @@ import kotlinx.coroutines.launch
import logcat.LogPriority
import tachiyomi.core.common.i18n.stringResource
import tachiyomi.core.common.util.system.logcat
import tachiyomi.data.Database
import tachiyomi.i18n.MR
import java.io.File
import java.text.SimpleDateFormat
@@ -43,7 +42,6 @@ class BackupRestorer(
@Assisted private val notifier: BackupNotifier,
@Assisted private val isSync: Boolean,
private val context: Context,
private val database: Database,
private val downloadCache: DownloadCache,
private val categoriesRestorer: CategoriesRestorer,
private val preferenceRestorer: PreferenceRestorer,
@@ -170,12 +168,8 @@ class BackupRestorer(
.chunked(100)
.forEach { chunk ->
val restoredAsBatch = try {
database.transaction {
chunk.forEach {
ensureActive()
mangaRestorer.restore(it, backupCategories)
}
}
mangaRestorer.restore(chunk, backupCategories)
true
} catch (e: Exception) {
ensureActive()
@@ -190,7 +184,7 @@ class BackupRestorer(
ensureActive()
try {
mangaRestorer.restore(it, backupCategories)
mangaRestorer.restore(listOf(it), backupCategories)
} catch (e: Exception) {
ensureActive()
val sourceName = sourceMapping[it.source] ?: it.source.toString()
@@ -245,7 +239,6 @@ class BackupRestorer(
backupExtensionStores
.chunked(100)
.forEach { chunk ->
database.transaction {
chunk.forEach {
ensureActive()
@@ -257,7 +250,6 @@ class BackupRestorer(
restoreProgress.incrementAndFetch()
}
}
notifier.showRestoreProgress(
context.stringResource(MR.strings.extensionStores),
restoreProgress.load(),
@@ -2,13 +2,13 @@ package eu.kanade.tachiyomi.data.backup.restore.restorers
import dev.zacsweers.metro.Inject
import eu.kanade.tachiyomi.data.backup.models.BackupCategory
import tachiyomi.data.Database
import tachiyomi.domain.category.interactor.GetCategories
import tachiyomi.domain.category.repository.CategoryRepository
import tachiyomi.domain.library.service.LibraryPreferences
@Inject
class CategoriesRestorer(
private val database: Database,
private val categoryRepository: CategoryRepository,
private val getCategories: GetCategories,
private val libraryPreferences: LibraryPreferences,
) {
@@ -19,18 +19,11 @@ class CategoriesRestorer(
val dbCategoriesByName = dbCategories.associateBy { it.name }
var nextOrder = dbCategories.maxOfOrNull { it.order }?.plus(1) ?: 0
val categories = database.transactionWithResult {
backupCategories
val categories = backupCategories
.sortedBy { it.order }
.map {
val dbCategory = dbCategoriesByName[it.name]
if (dbCategory != null) return@map dbCategory
val order = nextOrder++
database.categoriesQueries
.insert(it.name, order, it.flags)
.let { id -> it.toCategory(id).copy(order = order) }
}
}
.filter { it.name !in dbCategoriesByName }
.map { it.toCategory(id = 0).copy(order = nextOrder++) }
categoryRepository.insertAll(categories)
libraryPreferences.categorizedDisplaySettings.set(
(dbCategories + categories)
@@ -2,25 +2,30 @@ package eu.kanade.tachiyomi.data.backup.restore.restorers
import dev.zacsweers.metro.Inject
import eu.kanade.tachiyomi.data.backup.models.BackupExtensionStore
import tachiyomi.data.Database
import mihon.domain.extension.model.ExtensionStore
import mihon.domain.extension.repository.ExtensionStoreRepository
@Inject
class ExtensionStoreRestorer(
private val database: Database,
private val extensionStoreRepository: ExtensionStoreRepository,
) {
suspend operator fun invoke(
backupStore: BackupExtensionStore,
) {
database.extension_storeQueries.upsert(
extensionStoreRepository.upsert(
ExtensionStore(
indexUrl = backupStore.indexUrl,
name = backupStore.name,
badgeLabel = backupStore.badgeLabel ?: backupStore.name,
signingKey = backupStore.signingKey,
contactWebsite = backupStore.contactWebsite,
contactDiscord = backupStore.contactDiscord,
contact = ExtensionStore.Contact(
website = backupStore.contactWebsite,
discord = backupStore.contactDiscord,
),
isLegacy = backupStore.isLegacy ?: true,
extensionListUrl = backupStore.extensionListUrl,
),
)
}
}
@@ -1,42 +1,22 @@
package eu.kanade.tachiyomi.data.backup.restore.restorers
import app.cash.sqldelight.async.coroutines.awaitAsList
import app.cash.sqldelight.async.coroutines.awaitAsOne
import app.cash.sqldelight.async.coroutines.awaitAsOneOrNull
import dev.zacsweers.metro.Inject
import eu.kanade.domain.manga.interactor.UpdateManga
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.BackupTracking
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import kotlinx.serialization.json.JsonObject
import tachiyomi.data.Database
import tachiyomi.domain.backup.model.RestoredHistory
import tachiyomi.domain.backup.model.RestoredManga
import tachiyomi.domain.backup.repository.RestoreRepository
import tachiyomi.domain.category.interactor.GetCategories
import tachiyomi.domain.chapter.interactor.GetChaptersByMangaId
import tachiyomi.domain.chapter.model.Chapter
import tachiyomi.domain.manga.interactor.FetchInterval
import tachiyomi.domain.manga.interactor.GetMangaByUrlAndSourceId
import tachiyomi.domain.manga.model.Manga
import tachiyomi.domain.track.interactor.GetTracks
import tachiyomi.domain.track.interactor.InsertTrack
import tachiyomi.domain.track.model.Track
import java.util.Date
import kotlin.math.max
import kotlin.time.Clock
@Inject
class MangaRestorer(
private val database: Database,
private val restoreRepository: RestoreRepository,
private val getCategories: GetCategories,
private val getMangaByUrlAndSourceId: GetMangaByUrlAndSourceId,
private val getChaptersByMangaId: GetChaptersByMangaId,
private val updateManga: UpdateManga,
private val getTracks: GetTracks,
private val insertTrack: InsertTrack,
fetchInterval: FetchInterval,
private val fetchInterval: FetchInterval,
) {
private val timeZone = TimeZone.currentSystemDefault()
@@ -44,370 +24,42 @@ class MangaRestorer(
private val currentFetchWindow = fetchInterval.getWindow(now.date, timeZone)
suspend fun sortByNew(backupMangas: List<BackupManga>): List<BackupManga> {
val urlsBySource = database.mangasQueries
.getAllMangaSourceAndUrl()
.awaitAsList()
.groupBy({ it.source }, { it.url })
val urlsBySource = restoreRepository.getMangaUrlsBySourceId()
return backupMangas
.sortedBy { it.url in urlsBySource[it.source].orEmpty() }
}
/**
* Restores [backupMangas] all together, so either every one of them is restored or none is.
*/
suspend fun restore(
backupManga: BackupManga,
backupMangas: List<BackupManga>,
backupCategories: List<BackupCategory>,
) {
database.transaction {
val dbManga = findExistingManga(backupManga)
val manga = backupManga.getMangaImpl()
val restoredManga = if (dbManga == null) {
restoreNewManga(manga)
} else {
restoreExistingManga(manga, dbManga)
}
val dbCategoriesByName = getCategories.await().associateBy { it.name }
val backupCategoriesByOrder = backupCategories.associateBy { it.order }
restoreMangaDetails(
manga = restoredManga,
chapters = backupManga.chapters,
categories = backupManga.categories,
backupCategories = backupCategories,
history = backupManga.history,
tracks = backupManga.tracking,
val entries = backupMangas.map { backupManga ->
RestoredManga(
manga = backupManga.getMangaImpl(),
chapters = backupManga.chapters.map { it.toChapterImpl() },
categoryIds = backupManga.categories.mapNotNull { backupCategoryOrder ->
backupCategoriesByOrder[backupCategoryOrder]?.let { backupCategory ->
dbCategoriesByName[backupCategory.name]?.id
}
},
history = backupManga.history.map {
val history = it.getHistoryImpl()
RestoredHistory(it.url, history.readAt, history.readDuration)
},
tracks = backupManga.tracking.map { it.getTrackImpl() },
excludedScanlators = backupManga.excludedScanlators,
)
}
}
private suspend fun findExistingManga(backupManga: BackupManga): Manga? {
return getMangaByUrlAndSourceId.await(backupManga.url, backupManga.source)
}
private suspend fun restoreExistingManga(manga: Manga, dbManga: Manga): Manga {
val details = if (dbManga.initialized || !manga.initialized) dbManga else manga
return updateManga(
dbManga.copy(
favorite = dbManga.favorite || manga.favorite,
dateAdded = listOfNotNull(
dbManga.dateAdded.takeIf { it > 0 },
manga.dateAdded.takeIf { it > 0 },
).minOrNull() ?: 0L,
title = details.title,
artist = details.artist,
author = details.author,
description = details.description,
genre = details.genre,
status = details.status,
thumbnailUrl = details.thumbnailUrl,
updateStrategy = details.updateStrategy,
initialized = dbManga.initialized || manga.initialized,
// Merge both backup and local data with local winning
memo = JsonObject(manga.memo + dbManga.memo),
),
)
}
private suspend fun updateManga(manga: Manga): Manga {
database.mangasQueries.updateFromBackup(
artist = manga.artist,
author = manga.author,
description = manga.description,
genre = manga.genre,
title = manga.title,
status = manga.status,
thumbnailUrl = manga.thumbnailUrl,
favorite = manga.favorite,
lastUpdate = manga.lastUpdate,
initialized = manga.initialized,
viewer = manga.viewerFlags,
chapterFlags = manga.chapterFlags,
coverLastModified = manga.coverLastModified,
dateAdded = manga.dateAdded,
mangaId = manga.id,
updateStrategy = manga.updateStrategy,
notes = manga.notes,
memo = manga.memo,
)
return manga
}
private suspend fun restoreNewManga(
manga: Manga,
): Manga {
return manga.copy(
id = insertManga(manga),
)
}
private suspend fun restoreChapters(manga: Manga, backupChapters: List<BackupChapter>) {
val dbChaptersByUrl = getChaptersByMangaId.await(manga.id)
.associateBy { it.url }
val (existingChapters, newChapters) = backupChapters
.mapNotNull {
val chapter = it.toChapterImpl().copy(mangaId = manga.id)
val dbChapter = dbChaptersByUrl[chapter.url]
?: // New chapter
return@mapNotNull chapter
if (chapter.forComparison() == dbChapter.forComparison()) {
// Same state; skip
return@mapNotNull null
}
chapter
.copyFrom(dbChapter)
.copy(
id = dbChapter.id,
read = chapter.read || dbChapter.read,
bookmark = chapter.bookmark || dbChapter.bookmark,
lastPageRead = max(chapter.lastPageRead, dbChapter.lastPageRead),
dateFetch = dbChapter.dateFetch,
sourceOrder = dbChapter.sourceOrder,
// Merge both backup and local data with local winning
memo = JsonObject(chapter.memo + dbChapter.memo),
)
}
.partition { it.id > 0 }
insertNewChapters(newChapters)
updateExistingChapters(existingChapters)
}
private fun Chapter.forComparison() =
this.copy(id = 0L, mangaId = 0L, dateFetch = 0L, dateUpload = 0L)
private suspend fun insertNewChapters(chapters: List<Chapter>) {
database.transaction {
chapters.forEach { chapter ->
database.chaptersQueries.insert(
chapter.mangaId,
chapter.url,
chapter.name,
chapter.scanlator,
chapter.read,
chapter.bookmark,
chapter.lastPageRead,
chapter.chapterNumber,
chapter.sourceOrder,
chapter.dateFetch,
chapter.dateUpload,
chapter.memo,
)
restoreRepository.restoreManga(entries) {
fetchInterval.toMangaUpdate(it, now, timeZone, currentFetchWindow)
}
}
}
private suspend fun updateExistingChapters(chapters: List<Chapter>) {
database.transaction {
chapters.forEach { chapter ->
database.chaptersQueries.updateFromBackup(
read = chapter.read,
bookmark = chapter.bookmark,
lastPageRead = chapter.lastPageRead,
chapterId = chapter.id,
memo = chapter.memo,
)
}
}
}
/**
* Inserts manga and returns id
*
* @return id of [Manga], null if not found
*/
private suspend fun insertManga(manga: Manga): Long {
return database.mangasQueries.insertReturningId(
source = manga.source,
url = manga.url,
artist = manga.artist,
author = manga.author,
description = manga.description,
genre = manga.genre,
title = manga.title,
status = manga.status,
thumbnailUrl = manga.thumbnailUrl,
favorite = manga.favorite,
lastUpdate = manga.lastUpdate,
nextUpdate = 0L,
calculateInterval = 0L,
initialized = manga.initialized,
viewerFlags = manga.viewerFlags,
chapterFlags = manga.chapterFlags,
coverLastModified = manga.coverLastModified,
dateAdded = manga.dateAdded,
updateStrategy = manga.updateStrategy,
notes = manga.notes,
memo = manga.memo,
)
.awaitAsOne()
}
private suspend fun restoreMangaDetails(
manga: Manga,
chapters: List<BackupChapter>,
categories: List<Long>,
backupCategories: List<BackupCategory>,
history: List<BackupHistory>,
tracks: List<BackupTracking>,
excludedScanlators: List<String>,
): Manga {
restoreCategories(manga, categories, backupCategories)
restoreChapters(manga, chapters)
restoreTracking(manga, tracks)
restoreHistory(manga, history)
restoreExcludedScanlators(manga, excludedScanlators)
updateManga.awaitUpdateFetchInterval(manga, timeZone, now, currentFetchWindow)
return manga
}
/**
* Restores the categories a manga is in.
*
* @param manga the manga whose categories have to be restored.
* @param categories the categories to restore.
*/
private suspend fun restoreCategories(
manga: Manga,
categories: List<Long>,
backupCategories: List<BackupCategory>,
) {
val dbCategories = getCategories.await()
val dbCategoriesByName = dbCategories.associateBy { it.name }
val backupCategoriesByOrder = backupCategories.associateBy { it.order }
val mangaCategoriesToUpdate = categories.mapNotNull { backupCategoryOrder ->
backupCategoriesByOrder[backupCategoryOrder]?.let { backupCategory ->
dbCategoriesByName[backupCategory.name]?.let { dbCategory ->
Pair(manga.id, dbCategory.id)
}
}
}
if (mangaCategoriesToUpdate.isNotEmpty()) {
database.transaction {
database.mangas_categoriesQueries.deleteMangaCategoryByMangaId(manga.id)
mangaCategoriesToUpdate.forEach { (mangaId, categoryId) ->
database.mangas_categoriesQueries.insert(mangaId, categoryId)
}
}
}
}
private suspend fun restoreHistory(manga: Manga, backupHistory: List<BackupHistory>) {
val toUpdate = backupHistory.mapNotNull { history ->
val dbHistory = database.historyQueries
.getHistoryByChapterUrlAndMangaId(history.url, manga.id)
.awaitAsOneOrNull()
val item = history.getHistoryImpl()
if (dbHistory == null) {
val chapter = database.chaptersQueries
.getChapterByUrlAndMangaId(history.url, manga.id)
.awaitAsOneOrNull()
return@mapNotNull if (chapter == null) {
// Chapter doesn't exist; skip
null
} else {
// New history entry
item.copy(chapterId = chapter._id)
}
}
// Update history entry
item.copy(
id = dbHistory._id,
chapterId = dbHistory.chapter_id,
readAt = max(item.readAt?.time ?: 0L, dbHistory.last_read?.time ?: 0L)
.takeIf { it > 0L }
?.let { Date(it) },
readDuration = max(item.readDuration, dbHistory.time_read) - dbHistory.time_read,
)
}
if (toUpdate.isEmpty()) return
database.transaction {
toUpdate.forEach {
database.historyQueries.upsert(
it.chapterId,
it.readAt,
it.readDuration,
)
}
}
}
private suspend fun restoreTracking(manga: Manga, backupTracks: List<BackupTracking>) {
val dbTrackByTrackerId = getTracks.await(manga.id).associateBy { it.trackerId }
val (existingTracks, newTracks) = backupTracks
.mapNotNull {
val track = it.getTrackImpl()
val dbTrack = dbTrackByTrackerId[track.trackerId]
?: // New track
return@mapNotNull track.copy(
id = 0, // Let DB assign new ID
mangaId = manga.id,
)
if (track.forComparison() == dbTrack.forComparison()) {
// Same state; skip
return@mapNotNull null
}
// Update to an existing track
dbTrack.copy(
remoteId = track.remoteId,
libraryId = track.libraryId,
lastChapterRead = max(dbTrack.lastChapterRead, track.lastChapterRead),
)
}
.partition { it.id > 0 }
if (newTracks.isNotEmpty()) {
insertTrack.awaitAll(newTracks)
}
if (existingTracks.isEmpty()) return
database.transaction {
existingTracks.forEach { track ->
database.manga_syncQueries.update(
track.mangaId,
track.trackerId,
track.remoteId,
track.libraryId,
track.title,
track.lastChapterRead,
track.totalChapters,
track.status,
track.score,
track.remoteUrl,
track.startDate,
track.finishDate,
track.private,
track.id,
)
}
}
}
private fun Track.forComparison() = this.copy(id = 0L, mangaId = 0L)
/**
* Restores the excluded scanlators for the manga.
*
* @param manga the manga whose excluded scanlators have to be restored.
* @param excludedScanlators the excluded scanlators to restore.
*/
private suspend fun restoreExcludedScanlators(manga: Manga, excludedScanlators: List<String>) {
if (excludedScanlators.isEmpty()) return
val existingExcludedScanlators = database.excluded_scanlatorsQueries
.getExcludedScanlatorsByMangaId(manga.id)
.awaitAsList()
val toInsert = excludedScanlators.filter { it !in existingExcludedScanlators }
if (toInsert.isEmpty()) return
toInsert.forEach { database.excluded_scanlatorsQueries.insert(manga.id, it) }
}
}
@@ -1,12 +1,5 @@
package mihon.app.di
import android.content.Context
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import app.cash.sqldelight.db.SqlDriver
import com.eygraber.sqldelight.androidx.driver.AndroidxSqliteConfiguration
import com.eygraber.sqldelight.androidx.driver.AndroidxSqliteDatabaseType
import com.eygraber.sqldelight.androidx.driver.AndroidxSqliteDriver
import com.eygraber.sqldelight.androidx.driver.FileProvider
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.BindingContainer
import dev.zacsweers.metro.Provides
@@ -16,50 +9,10 @@ import kotlinx.serialization.protobuf.ProtoBuf
import nl.adaptivity.xmlutil.XmlDeclMode
import nl.adaptivity.xmlutil.core.XmlVersion
import nl.adaptivity.xmlutil.serialization.XML
import tachiyomi.data.Chapters
import tachiyomi.data.Database
import tachiyomi.data.DateColumnAdapter
import tachiyomi.data.History
import tachiyomi.data.Mangas
import tachiyomi.data.MemoColumnAdapter
import tachiyomi.data.StringListColumnAdapter
import tachiyomi.data.UpdateStrategyColumnAdapter
@BindingContainer
object AppBindings {
@Provides
@SingleIn(AppScope::class)
fun providesSqlDriver(context: Context): SqlDriver {
return AndroidxSqliteDriver(
driver = BundledSQLiteDriver(),
databaseType = AndroidxSqliteDatabaseType.FileProvider(context, "tachiyomi.db"),
schema = Database.Schema,
configuration = AndroidxSqliteConfiguration(
isForeignKeyConstraintsEnabled = true,
),
)
}
@Provides
@SingleIn(AppScope::class)
fun providesDatabase(driver: SqlDriver): Database {
return Database(
driver = driver,
historyAdapter = History.Adapter(
last_readAdapter = DateColumnAdapter,
),
mangasAdapter = Mangas.Adapter(
genreAdapter = StringListColumnAdapter,
update_strategyAdapter = UpdateStrategyColumnAdapter,
memoAdapter = MemoColumnAdapter,
),
chaptersAdapter = Chapters.Adapter(
memoAdapter = MemoColumnAdapter,
),
)
}
@Provides
@SingleIn(AppScope::class)
fun providesJson(): Json = Json {
@@ -1,5 +1,6 @@
package mihon.core.common.extensions
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
val JsonObjectEmpty = JsonObject(emptyMap())
@@ -8,3 +9,7 @@ val JsonObjectEmptyBytes = byteArrayOf(0x7B, 0x7D)
val JsonObject.Companion.EMPTY: JsonObject
inline get() = JsonObjectEmpty
fun JsonObject.toByteArray(): ByteArray = toString().encodeToByteArray()
fun ByteArray.toJsonObject(): JsonObject = Json.decodeFromString<JsonObject>(decodeToString())
+2 -1
View File
@@ -42,5 +42,6 @@ dependencies {
implementation(libs.kotlinx.datetime)
api(libs.bundles.sqldelight)
implementation(libs.androidx.sqlite.bundled)
implementation(libs.bundles.sqldelight)
}
@@ -66,7 +66,7 @@ class ExtensionStoreRepositoryImpl(
}
}
private suspend fun upsert(store: ExtensionStore) {
override suspend fun upsert(store: ExtensionStore) {
database.extension_storeQueries.upsert(
indexUrl = store.indexUrl,
name = store.name,
@@ -2,8 +2,9 @@ package tachiyomi.data
import app.cash.sqldelight.ColumnAdapter
import eu.kanade.tachiyomi.source.model.UpdateStrategy
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import mihon.core.common.extensions.toByteArray
import mihon.core.common.extensions.toJsonObject
import java.util.Date
object DateColumnAdapter : ColumnAdapter<Date, Long> {
@@ -31,11 +32,6 @@ object UpdateStrategyColumnAdapter : ColumnAdapter<UpdateStrategy, Long> {
}
object MemoColumnAdapter : ColumnAdapter<JsonObject, ByteArray> {
override fun decode(databaseValue: ByteArray): JsonObject {
return Json.decodeFromString<JsonObject>(databaseValue.decodeToString())
}
override fun encode(value: JsonObject): ByteArray {
return value.toString().encodeToByteArray()
}
override fun decode(databaseValue: ByteArray): JsonObject = databaseValue.toJsonObject()
override fun encode(value: JsonObject): ByteArray = value.toByteArray()
}
@@ -0,0 +1,51 @@
package tachiyomi.data
import android.content.Context
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import app.cash.sqldelight.db.SqlDriver
import com.eygraber.sqldelight.androidx.driver.AndroidxSqliteConfiguration
import com.eygraber.sqldelight.androidx.driver.AndroidxSqliteDatabaseType
import com.eygraber.sqldelight.androidx.driver.AndroidxSqliteDriver
import com.eygraber.sqldelight.androidx.driver.FileProvider
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.BindingContainer
import dev.zacsweers.metro.ContributesTo
import dev.zacsweers.metro.Provides
import dev.zacsweers.metro.SingleIn
@ContributesTo(AppScope::class)
@BindingContainer
object DatabaseBindings {
@Provides
@SingleIn(AppScope::class)
fun providesSqlDriver(context: Context): SqlDriver {
return AndroidxSqliteDriver(
driver = BundledSQLiteDriver(),
databaseType = AndroidxSqliteDatabaseType.FileProvider(context, "tachiyomi.db"),
schema = Database.Schema,
configuration = AndroidxSqliteConfiguration(
isForeignKeyConstraintsEnabled = true,
),
)
}
@Provides
@SingleIn(AppScope::class)
fun providesDatabase(driver: SqlDriver): Database {
return Database(
driver = driver,
historyAdapter = History.Adapter(
last_readAdapter = DateColumnAdapter,
),
mangasAdapter = Mangas.Adapter(
genreAdapter = StringListColumnAdapter,
update_strategyAdapter = UpdateStrategyColumnAdapter,
memoAdapter = MemoColumnAdapter,
),
chaptersAdapter = Chapters.Adapter(
memoAdapter = MemoColumnAdapter,
),
)
}
}
@@ -0,0 +1,298 @@
package tachiyomi.data.backup
import app.cash.sqldelight.async.coroutines.awaitAsList
import app.cash.sqldelight.async.coroutines.awaitAsOne
import app.cash.sqldelight.async.coroutines.awaitAsOneOrNull
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
import kotlinx.serialization.json.JsonObject
import tachiyomi.data.Database
import tachiyomi.domain.backup.model.RestoredHistory
import tachiyomi.domain.backup.model.RestoredManga
import tachiyomi.domain.backup.repository.RestoreRepository
import tachiyomi.domain.chapter.model.Chapter
import tachiyomi.domain.chapter.repository.ChapterRepository
import tachiyomi.domain.manga.model.Manga
import tachiyomi.domain.manga.model.MangaUpdate
import tachiyomi.domain.manga.repository.MangaRepository
import tachiyomi.domain.track.model.Track
import tachiyomi.domain.track.repository.TrackRepository
import java.util.Date
import kotlin.math.max
@Inject
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
class RestoreRepositoryImpl(
private val database: Database,
private val mangaRepository: MangaRepository,
private val chapterRepository: ChapterRepository,
private val trackRepository: TrackRepository,
) : RestoreRepository {
override suspend fun getMangaUrlsBySourceId(): Map<Long, List<String>> {
return database.mangasQueries
.getAllMangaSourceAndUrl()
.awaitAsList()
.groupBy({ it.source }, { it.url })
}
override suspend fun restoreManga(entries: List<RestoredManga>, update: suspend (Manga) -> MangaUpdate) {
database.transaction {
entries.forEach { entry ->
// A cancelled restore stops between entries and rolls the whole batch back
currentCoroutineContext().ensureActive()
mangaRepository.update(update(restore(entry)))
}
}
}
private suspend fun restore(entry: RestoredManga): Manga {
val dbManga = mangaRepository.getMangaByUrlAndSourceId(entry.manga.url, entry.manga.source)
val manga = if (dbManga == null) {
entry.manga.copy(id = insertManga(entry.manga))
} else {
updateManga(mergeManga(entry.manga, dbManga))
}
if (entry.categoryIds.isNotEmpty()) {
mangaRepository.setMangaCategories(manga.id, entry.categoryIds)
}
restoreChapters(manga, entry.chapters)
restoreTracking(manga, entry.tracks)
restoreHistory(manga, entry.history)
restoreExcludedScanlators(manga, entry.excludedScanlators)
return manga
}
private fun mergeManga(manga: Manga, dbManga: Manga): Manga {
val details = if (dbManga.initialized || !manga.initialized) dbManga else manga
return dbManga.copy(
favorite = dbManga.favorite || manga.favorite,
dateAdded = listOfNotNull(
dbManga.dateAdded.takeIf { it > 0 },
manga.dateAdded.takeIf { it > 0 },
).minOrNull() ?: 0L,
title = details.title,
artist = details.artist,
author = details.author,
description = details.description,
genre = details.genre,
status = details.status,
thumbnailUrl = details.thumbnailUrl,
updateStrategy = details.updateStrategy,
initialized = dbManga.initialized || manga.initialized,
// Merge both backup and local data with local winning
memo = JsonObject(manga.memo + dbManga.memo),
)
}
private suspend fun updateManga(manga: Manga): Manga {
database.mangasQueries.updateFromBackup(
artist = manga.artist,
author = manga.author,
description = manga.description,
genre = manga.genre,
title = manga.title,
status = manga.status,
thumbnailUrl = manga.thumbnailUrl,
favorite = manga.favorite,
lastUpdate = manga.lastUpdate,
initialized = manga.initialized,
viewer = manga.viewerFlags,
chapterFlags = manga.chapterFlags,
coverLastModified = manga.coverLastModified,
dateAdded = manga.dateAdded,
mangaId = manga.id,
updateStrategy = manga.updateStrategy,
notes = manga.notes,
memo = manga.memo,
)
return manga
}
private suspend fun insertManga(manga: Manga): Long {
return database.mangasQueries.insertReturningId(
source = manga.source,
url = manga.url,
artist = manga.artist,
author = manga.author,
description = manga.description,
genre = manga.genre,
title = manga.title,
status = manga.status,
thumbnailUrl = manga.thumbnailUrl,
favorite = manga.favorite,
lastUpdate = manga.lastUpdate,
nextUpdate = 0L,
calculateInterval = 0L,
initialized = manga.initialized,
viewerFlags = manga.viewerFlags,
chapterFlags = manga.chapterFlags,
coverLastModified = manga.coverLastModified,
dateAdded = manga.dateAdded,
updateStrategy = manga.updateStrategy,
notes = manga.notes,
memo = manga.memo,
)
.awaitAsOne()
}
private suspend fun restoreChapters(manga: Manga, restoredChapters: List<Chapter>) {
val dbChaptersByUrl = chapterRepository.getChapterByMangaId(manga.id)
.associateBy { it.url }
val (existingChapters, newChapters) = restoredChapters
.mapNotNull {
val chapter = it.copy(mangaId = manga.id)
val dbChapter = dbChaptersByUrl[chapter.url]
?: // New chapter
return@mapNotNull chapter
if (chapter.forComparison() == dbChapter.forComparison()) {
// Same state; skip
return@mapNotNull null
}
chapter
.copyFrom(dbChapter)
.copy(
id = dbChapter.id,
read = chapter.read || dbChapter.read,
bookmark = chapter.bookmark || dbChapter.bookmark,
lastPageRead = max(chapter.lastPageRead, dbChapter.lastPageRead),
dateFetch = dbChapter.dateFetch,
sourceOrder = dbChapter.sourceOrder,
// Merge both backup and local data with local winning
memo = JsonObject(chapter.memo + dbChapter.memo),
)
}
.partition { it.id > 0 }
newChapters.forEach { chapter ->
database.chaptersQueries.insert(
chapter.mangaId,
chapter.url,
chapter.name,
chapter.scanlator,
chapter.read,
chapter.bookmark,
chapter.lastPageRead,
chapter.chapterNumber,
chapter.sourceOrder,
chapter.dateFetch,
chapter.dateUpload,
chapter.memo,
)
}
existingChapters.forEach { chapter ->
database.chaptersQueries.updateFromBackup(
read = chapter.read,
bookmark = chapter.bookmark,
lastPageRead = chapter.lastPageRead,
chapterId = chapter.id,
memo = chapter.memo,
)
}
}
private fun Chapter.forComparison() =
this.copy(id = 0L, mangaId = 0L, dateFetch = 0L, dateUpload = 0L)
private suspend fun restoreHistory(manga: Manga, restoredHistory: List<RestoredHistory>) {
val toUpdate = restoredHistory.mapNotNull { history ->
val dbHistory = database.historyQueries
.getHistoryByChapterUrlAndMangaId(history.chapterUrl, manga.id)
.awaitAsOneOrNull()
if (dbHistory == null) {
val chapter = database.chaptersQueries
.getChapterByUrlAndMangaId(history.chapterUrl, manga.id)
.awaitAsOneOrNull()
// Chapter doesn't exist; skip
?: return@mapNotNull null
// New history entry
return@mapNotNull Triple(chapter._id, history.readAt, history.readDuration)
}
// Update history entry
Triple(
dbHistory.chapter_id,
max(history.readAt?.time ?: 0L, dbHistory.last_read?.time ?: 0L)
.takeIf { it > 0L }
?.let { Date(it) },
max(history.readDuration, dbHistory.time_read) - dbHistory.time_read,
)
}
toUpdate.forEach { (chapterId, readAt, readDuration) ->
database.historyQueries.upsert(chapterId, readAt, readDuration)
}
}
private suspend fun restoreTracking(manga: Manga, restoredTracks: List<Track>) {
val dbTrackByTrackerId = trackRepository.getTracksByMangaId(manga.id).associateBy { it.trackerId }
val (existingTracks, newTracks) = restoredTracks
.mapNotNull { track ->
val dbTrack = dbTrackByTrackerId[track.trackerId]
?: // New track
return@mapNotNull track.copy(
id = 0, // Let DB assign new ID
mangaId = manga.id,
)
if (track.forComparison() == dbTrack.forComparison()) {
// Same state; skip
return@mapNotNull null
}
// Update to an existing track
dbTrack.copy(
remoteId = track.remoteId,
libraryId = track.libraryId,
lastChapterRead = max(dbTrack.lastChapterRead, track.lastChapterRead),
)
}
.partition { it.id > 0 }
if (newTracks.isNotEmpty()) {
trackRepository.insertAll(newTracks)
}
existingTracks.forEach { track ->
database.manga_syncQueries.update(
track.mangaId,
track.trackerId,
track.remoteId,
track.libraryId,
track.title,
track.lastChapterRead,
track.totalChapters,
track.status,
track.score,
track.remoteUrl,
track.startDate,
track.finishDate,
track.private,
track.id,
)
}
}
private fun Track.forComparison() = this.copy(id = 0L, mangaId = 0L)
private suspend fun restoreExcludedScanlators(manga: Manga, excludedScanlators: List<String>) {
if (excludedScanlators.isEmpty()) return
val existingExcludedScanlators = database.excluded_scanlatorsQueries
.getExcludedScanlatorsByMangaId(manga.id)
.awaitAsList()
val toInsert = excludedScanlators.filter { it !in existingExcludedScanlators }
toInsert.forEach { database.excluded_scanlatorsQueries.insert(manga.id, it) }
}
}
@@ -57,6 +57,12 @@ class CategoryRepositoryImpl(
)
}
override suspend fun insertAll(categories: List<Category>) {
database.transaction {
categories.forEach { insert(it) }
}
}
override suspend fun updateName(categoryId: Long, name: String) {
database.categoriesQueries.updateName(name = name, categoryId = categoryId)
}
@@ -74,6 +74,10 @@ class HistoryRepositoryImpl(
}
}
override suspend fun deleteResetHistory() {
database.historyQueries.removeResettedHistory()
}
override suspend fun upsertHistory(historyUpdate: HistoryUpdate) {
try {
database.historyQueries.upsert(
@@ -8,10 +8,12 @@ import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.toLocalDateTime
import logcat.LogPriority
import tachiyomi.core.common.util.lang.toLong
import tachiyomi.core.common.util.system.logcat
import tachiyomi.data.Database
import tachiyomi.data.subscribeToList
@@ -123,6 +125,10 @@ class MangaRepositoryImpl(
}
}
override suspend fun deleteNonLibraryManga(sourceIds: List<Long>, keepReadManga: Boolean) {
database.mangasQueries.deleteNonLibraryManga(sourceIds, keepReadManga.toLong())
}
override suspend fun setMangaCategories(mangaId: Long, categoryIds: List<Long>) {
database.transaction {
database.mangas_categoriesQueries.deleteMangaCategoryByMangaId(mangaId)
@@ -132,6 +138,33 @@ class MangaRepositoryImpl(
}
}
override suspend fun getExcludedScanlators(mangaId: Long): Set<String> {
return database.excluded_scanlatorsQueries
.getExcludedScanlatorsByMangaId(mangaId)
.awaitAsList()
.toSet()
}
override fun getExcludedScanlatorsAsFlow(mangaId: Long): Flow<Set<String>> {
return database.excluded_scanlatorsQueries
.getExcludedScanlatorsByMangaId(mangaId)
.subscribeToList()
.map { it.toSet() }
}
override suspend fun setExcludedScanlators(mangaId: Long, excludedScanlators: Set<String>) {
database.transaction {
val current = database.excluded_scanlatorsQueries
.getExcludedScanlatorsByMangaId(mangaId)
.awaitAsList()
.toSet()
excludedScanlators.minus(current).forEach { scanlator ->
database.excluded_scanlatorsQueries.insert(mangaId, scanlator)
}
database.excluded_scanlatorsQueries.remove(mangaId, current.minus(excludedScanlators))
}
}
override suspend fun update(update: MangaUpdate): Boolean {
return try {
partialUpdate(update)
+1 -1
View File
@@ -28,7 +28,7 @@ dependencies {
implementation(libs.unifile)
api(libs.sqldelight.androidxPaging)
api(libs.androidx.paging.runtime)
compileOnly(platform(libs.androidx.compose.bom))
compileOnly(libs.androidx.compose.runtimeAnnotation)
@@ -9,6 +9,8 @@ interface ExtensionStoreRepository {
suspend fun insertFromPreference(indexUrl: String, name: String)
suspend fun upsert(store: ExtensionStore)
suspend fun refreshAll()
suspend fun fetchExtensions(): List<Extension.Available>
@@ -0,0 +1,21 @@
package tachiyomi.domain.backup.model
import tachiyomi.domain.chapter.model.Chapter
import tachiyomi.domain.manga.model.Manga
import tachiyomi.domain.track.model.Track
import java.util.Date
data class RestoredManga(
val manga: Manga,
val chapters: List<Chapter>,
val categoryIds: List<Long>,
val history: List<RestoredHistory>,
val tracks: List<Track>,
val excludedScanlators: List<String>,
)
data class RestoredHistory(
val chapterUrl: String,
val readAt: Date?,
val readDuration: Long,
)
@@ -0,0 +1,16 @@
package tachiyomi.domain.backup.repository
import tachiyomi.domain.backup.model.RestoredManga
import tachiyomi.domain.manga.model.Manga
import tachiyomi.domain.manga.model.MangaUpdate
interface RestoreRepository {
suspend fun getMangaUrlsBySourceId(): Map<Long, List<String>>
/**
* Restores [entries] all together: either every one of them is restored or none is. [update] is what to change on each
* entry once its chapters and history are in place, and is part of the same all-or-nothing restore.
*/
suspend fun restoreManga(entries: List<RestoredManga>, update: suspend (Manga) -> MangaUpdate)
}
@@ -17,6 +17,8 @@ interface CategoryRepository {
suspend fun insert(category: Category)
suspend fun insertAll(categories: List<Category>)
suspend fun updateName(categoryId: Long, name: String)
suspend fun updateFlags(categoryId: Long, flags: Long)
@@ -21,5 +21,7 @@ interface HistoryRepository {
suspend fun deleteAllHistory(): Boolean
suspend fun deleteResetHistory()
suspend fun upsertHistory(historyUpdate: HistoryUpdate)
}
@@ -37,8 +37,16 @@ interface MangaRepository {
suspend fun resetViewerFlags(): Boolean
suspend fun deleteNonLibraryManga(sourceIds: List<Long>, keepReadManga: Boolean)
suspend fun setMangaCategories(mangaId: Long, categoryIds: List<Long>)
suspend fun getExcludedScanlators(mangaId: Long): Set<String>
fun getExcludedScanlatorsAsFlow(mangaId: Long): Flow<Set<String>>
suspend fun setExcludedScanlators(mangaId: Long, excludedScanlators: Set<String>)
suspend fun update(update: MangaUpdate): Boolean
suspend fun updateRemote(update: MangaRemoteUpdate): Boolean