mirror of
				https://github.com/mihonapp/mihon.git
				synced 2025-11-04 08:08:55 +01:00 
			
		
		
		
	feat: syncyomi uses same internal logic as well now.
TODO: refactor them as the code is duplicated for now.
This commit is contained in:
		@@ -2,8 +2,12 @@ package eu.kanade.tachiyomi.data.sync.service
 | 
			
		||||
 | 
			
		||||
import android.content.Context
 | 
			
		||||
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.models.SyncData
 | 
			
		||||
import eu.kanade.tachiyomi.network.GET
 | 
			
		||||
import eu.kanade.tachiyomi.network.POST
 | 
			
		||||
import kotlinx.serialization.encodeToString
 | 
			
		||||
import kotlinx.serialization.json.Json
 | 
			
		||||
@@ -11,10 +15,12 @@ import logcat.LogPriority
 | 
			
		||||
import okhttp3.Headers
 | 
			
		||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
 | 
			
		||||
import okhttp3.OkHttpClient
 | 
			
		||||
import okhttp3.RequestBody
 | 
			
		||||
import okhttp3.RequestBody.Companion.gzip
 | 
			
		||||
import okhttp3.RequestBody.Companion.toRequestBody
 | 
			
		||||
import tachiyomi.core.util.system.logcat
 | 
			
		||||
import tachiyomi.domain.sync.SyncPreferences
 | 
			
		||||
import java.time.Instant
 | 
			
		||||
 | 
			
		||||
class SyncYomiSyncService(
 | 
			
		||||
    context: Context,
 | 
			
		||||
@@ -29,43 +35,247 @@ class SyncYomiSyncService(
 | 
			
		||||
 | 
			
		||||
        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 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(
 | 
			
		||||
            url = url,
 | 
			
		||||
        val finalSyncData =
 | 
			
		||||
            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,
 | 
			
		||||
            body = body,
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        client.newCall(request).execute().use { response ->
 | 
			
		||||
        client.newCall(downloadRequest).execute().use { response ->
 | 
			
		||||
            val responseBody = response.body.string()
 | 
			
		||||
 | 
			
		||||
            if (response.isSuccessful) {
 | 
			
		||||
                val syncDataResponse: SyncData = json.decodeFromString(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)
 | 
			
		||||
                return json.decodeFromString<SyncData>(responseBody)
 | 
			
		||||
            } else {
 | 
			
		||||
                notifier.showSyncError("Failed to sync: $responseBody")
 | 
			
		||||
                notifier.showSyncError("Failed to download sync data: $responseBody")
 | 
			
		||||
                responseBody.let { logcat(LogPriority.ERROR) { "SyncError:$it" } }
 | 
			
		||||
                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()
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user