mirror of
https://github.com/Suwayomi/Suwayomi-Server.git
synced 2026-06-30 09:24:34 -05:00
Merge branch 'master' into extensions_1.6
This commit is contained in:
@@ -8,7 +8,7 @@ javalin = "7.2.2"
|
||||
jte = "3.2.4"
|
||||
jackson = "3.2.0" # jackson version locked by javalin, ref: `io.javalin.core.util.OptionalDependency`
|
||||
exposed = "1.2.0"
|
||||
dex2jar = "2.4.36"
|
||||
dex2jar = "2.4.37"
|
||||
polyglot = "25.0.3"
|
||||
settings = "1.3.0"
|
||||
twelvemonkeys = "3.13.1"
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
<!-- OPDS errors -->
|
||||
<string name="opds_error_manga_not_found">Series with ID %1$d not found.</string>
|
||||
<string name="opds_error_chapter_not_found">Chapter with index %1$d not found.</string>
|
||||
<string name="opds_error_chapters_not_found">No chapters found or the source is unreachable on page %1$d.</string>
|
||||
|
||||
<!-- OPDS facets (Filters and Sorting) -->
|
||||
<string name="opds_facetgroup_sort_order">Sort Order</string>
|
||||
|
||||
125
server/i18n/src/commonMain/moko-resources/values/el/strings.xml
Normal file
125
server/i18n/src/commonMain/moko-resources/values/el/strings.xml
Normal file
@@ -0,0 +1,125 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="opds_feeds_root">Suwayomi OPDS Κατάλογος</string>
|
||||
<string name="opds_feeds_manga_chapters">Κεφάλαια %1$s</string>
|
||||
<string name="opds_feeds_chapter_details">%1$s | %2$s | Λεπτομέρειες</string>
|
||||
<string name="opds_feeds_explore_title">Εξερεύνηση</string>
|
||||
<string name="opds_feeds_explore_entry_content">Εξερεύνησε νέες σειρές από τις πηγές σου</string>
|
||||
<string name="opds_feeds_history_title">Ιστορικό</string>
|
||||
<string name="opds_feeds_history_entry_content">Πρόσφατα διαβασμένα κεφάλαια</string>
|
||||
<string name="opds_feeds_all_series_in_library_title">Όλες οι σειρές</string>
|
||||
<string name="opds_feeds_all_series_in_library_entry_content">Περιήγηση σε όλες τις σειρές της βιβλιοθήκης σου</string>
|
||||
<string name="opds_feeds_library_sources_title">Πηγές</string>
|
||||
<string name="opds_feeds_library_sources_entry_content">Περιήγηση σε σειρές της βιβλιοθήκης σου φιλτραρισμένες κατά πηγή</string>
|
||||
<string name="opds_feeds_categories_title">Κατηγορίες</string>
|
||||
<string name="opds_feeds_categories_entry_content">Περιήγηση σε σειρές οργανωμένες κατά κατηγορία</string>
|
||||
<string name="opds_feeds_genres_title">Είδη</string>
|
||||
<string name="opds_feeds_genres_entry_content">Περιήγηση σε σειρές κατά ετικέτες είδους</string>
|
||||
<string name="opds_feeds_status_title">Κατάσταση</string>
|
||||
<string name="opds_feeds_status_entry_content">Περιήγηση σε σειρές κατά κατάσταση δημοσίευσης</string>
|
||||
<string name="opds_feeds_languages_title">Γλώσσες</string>
|
||||
<string name="opds_feeds_languages_entry_content">Περιήγηση σε σειρές κατά γλώσσα περιεχομένου</string>
|
||||
<string name="opds_feeds_library_updates_title">Ιστορικό ενημερώσεων βιβλιοθήκης</string>
|
||||
<string name="opds_feeds_library_updates_entry_content">Πρόσφατα ενημερωμένα κεφάλαια από τη βιβλιοθήκη σου</string>
|
||||
<string name="opds_feeds_search_results_title">Αποτελέσματα αναζήτησης</string>
|
||||
<string name="opds_feeds_sources_title">Όλες οι πηγές</string>
|
||||
<string name="opds_feeds_category_specific_title">Κατηγορία: %1$s</string>
|
||||
<string name="opds_feeds_genre_specific_title">Είδος: %1$s</string>
|
||||
<string name="opds_feeds_status_specific_title">Κατάσταση: %1$s</string>
|
||||
<string name="opds_feeds_language_specific_title">Γλώσσα: %1$s</string>
|
||||
<string name="opds_feeds_source_specific_title">Πηγή: %1$s</string>
|
||||
<string name="opds_feeds_library_source_specific_title">Βιβλιοθήκη - Πηγή: %1$s</string>
|
||||
<string name="opds_feeds_source_specific_popular_title">Πηγή: %1$s - Δημοφιλές</string>
|
||||
<string name="opds_feeds_source_specific_latest_title">Πηγή: %1$s - Τελευταίο</string>
|
||||
<string name="opds_search_shortname">Suwayomi OPDS Αναζήτηση</string>
|
||||
<string name="opds_search_description">Αναζήτηση σειρών στον κατάλογο.</string>
|
||||
<string name="opds_error_manga_not_found">Η σειρά με ID %1$d δεν βρέθηκε.</string>
|
||||
<string name="opds_error_chapter_not_found">Το κεφάλαιο με δείκτη %1$d δεν βρέθηκε.</string>
|
||||
<string name="opds_facetgroup_sort_order">Σειρά ταξινόμησης</string>
|
||||
<string name="opds_facetgroup_filter_read_status">Φίλτρο κατά κατάσταση ανάγνωσης</string>
|
||||
<string name="opds_facetgroup_filter_content">Φίλτρο περιεχομένου</string>
|
||||
<string name="opds_facetgroup_filter_source">Φίλτρο κατά πηγή</string>
|
||||
<string name="opds_facetgroup_filter_category">Φίλτρο κατά κατηγορία</string>
|
||||
<string name="opds_facetgroup_filter_status">Φίλτρο κατά κατάσταση</string>
|
||||
<string name="opds_facetgroup_filter_language">Φίλτρο κατά γλώσσα</string>
|
||||
<string name="opds_facetgroup_filter_genre">Φίλτρο κατά είδος</string>
|
||||
<string name="opds_facet_sort_oldest_first">Παλαιότερα πρώτα</string>
|
||||
<string name="opds_facet_sort_newest_first">Νεότερα πρώτα</string>
|
||||
<string name="opds_facet_sort_date_asc">Ημερομηνία αύξουσα</string>
|
||||
<string name="opds_facet_sort_date_desc">Ημερομηνία φθίνουσα</string>
|
||||
<string name="opds_facet_sort_popular">Δημοφιλές</string>
|
||||
<string name="opds_facet_sort_latest">Τελευταίο</string>
|
||||
<string name="opds_facet_sort_alpha_asc">Αλφαβητικά Α-Ω</string>
|
||||
<string name="opds_facet_sort_alpha_desc">Αλφαβητικά Ω-Α</string>
|
||||
<string name="opds_facet_sort_last_read_desc">Τελευταία ανάγνωση</string>
|
||||
<string name="opds_facet_sort_latest_chapter_desc">Τελευταίο κεφάλαιο</string>
|
||||
<string name="opds_facet_sort_date_added_desc">Ημερομηνία προσθήκης</string>
|
||||
<string name="opds_facet_sort_unread_desc">Αδιάβαστα κεφάλαια</string>
|
||||
<string name="opds_facet_filter_all">Όλα</string>
|
||||
<string name="opds_facet_filter_all_chapters">Όλα τα κεφάλαια</string>
|
||||
<string name="opds_facet_filter_unread_only">Αδιάβαστα</string>
|
||||
<string name="opds_facet_filter_read_only">Διαβασμένα</string>
|
||||
<string name="opds_facet_filter_downloaded">Ληφθέντα</string>
|
||||
<string name="opds_facet_filter_ongoing">Σε εξέλιξη</string>
|
||||
<string name="opds_facet_filter_completed">Ολοκληρωμένα</string>
|
||||
<string name="opds_facet_all_sources">Όλες οι πηγές</string>
|
||||
<string name="opds_facet_all_categories">Όλες οι κατηγορίες</string>
|
||||
<string name="opds_facet_all_statuses">Όλες οι καταστάσεις</string>
|
||||
<string name="opds_facet_all_languages">Όλες οι γλώσσες</string>
|
||||
<string name="opds_facet_all_genres">Όλα τα είδη</string>
|
||||
<string name="opds_linktitle_catalog_root">Ρίζα καταλόγου</string>
|
||||
<string name="opds_linktitle_search_catalog">Αναζήτηση καταλόγου</string>
|
||||
<string name="opds_linktitle_first_page">Πρώτη σελίδα</string>
|
||||
<string name="opds_linktitle_previous_page">Προηγούμενη σελίδα</string>
|
||||
<string name="opds_linktitle_next_page">Επόμενη σελίδα</string>
|
||||
<string name="opds_linktitle_last_page">Τελευταία σελίδα</string>
|
||||
<string name="opds_linktitle_self_feed">Τρέχουσα ροή</string>
|
||||
<string name="opds_linktitle_view_on_web">Προβολή στο Web</string>
|
||||
<string name="opds_linktitle_stream_pages_start">Διάβασε Online</string>
|
||||
<string name="opds_linktitle_stream_pages_continue">Συνέχισε να διαβάζεις Online</string>
|
||||
<string name="opds_linktitle_stream_pages_start_local">Διάβασε Online (Τοπική πρόοδος)</string>
|
||||
<string name="opds_linktitle_stream_pages_continue_local">Συνέχισε να διαβάζεις Online (Τοπική πρόοδος)</string>
|
||||
<string name="opds_linktitle_stream_pages_start_remote">Διάβασε Online (Συγχρονισμένο από %1$s)</string>
|
||||
<string name="opds_linktitle_stream_pages_continue_remote">Συνέχισε να διαβάζεις Online (Συγχρονισμένο από %1$s)</string>
|
||||
<string name="opds_linktitle_download_cbz">Λήψη CBZ</string>
|
||||
<string name="opds_linktitle_chapter_cover">Εξώφυλλο κεφαλαίου</string>
|
||||
<string name="opds_linktitle_view_chapter_details">Λεπτομέρειες κεφαλαίου & Σελίδες</string>
|
||||
<string name="opds_chapter_status_read">✅</string>
|
||||
<string name="opds_chapter_status_in_progress">⌛</string>
|
||||
<string name="opds_chapter_status_unread">⭕</string>
|
||||
<string name="opds_chapter_status_downloaded">⬇️</string>
|
||||
<string name="opds_chapter_status_synced">🌐</string>
|
||||
<string name="opds_chapter_details_base">Σειρά: %1$s | %2$s</string>
|
||||
<string name="opds_chapter_details_scanlator">| Από %1$s</string>
|
||||
<string name="opds_chapter_details_progress">| Πρόοδος: %1$d από %2$d</string>
|
||||
<string name="opds_manga_summary_status">Κατάσταση: %1$s</string>
|
||||
<string name="opds_manga_summary_source">Πηγή: %1$s</string>
|
||||
<string name="opds_manga_summary_language">Γλώσσα: %1$s</string>
|
||||
<string name="manga_status_ongoing">Σε εξέλιξη</string>
|
||||
<string name="manga_status_completed">Ολοκληρωμένο</string>
|
||||
<string name="manga_status_licensed">Υπό άδεια</string>
|
||||
<string name="manga_status_publishing_finished">Δημοσίευση ολοκληρωμένη</string>
|
||||
<string name="manga_status_cancelled">Ακυρωμένο</string>
|
||||
<string name="manga_status_on_hiatus">Σε παύση</string>
|
||||
<string name="manga_status_unknown">Άγνωστο</string>
|
||||
<string name="label_error">Σφάλμα</string>
|
||||
<string name="label_version">Έκδοση <xliff:g id="version" example="v2.0.1833">%1$s</xliff:g></string>
|
||||
<string name="label_close">Κλείσε</string>
|
||||
<string name="webview_label_title">Suwayomi WebView</string>
|
||||
<string name="webview_label_disconnected">Αποσυνδεδεμένο, κάνε ανανέωση</string>
|
||||
<string name="webview_label_reversescroll">Αντίστροφη κύλιση</string>
|
||||
<string name="webview_label_bindingshint">Σημείωση: Όταν η εστίαση είναι στο τμήμα WebView, καμία συντόμευση πληκτρολογίου, συμπεριλαμβανομένης της ανανέωσης, δεν θα αντιμετωπίζεται από το πρόγραμμα περιήγησης.</string>
|
||||
<string name="webview_label_init">Αρχικοποίηση... Παρακαλώ περίμενε</string>
|
||||
<string name="webview_label_getstarted">Εισήγαγε μια διεύθυνση URL για να ξεκινήσεις</string>
|
||||
<string name="webview_label_loading">Φόρτωση σελίδας...</string>
|
||||
<string name="webview_label_copy">Αντιγραφή στο πρόχειρο</string>
|
||||
<string name="webview_label_copy_description">Η αυτόματη αντιγραφή στο πρόχειρο απέτυχε, χρησιμοποίησε το παρακάτω πεδίο για να αντιγράψεις χειροκίνητα την τιμή.</string>
|
||||
<string name="webview_label_login_required">Η διαμόρφωσή σου απαιτεί σύνδεση. Εισήγαγε όνομα χρήστη και κωδικό.</string>
|
||||
<string name="webview_placeholder_url">Εισήγαγε URL...</string>
|
||||
<string name="login_label_title">Suwayomi Σύνδεση</string>
|
||||
<string name="login_label_username">Όνομα χρήστη</string>
|
||||
<string name="login_label_password">Κωδικός</string>
|
||||
<string name="login_label_login">Σύνδεση</string>
|
||||
<string name="login_placeholder_username">Πληκτρολόγησε όνομα χρήστη...</string>
|
||||
<string name="login_placeholder_password">Μυστικό...</string>
|
||||
</resources>
|
||||
@@ -4,11 +4,15 @@ import android.app.Application
|
||||
import android.content.Context
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.NetworkHelper
|
||||
import eu.kanade.tachiyomi.network.POST
|
||||
import eu.kanade.tachiyomi.network.PUT
|
||||
import eu.kanade.tachiyomi.network.await
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import io.javalin.http.HttpStatus
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.protobuf.ProtoBuf
|
||||
import okhttp3.Headers
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
@@ -36,31 +40,66 @@ object SyncYomiSyncService {
|
||||
message: String?,
|
||||
) : 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(
|
||||
syncData: SyncData,
|
||||
startDate: Instant,
|
||||
setSyncState: (SyncManager.SyncState) -> Unit,
|
||||
): Backup? {
|
||||
reportSyncEvent(SyncEventStatus.SYNC_STARTED)
|
||||
setSyncState(SyncManager.SyncState.Downloading(startDate))
|
||||
val (remoteData, etag) = pullSyncData()
|
||||
|
||||
val finalSyncData =
|
||||
if (remoteData != null) {
|
||||
require(etag.isNotEmpty()) { "ETag should never be empty if remote data is not null" }
|
||||
logger.debug { "Try update remote data with ETag($etag)" }
|
||||
setSyncState(SyncManager.SyncState.Merging(startDate))
|
||||
mergeSyncData(syncData, remoteData)
|
||||
} else {
|
||||
// init or overwrite remote data
|
||||
logger.debug { "Try overwrite remote data with ETag($etag)" }
|
||||
syncData
|
||||
return try {
|
||||
val (remoteData, etag) = pullSyncData()
|
||||
|
||||
val finalSyncData =
|
||||
if (remoteData != null) {
|
||||
require(etag.isNotEmpty()) { "ETag should never be empty if remote data is not null" }
|
||||
logger.debug { "Try update remote data with ETag($etag)" }
|
||||
setSyncState(SyncManager.SyncState.Merging(startDate))
|
||||
mergeSyncData(syncData, remoteData)
|
||||
} 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) {
|
||||
setSyncState(SyncManager.SyncState.Uploading(startDate))
|
||||
val success = pushSyncData(finalSyncData, etag)
|
||||
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> {
|
||||
@@ -122,8 +161,8 @@ object SyncYomiSyncService {
|
||||
private suspend fun pushSyncData(
|
||||
syncData: SyncData,
|
||||
eTag: String,
|
||||
) {
|
||||
val backup = syncData.backup ?: return
|
||||
): Boolean {
|
||||
val backup = syncData.backup ?: return true
|
||||
|
||||
val host = serverConfig.syncYomiHost.value
|
||||
val apiKey = serverConfig.syncYomiApiKey.value
|
||||
@@ -160,7 +199,7 @@ object SyncYomiSyncService {
|
||||
|
||||
val response = client.newCall(uploadRequest).await()
|
||||
|
||||
if (response.isSuccessful) {
|
||||
return if (response.isSuccessful) {
|
||||
val newETag =
|
||||
response.headers["ETag"]
|
||||
?.takeIf { it.isNotEmpty() } ?: throw SyncYomiException("Missing ETag")
|
||||
@@ -169,12 +208,53 @@ object SyncYomiSyncService {
|
||||
.putString("last_sync_etag", newETag)
|
||||
.apply()
|
||||
logger.debug { "SyncYomi sync completed" }
|
||||
true
|
||||
} else if (response.code == HttpStatus.PRECONDITION_FAILED.code) {
|
||||
// other clients updated remote data, will try next time
|
||||
logger.debug { "SyncYomi sync failed with 412" }
|
||||
false
|
||||
} else {
|
||||
val responseBody = response.body.string()
|
||||
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}" }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import suwayomi.tachidesk.manga.model.table.ChapterTable
|
||||
import suwayomi.tachidesk.manga.model.table.MangaTable
|
||||
import suwayomi.tachidesk.manga.model.table.toDataClass
|
||||
import suwayomi.tachidesk.server.serverConfig
|
||||
import xyz.nulldev.androidcompat.util.SafePath
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
|
||||
@@ -76,13 +77,46 @@ object ChapterDownloadHelper {
|
||||
.select(ChapterTable.columns + MangaTable.columns)
|
||||
.where { ChapterTable.id eq chapterId }
|
||||
.firstOrNull() ?: throw IllegalArgumentException("ChapterId $chapterId not found")
|
||||
|
||||
val chapter = ChapterTable.toDataClass(row)
|
||||
val mangaTitle = row[MangaTable.title]
|
||||
val mangaTitle = row[MangaTable.title].trim()
|
||||
|
||||
val scanlatorPart = chapter.scanlator?.let { "[$it] " } ?: ""
|
||||
val fileName = "$mangaTitle - $scanlatorPart${chapter.name}.cbz"
|
||||
val scanlatorName = chapter.scanlator?.trim()?.takeIf { it.isNotEmpty() }
|
||||
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(
|
||||
|
||||
@@ -127,29 +127,36 @@ object KoreaderSyncService {
|
||||
}
|
||||
|
||||
val mangaId = chapterRow[ChapterTable.manga].value
|
||||
val isDownloaded = chapterRow[ChapterTable.isDownloaded]
|
||||
val checksumMethod = serverConfig.koreaderSyncChecksumMethod.value
|
||||
|
||||
val newHash =
|
||||
when (checksumMethod) {
|
||||
KoreaderSyncChecksumMethod.BINARY -> {
|
||||
logger.debug { "[KOSYNC HASH] No hash for chapterId=$chapterId. Generating from downloaded content." }
|
||||
try {
|
||||
// Always create a CBZ in memory if it doesn't exist
|
||||
val (stream, _) = ChapterDownloadHelper.getArchiveStreamWithSize(mangaId, chapterId)
|
||||
// Write the stream to a temp file for partial hashing
|
||||
val tempFile = File.createTempFile("kosync-hash-", ".cbz")
|
||||
// Only generate binary hash if the chapter is downloaded to avoid fetching missing files
|
||||
if (isDownloaded) {
|
||||
logger.debug { "[KOSYNC HASH] No hash for chapterId=$chapterId. Generating from downloaded content." }
|
||||
try {
|
||||
tempFile.outputStream().use { fos ->
|
||||
stream.use { it.copyTo(fos) }
|
||||
// Always create a CBZ in memory if it doesn't exist
|
||||
val (stream, _) = ChapterDownloadHelper.getArchiveStreamWithSize(mangaId, chapterId)
|
||||
// Write the stream to a temp file for partial hashing
|
||||
val tempFile = File.createTempFile("kosync-hash-", ".cbz")
|
||||
try {
|
||||
tempFile.outputStream().use { fos ->
|
||||
stream.use { it.copyTo(fos) }
|
||||
}
|
||||
// Use the same hashing method as for downloads
|
||||
KoreaderHelper.hashContents(tempFile)
|
||||
} finally {
|
||||
// Always delete the temp file
|
||||
tempFile.delete()
|
||||
}
|
||||
// Use the same hashing method as for downloads
|
||||
KoreaderHelper.hashContents(tempFile)
|
||||
} finally {
|
||||
// Always delete the temp file
|
||||
tempFile.delete()
|
||||
} catch (e: Exception) {
|
||||
logger.warn(e) { "[KOSYNC HASH] Failed to generate archive stream for chapterId=$chapterId." }
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn(e) { "[KOSYNC HASH] Failed to generate archive stream for chapterId=$chapterId." }
|
||||
} else {
|
||||
logger.debug { "[KOSYNC HASH] Skipping binary hash for chapterId=$chapterId because it is not downloaded." }
|
||||
null
|
||||
}
|
||||
}
|
||||
@@ -175,7 +182,7 @@ object KoreaderSyncService {
|
||||
}
|
||||
logger.info { "[KOSYNC HASH] Generated and saved new hash for chapterId=$chapterId" }
|
||||
} else {
|
||||
logger.warn { "[KOSYNC HASH] Hashing failed for chapterId=$chapterId." }
|
||||
logger.warn { "[KOSYNC HASH] Hashing failed or skipped for chapterId=$chapterId." }
|
||||
}
|
||||
newHash
|
||||
}
|
||||
|
||||
@@ -644,6 +644,16 @@ object OpdsFeedBuilder {
|
||||
skipMetadata,
|
||||
)
|
||||
|
||||
// Return a not-found feed if all available chapters are filtered out as unreachable
|
||||
if (skipMetadata && chapterEntries.isEmpty() && totalChapters > 0L) {
|
||||
return buildNotFoundFeed(
|
||||
baseUrl = baseUrl,
|
||||
locale = locale,
|
||||
idPath = "series/$mangaId/chapters",
|
||||
title = MR.strings.opds_error_chapters_not_found.localized(locale, pageNum),
|
||||
)
|
||||
}
|
||||
|
||||
// If no chapters are found in the database, attempt to fetch them from the source.
|
||||
if (chapterEntries.isEmpty() && totalChapters == 0L) {
|
||||
try {
|
||||
|
||||
@@ -169,6 +169,8 @@ object ChapterRepository {
|
||||
}
|
||||
}
|
||||
}.awaitAll()
|
||||
// Exclude unreachable chapters that are not downloaded and have no page count
|
||||
.filter { it.downloaded || it.pageCount > 0 }
|
||||
|
||||
return Pair(enrichedChapters, totalCount)
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import org.jetbrains.exposed.v1.core.and
|
||||
import org.jetbrains.exposed.v1.core.eq
|
||||
import org.jetbrains.exposed.v1.core.greater
|
||||
import org.jetbrains.exposed.v1.core.inList
|
||||
import org.jetbrains.exposed.v1.core.inSubQuery
|
||||
import org.jetbrains.exposed.v1.core.intLiteral
|
||||
import org.jetbrains.exposed.v1.core.like
|
||||
import org.jetbrains.exposed.v1.core.lowerCase
|
||||
@@ -75,13 +76,28 @@ fun Query.applyOpdsMangaFilter(
|
||||
}
|
||||
if (excludeField != "filter") {
|
||||
criteria.filter?.let { filterVal ->
|
||||
val unreadCountExpr = Case().When(ChapterTable.isRead eq false, intLiteral(1)).Else(intLiteral(0)).sum()
|
||||
val downloadedCountExpr = Case().When(ChapterTable.isDownloaded eq true, intLiteral(1)).Else(intLiteral(0)).sum()
|
||||
when (filterVal) {
|
||||
"unread" -> having { unreadCountExpr greater 0 }
|
||||
"downloaded" -> having { downloadedCountExpr greater 0 }
|
||||
"ongoing" -> andWhere { MangaTable.status eq MangaStatus.ONGOING.value }
|
||||
"completed" -> andWhere { MangaTable.status eq MangaStatus.COMPLETED.value }
|
||||
"unread" -> {
|
||||
andWhere {
|
||||
MangaTable.id inSubQuery
|
||||
ChapterTable.select(ChapterTable.manga).where { ChapterTable.isRead eq false }
|
||||
}
|
||||
}
|
||||
|
||||
"downloaded" -> {
|
||||
andWhere {
|
||||
MangaTable.id inSubQuery
|
||||
ChapterTable.select(ChapterTable.manga).where { ChapterTable.isDownloaded eq true }
|
||||
}
|
||||
}
|
||||
|
||||
"ongoing" -> {
|
||||
andWhere { MangaTable.status eq MangaStatus.ONGOING.value }
|
||||
}
|
||||
|
||||
"completed" -> {
|
||||
andWhere { MangaTable.status eq MangaStatus.COMPLETED.value }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -133,11 +149,17 @@ object MangaRepository {
|
||||
val unreadCount = unreadCountExpr.alias("unread_count")
|
||||
|
||||
// Base query with necessary joins for filtering and sorting
|
||||
val query =
|
||||
var baseJoin =
|
||||
MangaTable
|
||||
.join(SourceTable, JoinType.INNER, MangaTable.sourceReference, SourceTable.id)
|
||||
.join(ChapterTable, JoinType.LEFT, MangaTable.id, ChapterTable.manga)
|
||||
.join(CategoryMangaTable, JoinType.LEFT, MangaTable.id, CategoryMangaTable.manga)
|
||||
|
||||
if (criteria.categoryId != null) {
|
||||
baseJoin = baseJoin.join(CategoryMangaTable, JoinType.LEFT, MangaTable.id, CategoryMangaTable.manga)
|
||||
}
|
||||
|
||||
val query =
|
||||
baseJoin
|
||||
.select(MangaTable.columns + SourceTable.lang + SourceTable.name + unreadCount)
|
||||
.where { MangaTable.inLibrary eq true }
|
||||
|
||||
@@ -301,7 +323,6 @@ object MangaRepository {
|
||||
* Applies sorting and filtering logic to a manga library query.
|
||||
* @param query The Exposed SQL query to modify.
|
||||
* @param sort The sorting parameter.
|
||||
* @param filter The filtering parameter.
|
||||
*/
|
||||
private fun applyMangaLibrarySort(
|
||||
query: Query,
|
||||
@@ -330,36 +351,38 @@ object MangaRepository {
|
||||
*/
|
||||
fun getLibraryFilterCounts(activeFilters: OpdsMangaFilter): Map<String, Long> =
|
||||
transaction {
|
||||
val unreadCountExpr = Case().When(ChapterTable.isRead eq false, intLiteral(1)).Else(intLiteral(0)).sum()
|
||||
val downloadedCountExpr = Case().When(ChapterTable.isDownloaded eq true, intLiteral(1)).Else(intLiteral(0)).sum()
|
||||
var baseJoin =
|
||||
MangaTable
|
||||
.join(SourceTable, JoinType.INNER, MangaTable.sourceReference, SourceTable.id)
|
||||
|
||||
if (activeFilters.categoryId != null) {
|
||||
baseJoin = baseJoin.join(CategoryMangaTable, JoinType.LEFT, MangaTable.id, CategoryMangaTable.manga)
|
||||
}
|
||||
|
||||
val baseQuery =
|
||||
MangaTable
|
||||
.join(ChapterTable, JoinType.LEFT, MangaTable.id, ChapterTable.manga)
|
||||
.join(SourceTable, JoinType.INNER, MangaTable.sourceReference, SourceTable.id)
|
||||
.join(CategoryMangaTable, JoinType.LEFT, MangaTable.id, CategoryMangaTable.manga)
|
||||
baseJoin
|
||||
.select(MangaTable.id)
|
||||
.where { MangaTable.inLibrary eq true }
|
||||
.withDistinct()
|
||||
|
||||
baseQuery.applyOpdsMangaFilter(activeFilters, excludeField = "filter")
|
||||
baseQuery.groupBy(MangaTable.id)
|
||||
|
||||
val unreadCount = baseQuery.copy().having { unreadCountExpr greater 0 }.count()
|
||||
val downloadedCount = baseQuery.copy().having { downloadedCountExpr greater 0 }.count()
|
||||
|
||||
val statusBaseQuery =
|
||||
MangaTable
|
||||
.join(SourceTable, JoinType.INNER, MangaTable.sourceReference, SourceTable.id)
|
||||
.join(CategoryMangaTable, JoinType.LEFT, MangaTable.id, CategoryMangaTable.manga)
|
||||
.join(ChapterTable, JoinType.LEFT, MangaTable.id, ChapterTable.manga)
|
||||
.select(MangaTable.id)
|
||||
.where { MangaTable.inLibrary eq true }
|
||||
|
||||
statusBaseQuery.applyOpdsMangaFilter(activeFilters, excludeField = "filter")
|
||||
statusBaseQuery.groupBy(MangaTable.id)
|
||||
|
||||
val ongoingCount = statusBaseQuery.copy().andWhere { MangaTable.status eq MangaStatus.ONGOING.value }.count()
|
||||
val completedCount = statusBaseQuery.copy().andWhere { MangaTable.status eq MangaStatus.COMPLETED.value }.count()
|
||||
val unreadCount =
|
||||
baseQuery
|
||||
.copy()
|
||||
.andWhere {
|
||||
MangaTable.id inSubQuery
|
||||
ChapterTable.select(ChapterTable.manga).where { ChapterTable.isRead eq false }
|
||||
}.count()
|
||||
val downloadedCount =
|
||||
baseQuery
|
||||
.copy()
|
||||
.andWhere {
|
||||
MangaTable.id inSubQuery
|
||||
ChapterTable.select(ChapterTable.manga).where { ChapterTable.isDownloaded eq true }
|
||||
}.count()
|
||||
val ongoingCount = baseQuery.copy().andWhere { MangaTable.status eq MangaStatus.ONGOING.value }.count()
|
||||
val completedCount = baseQuery.copy().andWhere { MangaTable.status eq MangaStatus.COMPLETED.value }.count()
|
||||
|
||||
mapOf(
|
||||
"unread" to unreadCount,
|
||||
|
||||
@@ -12,7 +12,6 @@ import suwayomi.tachidesk.i18n.MR
|
||||
import suwayomi.tachidesk.manga.impl.extension.Extension
|
||||
import suwayomi.tachidesk.manga.model.table.CategoryMangaTable
|
||||
import suwayomi.tachidesk.manga.model.table.CategoryTable
|
||||
import suwayomi.tachidesk.manga.model.table.ChapterTable
|
||||
import suwayomi.tachidesk.manga.model.table.ExtensionTable
|
||||
import suwayomi.tachidesk.manga.model.table.MangaStatus
|
||||
import suwayomi.tachidesk.manga.model.table.MangaTable
|
||||
@@ -166,12 +165,17 @@ object NavigationRepository {
|
||||
transaction {
|
||||
val mangaCount = MangaTable.id.countDistinct().alias("manga_count")
|
||||
|
||||
val query =
|
||||
var baseJoin =
|
||||
SourceTable
|
||||
.join(MangaTable, JoinType.INNER, SourceTable.id, MangaTable.sourceReference)
|
||||
.join(ExtensionTable, JoinType.LEFT, onColumn = SourceTable.extension, otherColumn = ExtensionTable.id)
|
||||
.join(CategoryMangaTable, JoinType.LEFT, MangaTable.id, CategoryMangaTable.manga)
|
||||
.join(ChapterTable, JoinType.LEFT, MangaTable.id, ChapterTable.manga)
|
||||
|
||||
if (activeFilters.categoryId != null) {
|
||||
baseJoin = baseJoin.join(CategoryMangaTable, JoinType.LEFT, MangaTable.id, CategoryMangaTable.manga)
|
||||
}
|
||||
|
||||
val query =
|
||||
baseJoin
|
||||
.select(SourceTable.id, SourceTable.name, SourceTable.lang, ExtensionTable.pkgName, mangaCount)
|
||||
.where { MangaTable.inLibrary eq true }
|
||||
|
||||
@@ -227,7 +231,6 @@ object NavigationRepository {
|
||||
.join(CategoryMangaTable, JoinType.INNER, CategoryTable.id, CategoryMangaTable.category)
|
||||
.join(MangaTable, JoinType.INNER, CategoryMangaTable.manga, MangaTable.id)
|
||||
.join(SourceTable, JoinType.INNER, MangaTable.sourceReference, SourceTable.id)
|
||||
.join(ChapterTable, JoinType.LEFT, MangaTable.id, ChapterTable.manga)
|
||||
.select(CategoryTable.id, CategoryTable.name, mangaCount)
|
||||
.where { MangaTable.inLibrary eq true }
|
||||
|
||||
@@ -262,11 +265,15 @@ object NavigationRepository {
|
||||
activeFilters: OpdsMangaFilter = OpdsMangaFilter(),
|
||||
): Pair<List<OpdsGenreNavEntry>, Long> =
|
||||
transaction {
|
||||
val query =
|
||||
var baseJoin =
|
||||
MangaTable
|
||||
.join(SourceTable, JoinType.INNER, MangaTable.sourceReference, SourceTable.id)
|
||||
.join(CategoryMangaTable, JoinType.LEFT, MangaTable.id, CategoryMangaTable.manga)
|
||||
.join(ChapterTable, JoinType.LEFT, MangaTable.id, ChapterTable.manga)
|
||||
if (activeFilters.categoryId != null) {
|
||||
baseJoin = baseJoin.join(CategoryMangaTable, JoinType.LEFT, MangaTable.id, CategoryMangaTable.manga)
|
||||
}
|
||||
|
||||
val query =
|
||||
baseJoin
|
||||
.select(MangaTable.genre)
|
||||
.where { MangaTable.inLibrary eq true }
|
||||
|
||||
@@ -321,11 +328,16 @@ object NavigationRepository {
|
||||
val statusCounts =
|
||||
transaction {
|
||||
val countExpr = MangaTable.id.countDistinct().alias("manga_count")
|
||||
val query =
|
||||
|
||||
var baseJoin =
|
||||
MangaTable
|
||||
.join(SourceTable, JoinType.INNER, MangaTable.sourceReference, SourceTable.id)
|
||||
.join(CategoryMangaTable, JoinType.LEFT, MangaTable.id, CategoryMangaTable.manga)
|
||||
.join(ChapterTable, JoinType.LEFT, MangaTable.id, ChapterTable.manga)
|
||||
if (activeFilters.categoryId != null) {
|
||||
baseJoin = baseJoin.join(CategoryMangaTable, JoinType.LEFT, MangaTable.id, CategoryMangaTable.manga)
|
||||
}
|
||||
|
||||
val query =
|
||||
baseJoin
|
||||
.select(MangaTable.status, countExpr)
|
||||
.where { MangaTable.inLibrary eq true }
|
||||
|
||||
@@ -368,11 +380,16 @@ object NavigationRepository {
|
||||
): Pair<List<OpdsLanguageNavEntry>, Long> =
|
||||
transaction {
|
||||
val mangaCount = MangaTable.id.countDistinct().alias("manga_count")
|
||||
val query =
|
||||
|
||||
var baseJoin =
|
||||
SourceTable
|
||||
.join(MangaTable, JoinType.INNER, SourceTable.id, MangaTable.sourceReference)
|
||||
.join(CategoryMangaTable, JoinType.LEFT, MangaTable.id, CategoryMangaTable.manga)
|
||||
.join(ChapterTable, JoinType.LEFT, MangaTable.id, ChapterTable.manga)
|
||||
if (activeFilters.categoryId != null) {
|
||||
baseJoin = baseJoin.join(CategoryMangaTable, JoinType.LEFT, MangaTable.id, CategoryMangaTable.manga)
|
||||
}
|
||||
|
||||
val query =
|
||||
baseJoin
|
||||
.select(SourceTable.lang, mangaCount)
|
||||
.where { MangaTable.inLibrary eq true }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user