This commit is contained in:
Bartu Özen
2025-12-20 20:09:48 +03:00
parent 17af9b6419
commit a4bfecc09c
7 changed files with 348 additions and 283 deletions

View File

@@ -60,22 +60,23 @@ object SyncManager {
serverConfig.syncInterval, serverConfig.syncInterval,
) { enabled, interval -> Pair(enabled, interval) }, ) { enabled, interval -> Pair(enabled, interval) },
{ (enabled, interval) -> { (enabled, interval) ->
currentTaskId = if (enabled && interval > 0) { currentTaskId =
val intervalMs = interval.minutes.inWholeMilliseconds if (enabled && interval > 0) {
val intervalMs = interval.minutes.inWholeMilliseconds
currentTaskId?.let { HAScheduler.deschedule(it) } currentTaskId?.let { HAScheduler.deschedule(it) }
HAScheduler.schedule( HAScheduler.schedule(
{ {
startSync() startSync()
}, },
interval = intervalMs, interval = intervalMs,
delay = intervalMs, delay = intervalMs,
name = "sync", name = "sync",
) )
} else { } else {
null null
} }
}, },
ignoreInitialValue = false, ignoreInitialValue = false,
) )
@@ -132,29 +133,32 @@ object SyncManager {
val databaseManga = getAllMangaThatNeedsSync() val databaseManga = getAllMangaThatNeedsSync()
val backupFlags = BackupFlags( val backupFlags =
includeManga = serverConfig.syncDataManga.value, BackupFlags(
includeCategories = serverConfig.syncDataCategories.value, includeManga = serverConfig.syncDataManga.value,
includeChapters = serverConfig.syncDataChapters.value, includeCategories = serverConfig.syncDataCategories.value,
includeTracking = serverConfig.syncDataTracking.value, includeChapters = serverConfig.syncDataChapters.value,
includeHistory = serverConfig.syncDataHistory.value, includeTracking = serverConfig.syncDataTracking.value,
includeClientData = false, includeHistory = serverConfig.syncDataHistory.value,
includeServerSettings = false, includeClientData = false,
) includeServerSettings = false,
)
val backupMangas = BackupMangaHandler.backup(backupFlags) val backupMangas = BackupMangaHandler.backup(backupFlags)
val backup = Backup( val backup =
BackupMangaHandler.backup(backupFlags), Backup(
BackupCategoryHandler.backup(backupFlags).filter { it.name != Category.DEFAULT_CATEGORY_NAME }, BackupMangaHandler.backup(backupFlags),
BackupSourceHandler.backup(backupMangas, backupFlags), BackupCategoryHandler.backup(backupFlags).filter { it.name != Category.DEFAULT_CATEGORY_NAME },
emptyMap(), BackupSourceHandler.backup(backupMangas, backupFlags),
null, emptyMap(),
) null,
)
val syncData = SyncData( val syncData =
backup = backup, SyncData(
) backup = backup,
)
val remoteBackup = SyncYomiSyncService.doSync(syncData) val remoteBackup = SyncYomiSyncService.doSync(syncData)
@@ -167,7 +171,8 @@ object SyncManager {
if (remoteBackup === syncData.backup) { if (remoteBackup === syncData.backup) {
// nothing changed // nothing changed
logger.debug { "Skip restore due to remote was overwrite from local" } logger.debug { "Skip restore due to remote was overwrite from local" }
syncPreferences.edit() syncPreferences
.edit()
.putLong("last_sync_timestamp", Clock.System.now().toEpochMilliseconds()) .putLong("last_sync_timestamp", Clock.System.now().toEpochMilliseconds())
.apply() .apply()
return return
@@ -181,7 +186,8 @@ object SyncManager {
// Check if it's first sync based on lastSyncTimestamp // Check if it's first sync based on lastSyncTimestamp
if (syncPreferences.getLong("last_sync_timestamp", 0) == 0L && databaseManga.isNotEmpty()) { if (syncPreferences.getLong("last_sync_timestamp", 0) == 0L && databaseManga.isNotEmpty()) {
// It's first sync no need to restore data. (just update remote data) // It's first sync no need to restore data. (just update remote data)
syncPreferences.edit() syncPreferences
.edit()
.putLong("last_sync_timestamp", Clock.System.now().toEpochMilliseconds()) .putLong("last_sync_timestamp", Clock.System.now().toEpochMilliseconds())
.apply() .apply()
return return
@@ -190,16 +196,18 @@ object SyncManager {
val (filteredFavorites, nonFavorites) = filterFavoritesAndNonFavorites(remoteBackup) val (filteredFavorites, nonFavorites) = filterFavoritesAndNonFavorites(remoteBackup)
updateNonFavorites(nonFavorites) updateNonFavorites(nonFavorites)
val newSyncData = backup.copy( val newSyncData =
backupManga = filteredFavorites, backup.copy(
backupCategories = remoteBackup.backupCategories, backupManga = filteredFavorites,
backupSources = remoteBackup.backupSources, backupCategories = remoteBackup.backupCategories,
) backupSources = remoteBackup.backupSources,
)
// It's local sync no need to restore data. (just update remote data) // It's local sync no need to restore data. (just update remote data)
if (filteredFavorites.isEmpty()) { if (filteredFavorites.isEmpty()) {
// update the sync timestamp // update the sync timestamp
syncPreferences.edit() syncPreferences
.edit()
.putLong("last_sync_timestamp", Clock.System.now().toEpochMilliseconds()) .putLong("last_sync_timestamp", Clock.System.now().toEpochMilliseconds())
.apply() .apply()
return return
@@ -208,48 +216,52 @@ object SyncManager {
val backupStream = ProtoBuf.encodeToByteArray(Backup.serializer(), newSyncData).inputStream() val backupStream = ProtoBuf.encodeToByteArray(Backup.serializer(), newSyncData).inputStream()
ProtoBackupImport.restore( ProtoBackupImport.restore(
sourceStream = backupStream, sourceStream = backupStream,
flags = BackupFlags( flags =
includeManga = true, BackupFlags(
includeCategories = true, includeManga = true,
includeChapters = true, includeCategories = true,
includeTracking = true, includeChapters = true,
includeHistory = true, includeTracking = true,
includeClientData = false, includeHistory = true,
includeServerSettings = false, includeClientData = false,
), includeServerSettings = false,
),
isSync = true, isSync = true,
) )
// update the sync timestamp // update the sync timestamp
syncPreferences.edit() syncPreferences
.edit()
.putLong("last_sync_timestamp", Clock.System.now().toEpochMilliseconds()) .putLong("last_sync_timestamp", Clock.System.now().toEpochMilliseconds())
.apply() .apply()
} }
private fun getAllMangaFromDB(): List<MangaDataClass> { private fun getAllMangaFromDB(): List<MangaDataClass> = transaction { MangaTable.selectAll().map { MangaTable.toDataClass(it) } }
return transaction { MangaTable.selectAll().map { MangaTable.toDataClass(it) } }
}
private fun getAllMangaThatNeedsSync(): List<MangaDataClass> { private fun getAllMangaThatNeedsSync(): List<MangaDataClass> =
return transaction { transaction {
MangaTable.selectAll().where { MangaTable.inLibrary eq true }.map { MangaTable.toDataClass(it) } MangaTable.selectAll().where { MangaTable.inLibrary eq true }.map { MangaTable.toDataClass(it) }
} }
}
private fun isMangaDifferent(localManga: MangaDataClass, remoteManga: BackupManga): Boolean { private fun isMangaDifferent(
val localChapters = transaction { localManga: MangaDataClass,
ChapterTable remoteManga: BackupManga,
.selectAll() ): Boolean {
.where { ChapterTable.manga eq localManga.id } val localChapters =
.map { ChapterTable.toDataClass(it) } transaction {
} ChapterTable
val localCategories = transaction { .selectAll()
CategoryMangaTable .where { ChapterTable.manga eq localManga.id }
.innerJoin(CategoryTable) .map { ChapterTable.toDataClass(it) }
.selectAll() }
.where { CategoryMangaTable.manga eq localManga.id } val localCategories =
.map { it[CategoryTable.order] } transaction {
} CategoryMangaTable
.innerJoin(CategoryTable)
.selectAll()
.where { CategoryMangaTable.manga eq localManga.id }
.map { it[CategoryTable.order] }
}
if (areChaptersDifferent(localChapters, remoteManga.chapters)) { if (areChaptersDifferent(localChapters, remoteManga.chapters)) {
return true return true
@@ -293,39 +305,44 @@ object SyncManager {
val favorites = mutableListOf<BackupManga>() val favorites = mutableListOf<BackupManga>()
val nonFavorites = mutableListOf<BackupManga>() val nonFavorites = mutableListOf<BackupManga>()
val elapsedTimeMillis = measureTimeMillis { val elapsedTimeMillis =
val databaseManga = getAllMangaFromDB() measureTimeMillis {
val localMangaMap = databaseManga.associateBy { val databaseManga = getAllMangaFromDB()
Triple(it.sourceId.toLong(), it.url, it.title) val localMangaMap =
} databaseManga.associateBy {
Triple(it.sourceId.toLong(), it.url, it.title)
logger.debug { "Starting to filter favorites and non-favorites from backup data." }
backup.backupManga.forEach { remoteManga ->
val compositeKey = Triple(remoteManga.source, remoteManga.url, remoteManga.title)
val localManga = localMangaMap[compositeKey]
when {
// Checks if the manga is in favorites and needs updating or adding
remoteManga.favorite -> {
if (localManga == null || isMangaDifferent(localManga, remoteManga)) {
logger.debug { "Adding to favorites: ${remoteManga.title}" }
favorites.add(remoteManga)
} else {
logger.debug { "Already up-to-date favorite: ${remoteManga.title}" }
}
} }
// Handle non-favorites
!remoteManga.favorite -> { logger.debug { "Starting to filter favorites and non-favorites from backup data." }
logger.debug { "Adding to non-favorites: ${remoteManga.title}" }
nonFavorites.add(remoteManga) backup.backupManga.forEach { remoteManga ->
val compositeKey = Triple(remoteManga.source, remoteManga.url, remoteManga.title)
val localManga = localMangaMap[compositeKey]
when {
// Checks if the manga is in favorites and needs updating or adding
remoteManga.favorite -> {
if (localManga == null || isMangaDifferent(localManga, remoteManga)) {
logger.debug { "Adding to favorites: ${remoteManga.title}" }
favorites.add(remoteManga)
} else {
logger.debug { "Already up-to-date favorite: ${remoteManga.title}" }
}
}
// Handle non-favorites
!remoteManga.favorite -> {
logger.debug { "Adding to non-favorites: ${remoteManga.title}" }
nonFavorites.add(remoteManga)
}
} }
} }
} }
}
val minutes = elapsedTimeMillis / 60000 val minutes = elapsedTimeMillis / 60000
val seconds = (elapsedTimeMillis % 60000) / 1000 val seconds = (elapsedTimeMillis % 60000) / 1000
logger.debug { "Filtering completed in ${minutes}m ${seconds}s. Favorites found: ${favorites.size}, Non-favorites found: ${nonFavorites.size}" } logger.debug {
"Filtering completed in ${minutes}m ${seconds}s. Favorites found: ${favorites.size}, Non-favorites found: ${nonFavorites.size}"
}
return Pair(favorites, nonFavorites) return Pair(favorites, nonFavorites)
} }
@@ -378,4 +395,3 @@ object SyncManager {
} }
} }
} }

View File

@@ -31,21 +31,24 @@ object SyncYomiSyncService {
private val network: NetworkHelper by injectLazy() private val network: NetworkHelper by injectLazy()
private val logger = KotlinLogging.logger {} private val logger = KotlinLogging.logger {}
private class SyncYomiException(message: String?) : Exception(message) private class SyncYomiException(
message: String?,
) : Exception(message)
suspend fun doSync(syncData: SyncData): Backup? { suspend fun doSync(syncData: SyncData): Backup? {
try { try {
val (remoteData, etag) = pullSyncData() val (remoteData, etag) = pullSyncData()
val finalSyncData = if (remoteData != null) { val finalSyncData =
require(etag.isNotEmpty()) { "ETag should never be empty if remote data is not null" } if (remoteData != null) {
logger.debug { "Try update remote data with ETag($etag)" } require(etag.isNotEmpty()) { "ETag should never be empty if remote data is not null" }
mergeSyncData(syncData, remoteData) logger.debug { "Try update remote data with ETag($etag)" }
} else { mergeSyncData(syncData, remoteData)
// init or overwrite remote data } else {
logger.debug { "Try overwrite remote data with ETag($etag)" } // init or overwrite remote data
syncData logger.debug { "Try overwrite remote data with ETag($etag)" }
} syncData
}
pushSyncData(finalSyncData, etag) pushSyncData(finalSyncData, etag)
return finalSyncData.backup return finalSyncData.backup
@@ -67,10 +70,11 @@ object SyncYomiSyncService {
} }
val headers = headersBuilder.build() val headers = headersBuilder.build()
val downloadRequest = GET( val downloadRequest =
url = downloadUrl, GET(
headers = headers, url = downloadUrl,
) headers = headers,
)
val response = network.client.newCall(downloadRequest).await() val response = network.client.newCall(downloadRequest).await()
@@ -85,12 +89,14 @@ object SyncYomiSyncService {
} }
if (response.isSuccessful) { if (response.isSuccessful) {
val newETag = response.headers["ETag"] val newETag =
?.takeIf { it.isNotEmpty() } ?: throw SyncYomiException("Missing ETag") response.headers["ETag"]
?.takeIf { it.isNotEmpty() } ?: throw SyncYomiException("Missing ETag")
val byteArray = response.body.byteStream().use { val byteArray =
return@use it.readBytes() response.body.byteStream().use {
} return@use it.readBytes()
}
return try { return try {
val backup = ProtoBuf.decodeFromByteArray(Backup.serializer(), byteArray) val backup = ProtoBuf.decodeFromByteArray(Backup.serializer(), byteArray)
@@ -108,7 +114,10 @@ object SyncYomiSyncService {
} }
} }
private suspend fun pushSyncData(syncData: SyncData, eTag: String) { private suspend fun pushSyncData(
syncData: SyncData,
eTag: String,
) {
val backup = syncData.backup ?: return val backup = syncData.backup ?: return
val host = serverConfig.syncYomiHost.value val host = serverConfig.syncYomiHost.value
@@ -123,11 +132,13 @@ object SyncYomiSyncService {
val headers = headersBuilder.build() val headers = headersBuilder.build()
// Set timeout to 30 seconds // Set timeout to 30 seconds
val client = network.client.newBuilder() val client =
.connectTimeout(timeout, TimeUnit.SECONDS) network.client
.readTimeout(timeout, TimeUnit.SECONDS) .newBuilder()
.writeTimeout(timeout, TimeUnit.SECONDS) .connectTimeout(timeout, TimeUnit.SECONDS)
.build() .readTimeout(timeout, TimeUnit.SECONDS)
.writeTimeout(timeout, TimeUnit.SECONDS)
.build()
val byteArray = ProtoBuf.encodeToByteArray(Backup.serializer(), backup) val byteArray = ProtoBuf.encodeToByteArray(Backup.serializer(), backup)
if (byteArray.isEmpty()) { if (byteArray.isEmpty()) {
@@ -135,18 +146,21 @@ object SyncYomiSyncService {
} }
val body = byteArray.toRequestBody("application/octet-stream".toMediaType()) val body = byteArray.toRequestBody("application/octet-stream".toMediaType())
val uploadRequest = PUT( val uploadRequest =
url = uploadUrl, PUT(
headers = headers, url = uploadUrl,
body = body, headers = headers,
) body = body,
)
val response = client.newCall(uploadRequest).await() val response = client.newCall(uploadRequest).await()
if (response.isSuccessful) { if (response.isSuccessful) {
val newETag = response.headers["ETag"] val newETag =
?.takeIf { it.isNotEmpty() } ?: throw SyncYomiException("Missing ETag") response.headers["ETag"]
syncPreferences.edit() ?.takeIf { it.isNotEmpty() } ?: throw SyncYomiException("Missing ETag")
syncPreferences
.edit()
.putString("last_sync_etag", newETag) .putString("last_sync_etag", newETag)
.apply() .apply()
logger.debug { "SyncYomi sync completed" } logger.debug { "SyncYomi sync completed" }
@@ -159,29 +173,34 @@ object SyncYomiSyncService {
} }
} }
fun mergeSyncData(localSyncData: SyncData, remoteSyncData: SyncData): SyncData { fun mergeSyncData(
localSyncData: SyncData,
remoteSyncData: SyncData,
): SyncData {
val mergedCategoriesList = val mergedCategoriesList =
mergeCategoriesLists(localSyncData.backup?.backupCategories, remoteSyncData.backup?.backupCategories) mergeCategoriesLists(localSyncData.backup?.backupCategories, remoteSyncData.backup?.backupCategories)
val mergedMangaList = mergeMangaLists( val mergedMangaList =
localSyncData.backup?.backupManga, mergeMangaLists(
remoteSyncData.backup?.backupManga, localSyncData.backup?.backupManga,
localSyncData.backup?.backupCategories ?: emptyList(), remoteSyncData.backup?.backupManga,
remoteSyncData.backup?.backupCategories ?: emptyList(), localSyncData.backup?.backupCategories ?: emptyList(),
mergedCategoriesList, remoteSyncData.backup?.backupCategories ?: emptyList(),
) mergedCategoriesList,
)
val mergedSourcesList = val mergedSourcesList =
mergeSourcesLists(localSyncData.backup?.backupSources, remoteSyncData.backup?.backupSources) mergeSourcesLists(localSyncData.backup?.backupSources, remoteSyncData.backup?.backupSources)
// Create the merged Backup object // Create the merged Backup object
val mergedBackup = Backup( val mergedBackup =
backupManga = mergedMangaList, Backup(
backupCategories = mergedCategoriesList, backupManga = mergedMangaList,
backupSources = mergedSourcesList, backupCategories = mergedCategoriesList,
meta = emptyMap(), backupSources = mergedSourcesList,
serverSettings = null, meta = emptyMap(),
) serverSettings = null,
)
// Create the merged SData object // Create the merged SData object
return SyncData( return SyncData(
@@ -201,9 +220,8 @@ object SyncYomiSyncService {
logger.debug { "Starting merge. Local list size: ${localMangaListSafe.size}, Remote list size: ${remoteMangaListSafe.size}" } logger.debug { "Starting merge. Local list size: ${localMangaListSafe.size}, Remote list size: ${remoteMangaListSafe.size}" }
fun mangaCompositeKey(manga: BackupManga): String { fun mangaCompositeKey(manga: BackupManga): String =
return "${manga.source}|${manga.url}|${manga.title.lowercase().trim()}|${manga.author?.lowercase()?.trim()}" "${manga.source}|${manga.url}|${manga.title.lowercase().trim()}|${manga.author?.lowercase()?.trim()}"
}
// Create maps using composite keys // Create maps using composite keys
val localMangaMap = localMangaListSafe.associateBy { mangaCompositeKey(it) } val localMangaMap = localMangaListSafe.associateBy { mangaCompositeKey(it) }
@@ -213,51 +231,65 @@ object SyncYomiSyncService {
val remoteCategoriesMapByOrder = remoteCategories.associateBy { it.order } val remoteCategoriesMapByOrder = remoteCategories.associateBy { it.order }
val mergedCategoriesMapByName = mergedCategories.associateBy { it.name } val mergedCategoriesMapByName = mergedCategories.associateBy { it.name }
fun updateCategories(theManga: BackupManga, theMap: Map<Int, BackupCategory>): BackupManga { fun updateCategories(
return theManga.copy( theManga: BackupManga,
categories = theManga.categories.mapNotNull { theMap: Map<Int, BackupCategory>,
theMap[it]?.let { category -> ): BackupManga =
mergedCategoriesMapByName[category.name]?.order theManga.copy(
} categories =
}, theManga.categories.mapNotNull {
theMap[it]?.let { category ->
mergedCategoriesMapByName[category.name]?.order
}
},
) )
}
logger.debug { "Starting merge. Local list size: ${localMangaListSafe.size}, Remote list size: ${remoteMangaListSafe.size}" } logger.debug { "Starting merge. Local list size: ${localMangaListSafe.size}, Remote list size: ${remoteMangaListSafe.size}" }
val mergedList = (localMangaMap.keys + remoteMangaMap.keys).distinct().mapNotNull { compositeKey -> val mergedList =
val local = localMangaMap[compositeKey] (localMangaMap.keys + remoteMangaMap.keys).distinct().mapNotNull { compositeKey ->
val remote = remoteMangaMap[compositeKey] val local = localMangaMap[compositeKey]
val remote = remoteMangaMap[compositeKey]
// New version comparison logic // New version comparison logic
when { when {
local != null && remote == null -> updateCategories(local, localCategoriesMapByOrder) local != null && remote == null -> {
local == null && remote != null -> updateCategories(remote, remoteCategoriesMapByOrder) updateCategories(local, localCategoriesMapByOrder)
local != null && remote != null -> {
// Compare versions to decide which manga to keep
if (local.version >= remote.version) {
logger.debug { "Keeping local version of ${local.title} with merged chapters." }
updateCategories(
local.copy(chapters = mergeChapters(local.chapters, remote.chapters)),
localCategoriesMapByOrder,
)
} else {
logger.debug { "Keeping remote version of ${remote.title} with merged chapters." }
updateCategories(
remote.copy(chapters = mergeChapters(local.chapters, remote.chapters)),
remoteCategoriesMapByOrder,
)
} }
}
else -> null // No manga found for key local == null && remote != null -> {
updateCategories(remote, remoteCategoriesMapByOrder)
}
local != null && remote != null -> {
// Compare versions to decide which manga to keep
if (local.version >= remote.version) {
logger.debug { "Keeping local version of ${local.title} with merged chapters." }
updateCategories(
local.copy(chapters = mergeChapters(local.chapters, remote.chapters)),
localCategoriesMapByOrder,
)
} else {
logger.debug { "Keeping remote version of ${remote.title} with merged chapters." }
updateCategories(
remote.copy(chapters = mergeChapters(local.chapters, remote.chapters)),
remoteCategoriesMapByOrder,
)
}
}
else -> {
null
} // No manga found for key
}
} }
}
// Counting favorites and non-favorites // Counting favorites and non-favorites
val (favorites, nonFavorites) = mergedList.partition { it.favorite } val (favorites, nonFavorites) = mergedList.partition { it.favorite }
logger.debug { "Merge completed. Total merged manga: ${mergedList.size}, Favorites: ${favorites.size}, Non-Favorites: ${nonFavorites.size}" } logger.debug {
"Merge completed. Total merged manga: ${mergedList.size}, Favorites: ${favorites.size}, Non-Favorites: ${nonFavorites.size}"
}
return mergedList return mergedList
} }
@@ -266,9 +298,7 @@ object SyncYomiSyncService {
localChapters: List<BackupChapter>, localChapters: List<BackupChapter>,
remoteChapters: List<BackupChapter>, remoteChapters: List<BackupChapter>,
): List<BackupChapter> { ): List<BackupChapter> {
fun chapterCompositeKey(chapter: BackupChapter): String { fun chapterCompositeKey(chapter: BackupChapter): String = "${chapter.url}|${chapter.name}|${chapter.chapterNumber}"
return "${chapter.url}|${chapter.name}|${chapter.chapterNumber}"
}
val localChapterMap = localChapters.associateBy { chapterCompositeKey(it) } val localChapterMap = localChapters.associateBy { chapterCompositeKey(it) }
val remoteChapterMap = remoteChapters.associateBy { chapterCompositeKey(it) } val remoteChapterMap = remoteChapters.associateBy { chapterCompositeKey(it) }
@@ -276,45 +306,51 @@ object SyncYomiSyncService {
logger.debug { "Starting chapter merge. Local chapters: ${localChapters.size}, Remote chapters: ${remoteChapters.size}" } logger.debug { "Starting chapter merge. Local chapters: ${localChapters.size}, Remote chapters: ${remoteChapters.size}" }
// Merge both chapter maps based on version numbers // Merge both chapter maps based on version numbers
val mergedChapters = (localChapterMap.keys + remoteChapterMap.keys).distinct().mapNotNull { compositeKey -> val mergedChapters =
val localChapter = localChapterMap[compositeKey] (localChapterMap.keys + remoteChapterMap.keys).distinct().mapNotNull { compositeKey ->
val remoteChapter = remoteChapterMap[compositeKey] val localChapter = localChapterMap[compositeKey]
val remoteChapter = remoteChapterMap[compositeKey]
logger.debug { "Processing chapter key: $compositeKey. Local chapter: ${localChapter != null}, Remote chapter: ${remoteChapter != null}" } logger.debug {
"Processing chapter key: $compositeKey. Local chapter: ${localChapter != null}, Remote chapter: ${remoteChapter != null}"
when {
localChapter != null && remoteChapter == null -> {
logger.debug { "Keeping local chapter: ${localChapter.name}." }
localChapter
} }
localChapter == null && remoteChapter != null -> { when {
logger.debug { "Taking remote chapter: ${remoteChapter.name}." } localChapter != null && remoteChapter == null -> {
remoteChapter logger.debug { "Keeping local chapter: ${localChapter.name}." }
} localChapter
}
localChapter != null && remoteChapter != null -> { localChapter == null && remoteChapter != null -> {
// Use version number to decide which chapter to keep logger.debug { "Taking remote chapter: ${remoteChapter.name}." }
val chosenChapter = if (localChapter.version >= remoteChapter.version) {
// If there mare more chapter on remote, local sourceOrder will need to be updated to maintain correct source order.
if (localChapters.size < remoteChapters.size) {
localChapter.copy(sourceOrder = remoteChapter.sourceOrder)
} else {
localChapter
}
} else {
remoteChapter remoteChapter
} }
logger.debug { "Merging chapter: ${chosenChapter.name}. Chosen version from: ${if (localChapter.version >= remoteChapter.version) "Local" else "Remote"}, Local version: ${localChapter.version}, Remote version: ${remoteChapter.version}." }
chosenChapter
}
else -> { localChapter != null && remoteChapter != null -> {
logger.debug { "No chapter found for composite key: $compositeKey. Skipping." } // Use version number to decide which chapter to keep
null val chosenChapter =
if (localChapter.version >= remoteChapter.version) {
// If there mare more chapter on remote, local sourceOrder will need to be updated to maintain correct source order.
if (localChapters.size < remoteChapters.size) {
localChapter.copy(sourceOrder = remoteChapter.sourceOrder)
} else {
localChapter
}
} else {
remoteChapter
}
logger.debug {
"Merging chapter: ${chosenChapter.name}. Chosen version from: ${if (localChapter.version >= remoteChapter.version) "Local" else "Remote"}, Local version: ${localChapter.version}, Remote version: ${remoteChapter.version}."
}
chosenChapter
}
else -> {
logger.debug { "No chapter found for composite key: $compositeKey. Skipping." }
null
}
} }
} }
}
logger.debug { "Chapter merge completed. Total merged chapters: ${mergedChapters.size}" } logger.debug { "Chapter merge completed. Total merged chapters: ${mergedChapters.size}" }
@@ -336,11 +372,12 @@ object SyncYomiSyncService {
val remoteCategory = remoteCategoriesMap[name] val remoteCategory = remoteCategoriesMap[name]
if (remoteCategory != null) { if (remoteCategory != null) {
// Compare and merge local and remote categories // Compare and merge local and remote categories
val mergedCategory = if (localCategory.order > remoteCategory.order) { val mergedCategory =
localCategory if (localCategory.order > remoteCategory.order) {
} else { localCategory
remoteCategory } else {
} remoteCategory
}
mergedCategoriesMap[name] = mergedCategory mergedCategoriesMap[name] = mergedCategory
} else { } else {
// If the category is only in the local list, add it to the merged list // If the category is only in the local list, add it to the merged list
@@ -369,29 +406,32 @@ object SyncYomiSyncService {
logger.debug { "Starting source merge. Local sources: ${localSources?.size}, Remote sources: ${remoteSources?.size}" } logger.debug { "Starting source merge. Local sources: ${localSources?.size}, Remote sources: ${remoteSources?.size}" }
// Merge both source maps // Merge both source maps
val mergedSources = (localSourceMap.keys + remoteSourceMap.keys).distinct().mapNotNull { sourceId -> val mergedSources =
val localSource = localSourceMap[sourceId] (localSourceMap.keys + remoteSourceMap.keys).distinct().mapNotNull { sourceId ->
val remoteSource = remoteSourceMap[sourceId] val localSource = localSourceMap[sourceId]
val remoteSource = remoteSourceMap[sourceId]
logger.debug { "Processing source ID: $sourceId. Local source: ${localSource != null}, Remote source: ${remoteSource != null}" } logger.debug {
"Processing source ID: $sourceId. Local source: ${localSource != null}, Remote source: ${remoteSource != null}"
when {
localSource != null && remoteSource == null -> {
logger.debug { "Using local source: ${localSource.name}." }
localSource
} }
remoteSource != null && localSource == null -> { when {
logger.debug { "Using remote source: ${remoteSource.name}." } localSource != null && remoteSource == null -> {
remoteSource logger.debug { "Using local source: ${localSource.name}." }
} localSource
}
else -> { remoteSource != null && localSource == null -> {
logger.debug { "Remote and local is not empty: $sourceId. Skipping." } logger.debug { "Using remote source: ${remoteSource.name}." }
null remoteSource
}
else -> {
logger.debug { "Remote and local is not empty: $sourceId. Skipping." }
null
}
} }
} }
}
logger.debug { "Source merge completed. Total merged sources: ${mergedSources.size}" } logger.debug { "Source merge completed. Total merged sources: ${mergedSources.size}" }

View File

@@ -1,3 +1,5 @@
@file:Suppress("ktlint:standard:filename")
package suwayomi.tachidesk.graphql.types package suwayomi.tachidesk.graphql.types
enum class StartSyncResult { enum class StartSyncResult {

View File

@@ -165,8 +165,7 @@ object ProtoBackupImport : ProtoBackupBase() {
.source() .source()
.run { .run {
if (!isSync) gzip() else this if (!isSync) gzip() else this
} }.buffer()
.buffer()
.use { it.readByteArray() } .use { it.readByteArray() }
val backup = parser.decodeFromByteArray(Backup.serializer(), backupString) val backup = parser.decodeFromByteArray(Backup.serializer(), backupString)
@@ -246,10 +245,10 @@ object ProtoBackupImport : ProtoBackupBase() {
if (isSync) { if (isSync) {
transaction { transaction {
MangaTable.update({ MangaTable.isSyncing eq true}) { MangaTable.update({ MangaTable.isSyncing eq true }) {
it[isSyncing] = false it[isSyncing] = false
} }
ChapterTable.update({ ChapterTable.isSyncing eq true}) { ChapterTable.update({ ChapterTable.isSyncing eq true }) {
it[isSyncing] = false it[isSyncing] = false
} }
} }

View File

@@ -109,7 +109,7 @@ object BackupMangaHandler {
it.uploadDate, it.uploadDate,
it.chapterNumber, it.chapterNumber,
chapters.size - it.index, chapters.size - it.index,
it.version it.version,
).apply { ).apply {
if (flags.includeClientData) { if (flags.includeClientData) {
this.meta = chapterToMeta[it.id] ?: emptyMap() this.meta = chapterToMeta[it.id] ?: emptyMap()

View File

@@ -6,12 +6,14 @@ import suwayomi.tachidesk.server.serverConfig
@Suppress("ClassName", "unused") @Suppress("ClassName", "unused")
class M0053_SyncYomi : SQLMigration() { class M0053_SyncYomi : SQLMigration() {
override val sql = when (serverConfig.databaseType.value) { override val sql =
DatabaseType.POSTGRESQL -> postgresQuery() when (serverConfig.databaseType.value) {
DatabaseType.H2 -> h2Query() DatabaseType.POSTGRESQL -> postgresQuery()
} DatabaseType.H2 -> h2Query()
}
fun postgresQuery(): String = """ fun postgresQuery(): String =
"""
ALTER TABLE manga ADD COLUMN version BIGINT DEFAULT 0; ALTER TABLE manga ADD COLUMN version BIGINT DEFAULT 0;
ALTER TABLE manga ADD COLUMN is_syncing BOOLEAN DEFAULT FALSE; ALTER TABLE manga ADD COLUMN is_syncing BOOLEAN DEFAULT FALSE;
@@ -75,9 +77,10 @@ class M0053_SyncYomi : SQLMigration() {
AFTER INSERT ON categorymanga AFTER INSERT ON categorymanga
FOR EACH ROW FOR EACH ROW
EXECUTE FUNCTION insert_manga_category_update_version(); EXECUTE FUNCTION insert_manga_category_update_version();
""".trimIndent() """.trimIndent()
fun h2Query() = """ fun h2Query() =
"""
ALTER TABLE manga ADD COLUMN version BIGINT DEFAULT 0; ALTER TABLE manga ADD COLUMN version BIGINT DEFAULT 0;
ALTER TABLE manga ADD COLUMN is_syncing BOOLEAN DEFAULT FALSE; ALTER TABLE manga ADD COLUMN is_syncing BOOLEAN DEFAULT FALSE;
@@ -98,5 +101,5 @@ class M0053_SyncYomi : SQLMigration() {
AFTER INSERT ON categorymanga AFTER INSERT ON categorymanga
FOR EACH ROW FOR EACH ROW
CALL "suwayomi.tachidesk.server.database.trigger.InsertMangaCategoryUpdateVersionTrigger"; CALL "suwayomi.tachidesk.server.database.trigger.InsertMangaCategoryUpdateVersionTrigger";
""".trimIndent() """.trimIndent()
} }

View File

@@ -12,19 +12,21 @@ class UpdateMangaVersionTrigger : TriggerAdapter() {
newRow: ResultSet, newRow: ResultSet,
) { ) {
val isSyncing = newRow.getBoolean("is_syncing") val isSyncing = newRow.getBoolean("is_syncing")
val hasChanged = oldRow.getString("url") != newRow.getString("url") || val hasChanged =
oldRow.getString("description") != newRow.getString("description") || oldRow.getString("url") != newRow.getString("url") ||
oldRow.getBoolean("in_library") != newRow.getBoolean("in_library") oldRow.getString("description") != newRow.getString("description") ||
oldRow.getBoolean("in_library") != newRow.getBoolean("in_library")
if (!isSyncing && hasChanged) { if (!isSyncing && hasChanged) {
val id = newRow.getInt("id") val id = newRow.getInt("id")
conn.prepareStatement( conn
"UPDATE MANGA SET version = version + 1 WHERE id = ?", .prepareStatement(
).use { "UPDATE MANGA SET version = version + 1 WHERE id = ?",
it.setInt(1, id) ).use {
it.executeUpdate() it.setInt(1, id)
} it.executeUpdate()
}
} }
} }
} }
@@ -37,28 +39,31 @@ class UpdateChapterAndMangaVersionTrigger : TriggerAdapter() {
newRow: ResultSet, newRow: ResultSet,
) { ) {
val isSyncing = newRow.getBoolean("is_syncing") val isSyncing = newRow.getBoolean("is_syncing")
val hasChanged = oldRow.getBoolean("read") != newRow.getBoolean("read") || val hasChanged =
oldRow.getBoolean("bookmark") != newRow.getBoolean("bookmark") || oldRow.getBoolean("read") != newRow.getBoolean("read") ||
oldRow.getInt("last_page_read") != newRow.getInt("last_page_read") oldRow.getBoolean("bookmark") != newRow.getBoolean("bookmark") ||
oldRow.getInt("last_page_read") != newRow.getInt("last_page_read")
if (!isSyncing && hasChanged) { if (!isSyncing && hasChanged) {
val chapterId = newRow.getInt("id") val chapterId = newRow.getInt("id")
val mangaId = newRow.getInt("manga") val mangaId = newRow.getInt("manga")
conn.prepareStatement( conn
"UPDATE CHAPTER SET version = version + 1 WHERE id = ?", .prepareStatement(
).use { "UPDATE CHAPTER SET version = version + 1 WHERE id = ?",
it.setInt(1, chapterId) ).use {
it.executeUpdate() it.setInt(1, chapterId)
} it.executeUpdate()
}
conn.prepareStatement( conn
"UPDATE MANGA SET version = version + 1 WHERE id = ? AND (SELECT is_syncing FROM MANGA WHERE id = ?) = FALSE", .prepareStatement(
).use { "UPDATE MANGA SET version = version + 1 WHERE id = ? AND (SELECT is_syncing FROM MANGA WHERE id = ?) = FALSE",
it.setInt(1, mangaId) ).use {
it.setInt(2, mangaId) it.setInt(1, mangaId)
it.executeUpdate() it.setInt(2, mangaId)
} it.executeUpdate()
}
} }
} }
} }
@@ -72,13 +77,13 @@ class InsertMangaCategoryUpdateVersionTrigger : TriggerAdapter() {
) { ) {
val mangaId = newRow.getInt("manga") val mangaId = newRow.getInt("manga")
conn.prepareStatement( conn
"UPDATE MANGA SET version = version + 1 WHERE id = ? AND (SELECT is_syncing FROM MANGA WHERE id = ?) = FALSE", .prepareStatement(
).use { "UPDATE MANGA SET version = version + 1 WHERE id = ? AND (SELECT is_syncing FROM MANGA WHERE id = ?) = FALSE",
it.setInt(1, mangaId) ).use {
it.setInt(2, mangaId) it.setInt(1, mangaId)
it.executeUpdate() it.setInt(2, mangaId)
} it.executeUpdate()
}
} }
} }