feat: Add sync events to SyncYomi (jobobby04/TachiyomiSY#1558)

* feat: Add sync events to SyncYomi

Now it will send the events back to `SyncYomi` server and then forward those to the notifications services that are enabled, such as discord, telegram, and etc.

* chore: fix build error.

(cherry picked from commit 2776e411272499eba2f91acfea3cb40dc0d4c622)

* Improve client management and response handling

---------

Co-authored-by: Cuong-Tran <16017808+cuong-tran@users.noreply.github.com>
This commit is contained in:
KaiserBh 2026-02-28 03:20:59 +11:00 committed by Cuong-Tran
parent bad1dd264d
commit 38acd6f110

View file

@ -5,8 +5,14 @@ import eu.kanade.domain.sync.SyncPreferences
import eu.kanade.tachiyomi.data.backup.models.Backup
import eu.kanade.tachiyomi.data.sync.SyncNotifier
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.PUT
import eu.kanade.tachiyomi.network.await
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.withContext
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import kotlinx.serialization.protobuf.ProtoBuf
@ -21,7 +27,6 @@ import tachiyomi.core.common.i18n.stringResource
import tachiyomi.i18n.MR
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.util.concurrent.TimeUnit
class SyncYomiSyncService(
context: Context,
@ -32,9 +37,34 @@ class SyncYomiSyncService(
private val protoBuf: ProtoBuf = Injekt.get(),
) : SyncService(context, json, syncPreferences) {
// KMK -->
companion object {
private val client by lazy { OkHttpClient() }
}
// KMK <--
private class SyncYomiException(message: String?) : Exception(message)
@Serializable
private data class SyncEvent(
val event: SyncEventStatus,
@SerialName("device_name")
val deviceName: String? = null,
val message: String? = null,
)
@Serializable
private enum class SyncEventStatus {
SYNC_STARTED,
SYNC_SUCCESS,
SYNC_FAILED,
SYNC_ERROR,
SYNC_CANCELLED,
}
override suspend fun doSync(syncData: SyncData): Backup? {
reportSyncEvent(SyncEventStatus.SYNC_STARTED)
try {
val (remoteData, etag) = pullSyncData()
@ -52,11 +82,26 @@ class SyncYomiSyncService(
syncData
}
pushSyncData(finalSyncData, etag)
val success = pushSyncData(finalSyncData, etag)
if (success) {
reportSyncEvent(SyncEventStatus.SYNC_SUCCESS)
} else {
reportSyncEvent(SyncEventStatus.SYNC_FAILED, "Failed to push sync data")
// KMK -->
return null
// KMK <--
}
return finalSyncData.backup
} catch (e: Exception) {
if (e is CancellationException) {
reportSyncEvent(SyncEventStatus.SYNC_CANCELLED, e.message)
throw e
}
logcat(LogPriority.ERROR) { "Error syncing: ${e.message}" }
notifier.showSyncError(e.message)
reportSyncEvent(SyncEventStatus.SYNC_ERROR, e.message)
return null
}
}
@ -78,7 +123,6 @@ class SyncYomiSyncService(
headers = headers,
)
val client = OkHttpClient()
val response = client.newCall(downloadRequest).await()
if (response.code == HttpStatus.SC_NOT_MODIFIED) {
@ -123,13 +167,12 @@ class SyncYomiSyncService(
/**
* Return true if update success
*/
private suspend fun pushSyncData(syncData: SyncData, eTag: String) {
val backup = syncData.backup ?: return
private suspend fun pushSyncData(syncData: SyncData, eTag: String): Boolean {
val backup = syncData.backup ?: return true
val host = syncPreferences.clientHost().get()
val apiKey = syncPreferences.clientAPIKey().get()
val uploadUrl = "$host/api/sync/content"
val timeout = 30L
val headersBuilder = Headers.Builder().add("X-API-Token", apiKey)
if (eTag.isNotEmpty()) {
@ -137,13 +180,6 @@ class SyncYomiSyncService(
}
val headers = headersBuilder.build()
// Set timeout to 30 seconds
val client = OkHttpClient.Builder()
.connectTimeout(timeout, TimeUnit.SECONDS)
.readTimeout(timeout, TimeUnit.SECONDS)
.writeTimeout(timeout, TimeUnit.SECONDS)
.build()
val byteArray = protoBuf.encodeToByteArray(Backup.serializer(), backup)
if (byteArray.isEmpty()) {
throw IllegalStateException(context.stringResource(MR.strings.empty_backup_error))
@ -156,20 +192,58 @@ class SyncYomiSyncService(
body = body,
)
val response = client.newCall(uploadRequest).await()
client.newCall(uploadRequest).await()
// KMK -->
.use { response ->
// KMK <--
if (response.isSuccessful) {
val newETag = response.headers["ETag"]
.takeIf { it?.isNotEmpty() == true } ?: throw SyncYomiException("Missing ETag")
syncPreferences.lastSyncEtag().set(newETag)
logcat(LogPriority.DEBUG) { "SyncYomi sync completed" }
return true
} else if (response.code == HttpStatus.SC_PRECONDITION_FAILED) {
// other clients updated remote data, will try next time
logcat(LogPriority.DEBUG) { "SyncYomi sync failed with 412" }
return false
} else {
val responseBody = response.body.string()
notifier.showSyncError("Failed to upload sync data: $responseBody")
logcat(LogPriority.ERROR) { "SyncError: $responseBody" }
return false
}
}
}
private suspend fun reportSyncEvent(event: SyncEventStatus, message: String? = null) {
withContext(NonCancellable) {
try {
val host = syncPreferences.clientHost().get()
val apiKey = syncPreferences.clientAPIKey().get()
val url = "$host/api/sync/event"
val headersBuilder = Headers.Builder().add("X-API-Token", apiKey)
val headers = headersBuilder.build()
val bodyObj = SyncEvent(
event = event,
deviceName = android.os.Build.MODEL,
message = message,
)
val jsonBody = json.encodeToString(SyncEvent.serializer(), bodyObj)
val requestBody = jsonBody.toRequestBody("application/json; charset=utf-8".toMediaType())
val request = POST(
url = url,
headers = headers,
body = requestBody,
)
client.newCall(request).await().close()
} catch (e: Exception) {
logcat(LogPriority.ERROR) { "Failed to report sync event: ${e.message}" }
}
}
}
}