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