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,37 +39,21 @@ 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(
url = url,
name = name,
chapterNumber = chapterNumber.toFloat(),
scanlator = scanlator,
read = read,
bookmark = bookmark,
lastPageRead = lastPageRead,
dateFetch = dateFetch,
dateUpload = dateUpload,
sourceOrder = sourceOrder,
memo = MemoColumnAdapter.encode(memo),
)
}
fun Chapter.toBackupChapter() = BackupChapter(
url = url,
name = name,
chapterNumber = chapterNumber.toFloat(),
scanlator = scanlator,
read = read,
bookmark = bookmark,
lastPageRead = lastPageRead,
dateFetch = dateFetch,
dateUpload = dateUpload,
sourceOrder = sourceOrder,
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,35 +54,18 @@ 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,
// forced not null so its compatible with 1.x backup system
libraryId = libraryId ?: 0,
title = title,
lastChapterRead = lastChapterRead.toFloat(),
totalChapters = totalChapters.toInt(),
score = score.toFloat(),
status = status.toInt(),
startedReadingDate = startDate,
finishedReadingDate = finishDate,
trackingUrl = remoteUrl,
private = private,
)
}
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,
lastChapterRead = lastChapterRead.toFloat(),
totalChapters = totalChapters.toInt(),
score = score.toFloat(),
status = status.toInt(),
startedReadingDate = startDate,
finishedReadingDate = finishDate,
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)
}
}
ensureActive()
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,18 +239,16 @@ class BackupRestorer(
backupExtensionStores
.chunked(100)
.forEach { chunk ->
database.transaction {
chunk.forEach {
ensureActive()
chunk.forEach {
ensureActive()
try {
extensionStoreRestorer(it)
} catch (e: Exception) {
errors.add(Date() to "Error Adding Repo: ${it.name} : ${e.message}")
}
restoreProgress.incrementAndFetch()
try {
extensionStoreRestorer(it)
} catch (e: Exception) {
errors.add(Date() to "Error Adding Repo: ${it.name} : ${e.message}")
}
restoreProgress.incrementAndFetch()
}
notifier.showRestoreProgress(
context.stringResource(MR.strings.extensionStores),
@@ -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
.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) }
}
}
val categories = backupCategories
.sortedBy { it.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(
indexUrl = backupStore.indexUrl,
name = backupStore.name,
badgeLabel = backupStore.badgeLabel ?: backupStore.name,
signingKey = backupStore.signingKey,
contactWebsite = backupStore.contactWebsite,
contactDiscord = backupStore.contactDiscord,
isLegacy = backupStore.isLegacy ?: true,
extensionListUrl = backupStore.extensionListUrl,
extensionStoreRepository.upsert(
ExtensionStore(
indexUrl = backupStore.indexUrl,
name = backupStore.name,
badgeLabel = backupStore.badgeLabel ?: backupStore.name,
signingKey = backupStore.signingKey,
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 {