Compare commits

...

3 Commits

Author SHA1 Message Date
renovate[bot]
a65c7d31b0 Update Gradle to v9.6.1 2026-06-26 16:10:38 +00:00
herowinb
c8f5d83e9c add reportSyncEvent for SyncYomi service (#2110)
* add reportSyncEvent

* Update SyncYomiSyncService.kt
2026-06-17 22:46:21 -04:00
Zeedif
be5e3f022e feat(download): improve chapter download filenames (#2100)
* feat(download): improve chapter download filenames

* refactor(download): use SafePath helper for filename sanitization
2026-06-17 22:41:31 -04:00
5 changed files with 141 additions and 27 deletions

View File

@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip
networkTimeout=10000 networkTimeout=10000
retries=0 retries=0
retryBackOffMs=500 retryBackOffMs=500

4
gradlew vendored
View File

@@ -20,7 +20,7 @@
############################################################################## ##############################################################################
# #
# Gradle start up script for POSIX generated by Gradle. # gradlew start up script for POSIX generated by Gradle.
# #
# Important for running: # Important for running:
# #
@@ -29,7 +29,7 @@
# bash, then to run this script, type that shell name before the whole # bash, then to run this script, type that shell name before the whole
# command line, like: # command line, like:
# #
# ksh Gradle # ksh gradlew
# #
# Busybox and similar reduced shells will NOT work, because this script # Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features: # requires all of these POSIX shell features:

4
gradlew.bat vendored
View File

@@ -19,7 +19,7 @@
@if "%DEBUG%"=="" @echo off @if "%DEBUG%"=="" @echo off
@rem ########################################################################## @rem ##########################################################################
@rem @rem
@rem Gradle startup script for Windows @rem gradlew startup script for Windows
@rem @rem
@rem ########################################################################## @rem ##########################################################################
@@ -72,7 +72,7 @@ echo location of your Java installation. 1>&2
@rem Execute Gradle @rem Execute gradlew
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded @rem endlocal doesn't take effect until after the line is parsed and variables are expanded
@rem which allows us to clear the local environment before executing the java command @rem which allows us to clear the local environment before executing the java command
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel

View File

@@ -4,11 +4,15 @@ import android.app.Application
import android.content.Context import android.content.Context
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.NetworkHelper import eu.kanade.tachiyomi.network.NetworkHelper
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 io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import io.javalin.http.HttpStatus import io.javalin.http.HttpStatus
import kotlinx.coroutines.CancellationException
import kotlinx.serialization.Serializable
import kotlinx.serialization.SerializationException import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import kotlinx.serialization.protobuf.ProtoBuf import kotlinx.serialization.protobuf.ProtoBuf
import okhttp3.Headers import okhttp3.Headers
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
@@ -36,31 +40,66 @@ object SyncYomiSyncService {
message: String?, message: String?,
) : Exception(message) ) : Exception(message)
@Serializable
private data class SyncEvent(
val event: SyncEventStatus,
val device_Name: String? = null,
val message: String? = null,
)
@Serializable
private enum class SyncEventStatus {
SYNC_STARTED,
SYNC_SUCCESS,
SYNC_FAILED,
SYNC_ERROR,
SYNC_CANCELLED,
}
suspend fun doSync( suspend fun doSync(
syncData: SyncData, syncData: SyncData,
startDate: Instant, startDate: Instant,
setSyncState: (SyncManager.SyncState) -> Unit, setSyncState: (SyncManager.SyncState) -> Unit,
): Backup? { ): Backup? {
reportSyncEvent(SyncEventStatus.SYNC_STARTED)
setSyncState(SyncManager.SyncState.Downloading(startDate)) setSyncState(SyncManager.SyncState.Downloading(startDate))
val (remoteData, etag) = pullSyncData()
val finalSyncData = return try {
if (remoteData != null) { val (remoteData, etag) = pullSyncData()
require(etag.isNotEmpty()) { "ETag should never be empty if remote data is not null" }
logger.debug { "Try update remote data with ETag($etag)" } val finalSyncData =
setSyncState(SyncManager.SyncState.Merging(startDate)) if (remoteData != null) {
mergeSyncData(syncData, remoteData) require(etag.isNotEmpty()) { "ETag should never be empty if remote data is not null" }
} else { logger.debug { "Try update remote data with ETag($etag)" }
// init or overwrite remote data setSyncState(SyncManager.SyncState.Merging(startDate))
logger.debug { "Try overwrite remote data with ETag($etag)" } mergeSyncData(syncData, remoteData)
syncData } else {
// init or overwrite remote data
logger.debug { "Try overwrite remote data with ETag($etag)" }
syncData
}
if (finalSyncData.backup != null) {
setSyncState(SyncManager.SyncState.Uploading(startDate))
} }
if (finalSyncData.backup != null) { val success = pushSyncData(finalSyncData, etag)
setSyncState(SyncManager.SyncState.Uploading(startDate)) if (success) {
reportSyncEvent(SyncEventStatus.SYNC_SUCCESS)
} else {
reportSyncEvent(SyncEventStatus.SYNC_FAILED, "Failed to push sync data")
}
finalSyncData.backup
} catch (e: Exception) {
if (e is CancellationException) {
reportSyncEvent(SyncEventStatus.SYNC_CANCELLED, e.message)
throw e
}
logger.error { "Error syncing: ${e.message}" }
reportSyncEvent(SyncEventStatus.SYNC_ERROR, e.message)
throw e
} }
pushSyncData(finalSyncData, etag)
return finalSyncData.backup
} }
private suspend fun pullSyncData(): Pair<SyncData?, String> { private suspend fun pullSyncData(): Pair<SyncData?, String> {
@@ -122,8 +161,8 @@ object SyncYomiSyncService {
private suspend fun pushSyncData( private suspend fun pushSyncData(
syncData: SyncData, syncData: SyncData,
eTag: String, eTag: String,
) { ): Boolean {
val backup = syncData.backup ?: return val backup = syncData.backup ?: return true
val host = serverConfig.syncYomiHost.value val host = serverConfig.syncYomiHost.value
val apiKey = serverConfig.syncYomiApiKey.value val apiKey = serverConfig.syncYomiApiKey.value
@@ -160,7 +199,7 @@ object SyncYomiSyncService {
val response = client.newCall(uploadRequest).await() val response = client.newCall(uploadRequest).await()
if (response.isSuccessful) { return if (response.isSuccessful) {
val newETag = val newETag =
response.headers["ETag"] response.headers["ETag"]
?.takeIf { it.isNotEmpty() } ?: throw SyncYomiException("Missing ETag") ?.takeIf { it.isNotEmpty() } ?: throw SyncYomiException("Missing ETag")
@@ -169,12 +208,53 @@ object SyncYomiSyncService {
.putString("last_sync_etag", newETag) .putString("last_sync_etag", newETag)
.apply() .apply()
logger.debug { "SyncYomi sync completed" } logger.debug { "SyncYomi sync completed" }
true
} else if (response.code == HttpStatus.PRECONDITION_FAILED.code) { } else if (response.code == HttpStatus.PRECONDITION_FAILED.code) {
// other clients updated remote data, will try next time // other clients updated remote data, will try next time
logger.debug { "SyncYomi sync failed with 412" } logger.debug { "SyncYomi sync failed with 412" }
false
} else { } else {
val responseBody = response.body.string() val responseBody = response.body.string()
logger.error { "SyncError: $responseBody" } logger.error { "SyncError: $responseBody" }
false
}
}
private suspend fun reportSyncEvent(
event: SyncEventStatus,
message: String? = null,
) {
try {
val host = serverConfig.syncYomiHost.value
val apiKey = serverConfig.syncYomiApiKey.value
val url = "$host/api/sync/event"
val headers = Headers.Builder().add("X-API-Token", apiKey).build()
// Use a fixed server name.
val bodyObj =
SyncEvent(
event = event,
device_Name = "Suwayomi Server",
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,
)
network.client
.newCall(request)
.await()
.close()
} catch (e: Exception) {
logger.error { "Failed to report sync event: ${e.message}" }
} }
} }

View File

@@ -16,6 +16,7 @@ import suwayomi.tachidesk.manga.model.table.ChapterTable
import suwayomi.tachidesk.manga.model.table.MangaTable import suwayomi.tachidesk.manga.model.table.MangaTable
import suwayomi.tachidesk.manga.model.table.toDataClass import suwayomi.tachidesk.manga.model.table.toDataClass
import suwayomi.tachidesk.server.serverConfig import suwayomi.tachidesk.server.serverConfig
import xyz.nulldev.androidcompat.util.SafePath
import java.io.File import java.io.File
import java.io.InputStream import java.io.InputStream
@@ -76,13 +77,46 @@ object ChapterDownloadHelper {
.select(ChapterTable.columns + MangaTable.columns) .select(ChapterTable.columns + MangaTable.columns)
.where { ChapterTable.id eq chapterId } .where { ChapterTable.id eq chapterId }
.firstOrNull() ?: throw IllegalArgumentException("ChapterId $chapterId not found") .firstOrNull() ?: throw IllegalArgumentException("ChapterId $chapterId not found")
val chapter = ChapterTable.toDataClass(row) val chapter = ChapterTable.toDataClass(row)
val mangaTitle = row[MangaTable.title] val mangaTitle = row[MangaTable.title].trim()
val scanlatorPart = chapter.scanlator?.let { "[$it] " } ?: "" val scanlatorName = chapter.scanlator?.trim()?.takeIf { it.isNotEmpty() }
val fileName = "$mangaTitle - $scanlatorPart${chapter.name}.cbz" val chapterName = chapter.name.trim().takeIf { it.isNotEmpty() }
Pair(chapter, fileName) val fileName =
buildString {
append(mangaTitle)
append(" - ")
if (chapterName != null) {
append(chapterName)
} else if (chapter.chapterNumber >= 0f) {
// chapterNumber is stored as Float, drop .0 for whole numbers.
val formatNumber =
if (chapter.chapterNumber % 1 == 0f) {
chapter.chapterNumber.toInt().toString()
} else {
chapter.chapterNumber.toString()
}
append("#$formatNumber")
} else {
// Fallback when neither name nor valid chapter number exists
append("#${chapter.index}")
}
if (scanlatorName != null) {
append(" [")
append(scanlatorName)
append("]")
}
append(".cbz")
}
// Sanitize filename for OS compatibility
val safeFileName = SafePath.buildValidFilename(fileName)
Pair(chapter, safeFileName)
} }
fun getCbzForDownload( fun getCbzForDownload(