feat: syncyomi uses same internal logic as well now.

TODO: refactor them as the code is duplicated for now.
This commit is contained in:
KaiserBh 2023-07-07 15:52:26 +10:00 committed by Aria Moradi
parent 877bf721e3
commit 878f7097d1

View File

@ -2,8 +2,12 @@ package eu.kanade.tachiyomi.data.sync.service
import android.content.Context import android.content.Context
import eu.kanade.tachiyomi.data.backup.models.Backup import eu.kanade.tachiyomi.data.backup.models.Backup
import eu.kanade.tachiyomi.data.backup.models.BackupCategory
import eu.kanade.tachiyomi.data.backup.models.BackupChapter
import eu.kanade.tachiyomi.data.backup.models.BackupManga
import eu.kanade.tachiyomi.data.sync.SyncNotifier import eu.kanade.tachiyomi.data.sync.SyncNotifier
import eu.kanade.tachiyomi.data.sync.models.SyncData import eu.kanade.tachiyomi.data.sync.models.SyncData
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST import eu.kanade.tachiyomi.network.POST
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
@ -11,10 +15,12 @@ import logcat.LogPriority
import okhttp3.Headers import okhttp3.Headers
import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.gzip import okhttp3.RequestBody.Companion.gzip
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import tachiyomi.core.util.system.logcat import tachiyomi.core.util.system.logcat
import tachiyomi.domain.sync.SyncPreferences import tachiyomi.domain.sync.SyncPreferences
import java.time.Instant
class SyncYomiSyncService( class SyncYomiSyncService(
context: Context, context: Context,
@ -29,43 +35,247 @@ class SyncYomiSyncService(
val jsonData = json.encodeToString(syncData) val jsonData = json.encodeToString(syncData)
val host = syncPreferences.syncHost().get()
val apiKey = syncPreferences.syncAPIKey().get()
val url = "$host/api/sync/data"
val client = OkHttpClient()
val mediaType = "application/gzip".toMediaTypeOrNull() val mediaType = "application/gzip".toMediaTypeOrNull()
val body = jsonData.toRequestBody(mediaType).gzip() val body = jsonData.toRequestBody(mediaType).gzip()
val headers = Headers.Builder().add("Content-Type", "application/gzip").add("Content-Encoding", "gzip").add("X-API-Token", apiKey).build() val remoteSyncData = downloadSyncData()
val request = POST( val finalSyncData =
url = url, if (remoteSyncData?.backup == null) {
uploadSyncData(body)
syncData
} else {
val mergedSyncData = mergeSyncData(syncData, remoteSyncData)
val encodeMergedData = json.encodeToString(mergedSyncData)
uploadSyncData(encodeMergedData.toRequestBody(mediaType).gzip())
mergedSyncData
}
return finalSyncData.backup
}
suspend fun downloadSyncData(): SyncData? {
val host = syncPreferences.syncHost().get()
val apiKey = syncPreferences.syncAPIKey().get()
val deviceId = syncPreferences.deviceID().get()
val downloadUrl = "$host/api/sync/download?deviceId=$deviceId"
val client = OkHttpClient()
val headers = Headers.Builder().add("X-API-Token", apiKey).build()
val downloadRequest = GET(
url = downloadUrl,
headers = headers, headers = headers,
body = body,
) )
client.newCall(request).execute().use { response -> client.newCall(downloadRequest).execute().use { response ->
val responseBody = response.body.string() val responseBody = response.body.string()
if (response.isSuccessful) { if (response.isSuccessful) {
val syncDataResponse: SyncData = json.decodeFromString(responseBody) return json.decodeFromString<SyncData>(responseBody)
// If the device ID is 0 and not equal to the server device ID (this happens when the DB is fresh and the app is not), update it
if (syncPreferences.deviceID().get() == 0 || syncPreferences.deviceID().get() != syncDataResponse.device?.id) {
syncDataResponse.device?.id?.let { syncPreferences.deviceID().set(it) }
}
logcat(
LogPriority.DEBUG,
) { "SyncYomi sync completed!" }
return decodeSyncBackup(responseBody)
} else { } else {
notifier.showSyncError("Failed to sync: $responseBody") notifier.showSyncError("Failed to download sync data: $responseBody")
responseBody.let { logcat(LogPriority.ERROR) { "SyncError:$it" } } responseBody.let { logcat(LogPriority.ERROR) { "SyncError:$it" } }
return null return null
} }
} }
} }
suspend fun uploadSyncData(body: RequestBody) {
val host = syncPreferences.syncHost().get()
val apiKey = syncPreferences.syncAPIKey().get()
val uploadUrl = "$host/api/sync/upload"
val client = OkHttpClient()
val headers = Headers.Builder().add("Content-Type", "application/gzip").add("Content-Encoding", "gzip").add("X-API-Token", apiKey).build()
val uploadRequest = POST(
url = uploadUrl,
headers = headers,
body = body,
)
client.newCall(uploadRequest).execute().use {
if (it.isSuccessful) {
logcat(
LogPriority.DEBUG,
) { "SyncYomi sync completed!" }
} else {
val responseBody = it.body.string()
notifier.showSyncError("Failed to upload sync data: $responseBody")
responseBody.let { logcat(LogPriority.ERROR) { "SyncError:$it" } }
}
}
}
/**
* Merges the local and remote sync data into a single JSON string.
*
* @param localSyncData The SyncData containing the local sync data.
* @param remoteSyncData The SyncData containing the remote sync data.
* @return The JSON string containing the merged sync data.
*/
fun mergeSyncData(localSyncData: SyncData, remoteSyncData: SyncData): SyncData {
val mergedMangaList = mergeMangaLists(localSyncData.backup?.backupManga, remoteSyncData.backup?.backupManga)
val mergedCategoriesList = mergeCategoriesLists(localSyncData.backup?.backupCategories, remoteSyncData.backup?.backupCategories)
// Create the merged Backup object
val mergedBackup = Backup(
backupManga = mergedMangaList,
backupCategories = mergedCategoriesList,
backupBrokenSources = localSyncData.backup?.backupBrokenSources ?: emptyList(),
backupSources = localSyncData.backup?.backupSources ?: emptyList(),
)
// Create the merged SyncData object
return SyncData(
sync = localSyncData.sync, // always use the local sync info
backup = mergedBackup,
device = localSyncData.device, // always use the local device info
)
}
/**
* Merges two lists of SyncManga objects, prioritizing the manga with the most recent lastModifiedAt value.
* If lastModifiedAt is null, the function defaults to Instant.MIN for comparison purposes.
*
* @param localMangaList The list of local SyncManga objects.
* @param remoteMangaList The list of remote SyncManga objects.
* @return The merged list of SyncManga objects.
*/
private fun mergeMangaLists(localMangaList: List<BackupManga>?, remoteMangaList: List<BackupManga>?): List<BackupManga> {
if (localMangaList == null) return remoteMangaList ?: emptyList()
if (remoteMangaList == null) return localMangaList
val localMangaMap = localMangaList.associateBy { Pair(it.source, it.url) }
val remoteMangaMap = remoteMangaList.associateBy { Pair(it.source, it.url) }
val mergedMangaMap = mutableMapOf<Pair<Long, String>, BackupManga>()
localMangaMap.forEach { (key, localManga) ->
val remoteManga = remoteMangaMap[key]
if (remoteManga != null) {
val localInstant = localManga.lastModifiedAt?.let { Instant.ofEpochMilli(it) }
val remoteInstant = remoteManga.lastModifiedAt?.let { Instant.ofEpochMilli(it) }
val mergedManga = if ((localInstant ?: Instant.MIN) >= (
remoteInstant
?: Instant.MIN
)
) {
localManga
} else {
remoteManga
}
val localChapters = localManga.chapters
val remoteChapters = remoteManga.chapters
val mergedChapters = mergeChapters(localChapters, remoteChapters)
val isFavorite = if ((localInstant ?: Instant.MIN) >= (
remoteInstant
?: Instant.MIN
)
) {
localManga.favorite
} else {
remoteManga.favorite
}
mergedMangaMap[key] = mergedManga.copy(chapters = mergedChapters, favorite = isFavorite)
} else {
mergedMangaMap[key] = localManga
}
}
remoteMangaMap.forEach { (key, remoteManga) ->
if (!mergedMangaMap.containsKey(key)) {
mergedMangaMap[key] = remoteManga
}
}
return mergedMangaMap.values.toList()
}
/**
* Merges two lists of SyncChapter objects, prioritizing the chapter with the most recent lastModifiedAt value.
* If lastModifiedAt is null, the function defaults to Instant.MIN for comparison purposes.
*
* @param localChapters The list of local SyncChapter objects.
* @param remoteChapters The list of remote SyncChapter objects.
* @return The merged list of SyncChapter objects.
*/
private fun mergeChapters(localChapters: List<BackupChapter>, remoteChapters: List<BackupChapter>): List<BackupChapter> {
val localChapterMap = localChapters.associateBy { it.url }
val remoteChapterMap = remoteChapters.associateBy { it.url }
val mergedChapterMap = mutableMapOf<String, BackupChapter>()
localChapterMap.forEach { (url, localChapter) ->
val remoteChapter = remoteChapterMap[url]
if (remoteChapter != null) {
val localInstant = localChapter.lastModifiedAt?.let { Instant.ofEpochMilli(it) }
val remoteInstant = remoteChapter.lastModifiedAt?.let { Instant.ofEpochMilli(it) }
val mergedChapter =
if ((localInstant ?: Instant.MIN) >= (remoteInstant ?: Instant.MIN)) {
localChapter
} else {
remoteChapter
}
mergedChapterMap[url] = mergedChapter
} else {
mergedChapterMap[url] = localChapter
}
}
remoteChapterMap.forEach { (url, remoteChapter) ->
if (!mergedChapterMap.containsKey(url)) {
mergedChapterMap[url] = remoteChapter
}
}
return mergedChapterMap.values.toList()
}
/**
* Merges two lists of SyncCategory objects, prioritizing the category with the most recent order value.
*
* @param localCategoriesList The list of local SyncCategory objects.
* @param remoteCategoriesList The list of remote SyncCategory objects.
* @return The merged list of SyncCategory objects.
*/
private fun mergeCategoriesLists(localCategoriesList: List<BackupCategory>?, remoteCategoriesList: List<BackupCategory>?): List<BackupCategory> {
if (localCategoriesList == null) return remoteCategoriesList ?: emptyList()
if (remoteCategoriesList == null) return localCategoriesList
val localCategoriesMap = localCategoriesList.associateBy { it.name }
val remoteCategoriesMap = remoteCategoriesList.associateBy { it.name }
val mergedCategoriesMap = mutableMapOf<String, BackupCategory>()
localCategoriesMap.forEach { (name, localCategory) ->
val remoteCategory = remoteCategoriesMap[name]
if (remoteCategory != null) {
// Compare and merge local and remote categories
val mergedCategory = if (localCategory.order >= remoteCategory.order) {
localCategory
} else {
remoteCategory
}
mergedCategoriesMap[name] = mergedCategory
} else {
// If the category is only in the local list, add it to the merged list
mergedCategoriesMap[name] = localCategory
}
}
// Add any categories from the remote list that are not in the local list
remoteCategoriesMap.forEach { (name, remoteCategory) ->
if (!mergedCategoriesMap.containsKey(name)) {
mergedCategoriesMap[name] = remoteCategory
}
}
return mergedCategoriesMap.values.toList()
}
} }