Compare commits

...

3 Commits

Author SHA1 Message Date
renovate[bot]
dcc0e3fd67 Update actions/checkout action to v7 2026-06-18 19:06:49 +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
6 changed files with 146 additions and 32 deletions

View File

@@ -14,7 +14,7 @@ jobs:
steps: steps:
- name: Clone repo - name: Clone repo
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: Validate Gradle Wrapper - name: Validate Gradle Wrapper
uses: gradle/actions/wrapper-validation@v5 uses: gradle/actions/wrapper-validation@v5
@@ -38,7 +38,7 @@ jobs:
steps: steps:
- name: Checkout pull request - name: Checkout pull request
uses: actions/checkout@v6 uses: actions/checkout@v7
with: with:
ref: ${{ github.event.pull_request.head.sha }} ref: ${{ github.event.pull_request.head.sha }}
path: master path: master
@@ -106,7 +106,7 @@ jobs:
steps: steps:
- name: Clone repo - name: Clone repo
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: Validate all options are documented - name: Validate all options are documented
run: | run: |

View File

@@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Clone repo - name: Clone repo
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: Validate Gradle Wrapper - name: Validate Gradle Wrapper
uses: gradle/actions/wrapper-validation@v5 uses: gradle/actions/wrapper-validation@v5
@@ -26,7 +26,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout master branch - name: Checkout master branch
uses: actions/checkout@v6 uses: actions/checkout@v7
with: with:
ref: master ref: master
path: master path: master
@@ -208,7 +208,7 @@ jobs:
path: release path: release
- name: Checkout Preview branch - name: Checkout Preview branch
uses: actions/checkout@v6 uses: actions/checkout@v7
with: with:
repository: "Suwayomi/Suwayomi-Server-preview" repository: "Suwayomi/Suwayomi-Server-preview"
ref: main ref: main

View File

@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Clone repo - name: Clone repo
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: Validate Gradle Wrapper - name: Validate Gradle Wrapper
uses: gradle/actions/wrapper-validation@v5 uses: gradle/actions/wrapper-validation@v5
@@ -27,7 +27,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout ${{ github.ref }} - name: Checkout ${{ github.ref }}
uses: actions/checkout@v6 uses: actions/checkout@v7
with: with:
ref: ${{ github.ref }} ref: ${{ github.ref }}
path: master path: master

View File

@@ -20,7 +20,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout Code - name: Checkout Code
uses: actions/checkout@v6 uses: actions/checkout@v7
with: with:
repository: ${{github.repository}} repository: ${{github.repository}}
path: ${{github.repository}} path: ${{github.repository}}
@@ -28,7 +28,7 @@ jobs:
fetch-tags: true fetch-tags: true
- name: Checkout Wiki - name: Checkout Wiki
uses: actions/checkout@v6 uses: actions/checkout@v7
with: with:
repository: ${{github.repository}}.wiki repository: ${{github.repository}}.wiki
path: ${{github.repository}}.wiki path: ${{github.repository}}.wiki

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(