Feature/backup suwayomi data (#1430)

* Export meta data

* Import meta data

* Add missing "opdsUseBinaryFileSize" setting to gql

* Export server settings

* Import server settings

* Streamline server config enum handling

* Use "restore amount" in backup import progress
This commit is contained in:
schroda
2025-06-15 23:14:13 +02:00
committed by GitHub
parent 483e3a760f
commit 4086a73727
29 changed files with 662 additions and 155 deletions
@@ -1,9 +1,10 @@
package suwayomi.tachidesk.global.impl
import org.jetbrains.exposed.sql.insert
import org.jetbrains.exposed.dao.id.EntityID
import org.jetbrains.exposed.sql.batchInsert
import org.jetbrains.exposed.sql.selectAll
import org.jetbrains.exposed.sql.statements.BatchUpdateStatement
import org.jetbrains.exposed.sql.transactions.transaction
import org.jetbrains.exposed.sql.update
import suwayomi.tachidesk.global.model.table.GlobalMetaTable
/*
@@ -18,20 +19,32 @@ object GlobalMeta {
key: String,
value: String,
) {
transaction {
val meta =
transaction {
GlobalMetaTable.selectAll().where { GlobalMetaTable.key eq key }
}.firstOrNull()
modifyMetas(mapOf(key to value))
}
if (meta == null) {
GlobalMetaTable.insert {
it[GlobalMetaTable.key] = key
it[GlobalMetaTable.value] = value
fun modifyMetas(meta: Map<String, String>) {
transaction {
val dbMetaMap =
GlobalMetaTable
.selectAll()
.where { GlobalMetaTable.key inList meta.keys }
.associateBy { it[GlobalMetaTable.key] }
val (existingMeta, newMeta) = meta.toList().partition { (key) -> key in dbMetaMap.keys }
if (existingMeta.isNotEmpty()) {
BatchUpdateStatement(GlobalMetaTable).apply {
existingMeta.forEach { (key, value) ->
addBatch(EntityID(dbMetaMap[key]!![GlobalMetaTable.id].value, GlobalMetaTable))
this[GlobalMetaTable.value] = value
}
execute(this@transaction)
}
} else {
GlobalMetaTable.update({ GlobalMetaTable.key eq key }) {
it[GlobalMetaTable.value] = value
}
if (newMeta.isNotEmpty()) {
GlobalMetaTable.batchInsert(newMeta) { (key, value) ->
this[GlobalMetaTable.key] = key
this[GlobalMetaTable.value] = value
}
}
}
@@ -64,6 +64,8 @@ class BackupMutation {
includeChapters = input?.includeChapters ?: true,
includeTracking = true,
includeHistory = true,
includeClientData = true,
includeServerSettings = true,
),
)
@@ -1,5 +1,6 @@
package suwayomi.tachidesk.graphql.mutations
import com.expediagroup.graphql.generator.annotations.GraphQLIgnore
import kotlinx.coroutines.flow.MutableStateFlow
import suwayomi.tachidesk.graphql.types.PartialSettingsType
import suwayomi.tachidesk.graphql.types.Settings
@@ -110,7 +111,8 @@ class SettingsMutation {
configSetting.value = newSetting
}
private fun updateSettings(settings: Settings) {
@GraphQLIgnore
fun updateSettings(settings: Settings) {
updateSetting(settings.ip, serverConfig.ip)
updateSetting(settings.port, serverConfig.port)
@@ -123,11 +125,11 @@ class SettingsMutation {
updateSetting(settings.socksProxyPassword, serverConfig.socksProxyPassword)
// webUI
updateSetting(settings.webUIFlavor?.uiName, serverConfig.webUIFlavor)
updateSetting(settings.webUIFlavor, serverConfig.webUIFlavor)
updateSetting(settings.initialOpenInBrowserEnabled, serverConfig.initialOpenInBrowserEnabled)
updateSetting(settings.webUIInterface?.name?.lowercase(), serverConfig.webUIInterface)
updateSetting(settings.webUIInterface, serverConfig.webUIInterface)
updateSetting(settings.electronPath, serverConfig.electronPath)
updateSetting(settings.webUIChannel?.name?.lowercase(), serverConfig.webUIChannel)
updateSetting(settings.webUIChannel, serverConfig.webUIChannel)
updateSetting(settings.webUIUpdateCheckInterval, serverConfig.webUIUpdateCheckInterval)
// downloader
@@ -182,6 +184,7 @@ class SettingsMutation {
updateSetting(settings.flareSolverrAsResponseFallback, serverConfig.flareSolverrAsResponseFallback)
// opds
updateSetting(settings.opdsUseBinaryFileSizes, serverConfig.opdsUseBinaryFileSizes)
updateSetting(settings.opdsItemsPerPage, serverConfig.opdsItemsPerPage)
updateSetting(settings.opdsEnablePageReadProgress, serverConfig.opdsEnablePageReadProgress)
updateSetting(settings.opdsMarkAsReadOnDownload, serverConfig.opdsMarkAsReadOnDownload)
@@ -3,7 +3,6 @@ package suwayomi.tachidesk.graphql.queries
import com.expediagroup.graphql.generator.annotations.GraphQLDeprecated
import suwayomi.tachidesk.global.impl.AppUpdate
import suwayomi.tachidesk.graphql.types.AboutWebUI
import suwayomi.tachidesk.graphql.types.WebUIChannel
import suwayomi.tachidesk.graphql.types.WebUIFlavor
import suwayomi.tachidesk.graphql.types.WebUIUpdateCheck
import suwayomi.tachidesk.graphql.types.WebUIUpdateStatus
@@ -63,7 +62,7 @@ class InfoQuery {
future {
val (version, updateAvailable) = WebInterfaceManager.isUpdateAvailable(WebUIFlavor.current, raiseError = true)
WebUIUpdateCheck(
channel = WebUIChannel.from(serverConfig.webUIChannel.value),
channel = serverConfig.webUIChannel.value,
tag = version,
updateAvailable,
)
@@ -8,6 +8,8 @@ enum class BackupRestoreState {
FAILURE,
RESTORING_CATEGORIES,
RESTORING_MANGA,
RESTORING_META,
RESTORING_SETTINGS,
}
data class BackupRestoreStatus(
@@ -40,7 +42,19 @@ fun ProtoBackupImport.BackupRestoreState.toStatus(): BackupRestoreStatus =
BackupRestoreStatus(
state = BackupRestoreState.RESTORING_CATEGORIES,
totalManga = totalManga,
mangaProgress = 0,
mangaProgress = current,
)
is ProtoBackupImport.BackupRestoreState.RestoringMeta ->
BackupRestoreStatus(
state = BackupRestoreState.RESTORING_META,
totalManga = totalManga,
mangaProgress = current,
)
is ProtoBackupImport.BackupRestoreState.RestoringSettings ->
BackupRestoreStatus(
state = BackupRestoreState.RESTORING_SETTINGS,
totalManga = totalManga,
mangaProgress = current,
)
is ProtoBackupImport.BackupRestoreState.RestoringManga ->
BackupRestoreStatus(
@@ -95,6 +95,7 @@ interface Settings : Node {
val flareSolverrAsResponseFallback: Boolean?
// opds
val opdsUseBinaryFileSizes: Boolean?
val opdsItemsPerPage: Int?
val opdsEnablePageReadProgress: Boolean?
val opdsMarkAsReadOnDownload: Boolean?
@@ -169,6 +170,7 @@ data class PartialSettingsType(
override val flareSolverrSessionTtl: Int?,
override val flareSolverrAsResponseFallback: Boolean?,
// opds
override val opdsUseBinaryFileSizes: Boolean?,
override val opdsItemsPerPage: Int?,
override val opdsEnablePageReadProgress: Boolean?,
override val opdsMarkAsReadOnDownload: Boolean?,
@@ -243,6 +245,7 @@ class SettingsType(
override val flareSolverrSessionTtl: Int,
override val flareSolverrAsResponseFallback: Boolean,
// opds
override val opdsUseBinaryFileSizes: Boolean,
override val opdsItemsPerPage: Int,
override val opdsEnablePageReadProgress: Boolean,
override val opdsMarkAsReadOnDownload: Boolean,
@@ -261,11 +264,11 @@ class SettingsType(
config.socksProxyUsername.value,
config.socksProxyPassword.value,
// webUI
WebUIFlavor.from(config.webUIFlavor.value),
config.webUIFlavor.value,
config.initialOpenInBrowserEnabled.value,
WebUIInterface.from(config.webUIInterface.value),
config.webUIInterface.value,
config.electronPath.value,
WebUIChannel.from(config.webUIChannel.value),
config.webUIChannel.value,
config.webUIUpdateCheckInterval.value,
// downloader
config.downloadAsCbz.value,
@@ -311,6 +314,7 @@ class SettingsType(
config.flareSolverrSessionTtl.value,
config.flareSolverrAsResponseFallback.value,
// opds
config.opdsUseBinaryFileSizes.value,
config.opdsItemsPerPage.value,
config.opdsEnablePageReadProgress.value,
config.opdsMarkAsReadOnDownload.value,
@@ -34,11 +34,6 @@ data class WebUIUpdateStatus(
enum class WebUIInterface {
BROWSER,
ELECTRON,
;
companion object {
fun from(value: String): WebUIInterface = entries.find { it.name.lowercase() == value.lowercase() } ?: BROWSER
}
}
enum class WebUIChannel {
@@ -49,8 +44,6 @@ enum class WebUIChannel {
companion object {
fun from(channel: String): WebUIChannel = entries.find { it.name.lowercase() == channel.lowercase() } ?: STABLE
fun doesConfigChannelEqual(channel: WebUIChannel): Boolean = serverConfig.webUIChannel.value.equals(channel.name, true)
}
}
@@ -92,6 +85,6 @@ enum class WebUIFlavor(
fun from(value: String): WebUIFlavor = entries.find { it.uiName == value } ?: default
val current: WebUIFlavor
get() = from(serverConfig.webUIFlavor.value)
get() = serverConfig.webUIFlavor.value
}
}
@@ -90,6 +90,8 @@ object BackupController {
includeChapters = true,
includeTracking = true,
includeHistory = true,
includeClientData = true,
includeServerSettings = true,
),
)
}.thenApply { ctx.result(it) }
@@ -122,6 +124,8 @@ object BackupController {
includeChapters = true,
includeTracking = true,
includeHistory = true,
includeClientData = true,
includeServerSettings = true,
),
)
}.thenApply { ctx.result(it) }
@@ -7,14 +7,15 @@ package suwayomi.tachidesk.manga.impl
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import org.jetbrains.exposed.dao.id.EntityID
import org.jetbrains.exposed.sql.SortOrder
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.andWhere
import org.jetbrains.exposed.sql.batchInsert
import org.jetbrains.exposed.sql.deleteWhere
import org.jetbrains.exposed.sql.insert
import org.jetbrains.exposed.sql.selectAll
import org.jetbrains.exposed.sql.statements.BatchUpdateStatement
import org.jetbrains.exposed.sql.transactions.transaction
import org.jetbrains.exposed.sql.update
import suwayomi.tachidesk.manga.model.dataclass.CategoryDataClass
@@ -23,6 +24,8 @@ import suwayomi.tachidesk.manga.model.table.CategoryMetaTable
import suwayomi.tachidesk.manga.model.table.CategoryTable
import suwayomi.tachidesk.manga.model.table.MangaTable
import suwayomi.tachidesk.manga.model.table.toDataClass
import kotlin.collections.component1
import kotlin.collections.orEmpty
object Category {
/**
@@ -193,26 +196,72 @@ object Category {
.associate { it[CategoryMetaTable.key] to it[CategoryMetaTable.value] }
}
fun getCategoriesMetaMaps(ids: List<Int>): Map<Int, Map<String, String>> =
transaction {
CategoryMetaTable
.selectAll()
.where { CategoryMetaTable.ref inList ids }
.groupBy { it[CategoryMetaTable.ref].value }
.mapValues { it.value.associate { it[CategoryMetaTable.key] to it[CategoryMetaTable.value] } }
.withDefault { emptyMap() }
}
fun modifyMeta(
categoryId: Int,
key: String,
value: String,
) {
transaction {
val meta =
transaction {
CategoryMetaTable.selectAll().where { (CategoryMetaTable.ref eq categoryId) and (CategoryMetaTable.key eq key) }
}.firstOrNull()
modifyCategoriesMetas(mapOf(categoryId to mapOf(key to value)))
}
if (meta == null) {
CategoryMetaTable.insert {
it[CategoryMetaTable.key] = key
it[CategoryMetaTable.value] = value
it[CategoryMetaTable.ref] = categoryId
fun modifyCategoriesMetas(metaByCategoryId: Map<Int, Map<String, String>>) {
transaction {
val categoryIds = metaByCategoryId.keys
val metaKeys = metaByCategoryId.flatMap { it.value.keys }
val dbMetaByCategoryId =
CategoryMetaTable
.selectAll()
.where { (CategoryMetaTable.ref inList categoryIds) and (CategoryMetaTable.key inList metaKeys) }
.groupBy { it[CategoryMetaTable.ref].value }
val existingMetaByMetaId =
categoryIds.flatMap { categoryId ->
val dbMetaByKey = dbMetaByCategoryId[categoryId].orEmpty().associateBy { it[CategoryMetaTable.key] }
val existingMetas = metaByCategoryId[categoryId].orEmpty().filter { (key) -> key in dbMetaByKey.keys }
existingMetas.map { entry ->
val metaId = dbMetaByKey[entry.key]!![CategoryMetaTable.id].value
metaId to entry
}
}
} else {
CategoryMetaTable.update({ (CategoryMetaTable.ref eq categoryId) and (CategoryMetaTable.key eq key) }) {
it[CategoryMetaTable.value] = value
val newMetaByCategoryId =
categoryIds.flatMap { categoryID ->
val dbMetaByKey = dbMetaByCategoryId[categoryID].orEmpty().associateBy { it[CategoryMetaTable.key] }
metaByCategoryId[categoryID]
.orEmpty()
.filter { entry -> entry.key !in dbMetaByKey.keys }
.map { entry -> categoryID to entry }
}
if (existingMetaByMetaId.isNotEmpty()) {
BatchUpdateStatement(CategoryMetaTable).apply {
existingMetaByMetaId.forEach { (metaId, entry) ->
addBatch(EntityID(metaId, CategoryMetaTable))
this[CategoryMetaTable.value] = entry.value
}
execute(this@transaction)
}
}
if (newMetaByCategoryId.isNotEmpty()) {
CategoryMetaTable.batchInsert(newMetaByCategoryId) { (categoryId, entry) ->
this[CategoryMetaTable.ref] = EntityID(categoryId, CategoryTable)
this[CategoryMetaTable.key] = entry.key
this[CategoryMetaTable.value] = entry.value
}
}
}
@@ -24,7 +24,6 @@ import org.jetbrains.exposed.sql.SqlExpressionBuilder.inList
import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.batchInsert
import org.jetbrains.exposed.sql.deleteWhere
import org.jetbrains.exposed.sql.insert
import org.jetbrains.exposed.sql.selectAll
import org.jetbrains.exposed.sql.statements.BatchUpdateStatement
import org.jetbrains.exposed.sql.transactions.transaction
@@ -102,7 +101,7 @@ object Chapter {
}
val chapterIds = chapterList.map { dbChapterMap.getValue(it.url)[ChapterTable.id] }
val chapterMetas = getChaptersMetaMaps(chapterIds)
val chapterMetas = getChaptersMetaMaps(chapterIds.map { it.value })
return chapterList.mapIndexed { index, it ->
@@ -126,7 +125,7 @@ object Chapter {
downloaded = dbChapter[ChapterTable.isDownloaded],
pageCount = dbChapter[ChapterTable.pageCount],
chapterCount = chapterList.size,
meta = chapterMetas.getValue(dbChapter[ChapterTable.id]),
meta = chapterMetas.getValue(dbChapter[ChapterTable.id].value),
)
}
}
@@ -553,12 +552,12 @@ object Chapter {
}
}
fun getChaptersMetaMaps(chapterIds: List<EntityID<Int>>): Map<EntityID<Int>, Map<String, String>> =
fun getChaptersMetaMaps(chapterIds: List<Int>): Map<Int, Map<String, String>> =
transaction {
ChapterMetaTable
.selectAll()
.where { ChapterMetaTable.ref inList chapterIds }
.groupBy { it[ChapterMetaTable.ref] }
.groupBy { it[ChapterMetaTable.ref].value }
.mapValues { it.value.associate { it[ChapterMetaTable.key] to it[ChapterMetaTable.value] } }
.withDefault { emptyMap() }
}
@@ -593,22 +592,57 @@ object Chapter {
key: String,
value: String,
) {
modifyChaptersMetas(mapOf(chapterId to mapOf(key to value)))
}
fun modifyChaptersMetas(metaByChapterId: Map<Int, Map<String, String>>) {
transaction {
val meta =
val chapterIds = metaByChapterId.keys
val metaKeys = metaByChapterId.flatMap { it.value.keys }
val dbMetaByChapterId =
ChapterMetaTable
.selectAll()
.where { (ChapterMetaTable.ref eq chapterId) and (ChapterMetaTable.key eq key) }
.firstOrNull()
.where { (ChapterMetaTable.ref inList chapterIds) and (ChapterMetaTable.key inList metaKeys) }
.groupBy { it[ChapterMetaTable.ref].value }
if (meta == null) {
ChapterMetaTable.insert {
it[ChapterMetaTable.key] = key
it[ChapterMetaTable.value] = value
it[ref] = chapterId
val existingMetaByMetaId =
chapterIds.flatMap { chapterId ->
val dbMetaByKey = dbMetaByChapterId[chapterId].orEmpty().associateBy { it[ChapterMetaTable.key] }
val existingMetas = metaByChapterId[chapterId].orEmpty().filter { (key) -> key in dbMetaByKey.keys }
existingMetas.map { entry ->
val metaId = dbMetaByKey[entry.key]!![ChapterMetaTable.id].value
metaId to entry
}
}
} else {
ChapterMetaTable.update({ (ChapterMetaTable.ref eq chapterId) and (ChapterMetaTable.key eq key) }) {
it[ChapterMetaTable.value] = value
val newMetaByChapterId =
chapterIds.flatMap { chapterId ->
val dbMetaByKey = dbMetaByChapterId[chapterId].orEmpty().associateBy { it[ChapterMetaTable.key] }
metaByChapterId[chapterId]
.orEmpty()
.filter { entry -> entry.key !in dbMetaByKey.keys }
.map { entry -> chapterId to entry }
}
if (existingMetaByMetaId.isNotEmpty()) {
BatchUpdateStatement(ChapterMetaTable).apply {
existingMetaByMetaId.forEach { (metaId, entry) ->
addBatch(EntityID(metaId, ChapterMetaTable))
this[ChapterMetaTable.value] = entry.value
}
execute(this@transaction)
}
}
if (newMetaByChapterId.isNotEmpty()) {
ChapterMetaTable.batchInsert(newMetaByChapterId) { (chapterId, entry) ->
this[ChapterMetaTable.ref] = EntityID(chapterId, ChapterTable)
this[ChapterMetaTable.key] = entry.key
this[ChapterMetaTable.value] = entry.value
}
}
}
@@ -20,11 +20,13 @@ import io.github.oshai.kotlinlogging.KotlinLogging
import io.javalin.http.HttpStatus
import okhttp3.CacheControl
import okhttp3.Response
import org.jetbrains.exposed.dao.id.EntityID
import org.jetbrains.exposed.sql.ResultRow
import org.jetbrains.exposed.sql.SortOrder
import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.insert
import org.jetbrains.exposed.sql.batchInsert
import org.jetbrains.exposed.sql.selectAll
import org.jetbrains.exposed.sql.statements.BatchUpdateStatement
import org.jetbrains.exposed.sql.transactions.transaction
import org.jetbrains.exposed.sql.update
import suwayomi.tachidesk.manga.impl.MangaList.proxyThumbnailUrl
@@ -256,22 +258,57 @@ object Manga {
key: String,
value: String,
) {
modifyMangasMetas(mapOf(mangaId to mapOf(key to value)))
}
fun modifyMangasMetas(metaByMangaId: Map<Int, Map<String, String>>) {
transaction {
val meta =
val mangaIds = metaByMangaId.keys
val metaKeys = metaByMangaId.flatMap { it.value.keys }
val dbMetaByMangaId =
MangaMetaTable
.selectAll()
.where { (MangaMetaTable.ref eq mangaId) and (MangaMetaTable.key eq key) }
.firstOrNull()
.where { (MangaMetaTable.ref inList mangaIds) and (MangaMetaTable.key inList metaKeys) }
.groupBy { it[MangaMetaTable.ref].value }
if (meta == null) {
MangaMetaTable.insert {
it[MangaMetaTable.key] = key
it[MangaMetaTable.value] = value
it[MangaMetaTable.ref] = mangaId
val existingMetaByMetaId =
mangaIds.flatMap { mangaId ->
val metaByKey = dbMetaByMangaId[mangaId].orEmpty().associateBy { it[MangaMetaTable.key] }
val existingMetas = metaByMangaId[mangaId].orEmpty().filter { (key) -> key in metaByKey.keys }
existingMetas.map { entry ->
val metaId = metaByKey[entry.key]!![MangaMetaTable.id].value
metaId to entry
}
}
} else {
MangaMetaTable.update({ (MangaMetaTable.ref eq mangaId) and (MangaMetaTable.key eq key) }) {
it[MangaMetaTable.value] = value
val newMetaByMangaId =
mangaIds.flatMap { mangaId ->
val metaByKey = dbMetaByMangaId[mangaId].orEmpty().associateBy { it[MangaMetaTable.key] }
metaByMangaId[mangaId]
.orEmpty()
.filter { entry -> entry.key !in metaByKey.keys }
.map { entry -> mangaId to entry }
}
if (existingMetaByMetaId.isNotEmpty()) {
BatchUpdateStatement(MangaMetaTable).apply {
existingMetaByMetaId.forEach { (metaId, entry) ->
addBatch(EntityID(metaId, MangaMetaTable))
this[MangaMetaTable.value] = entry.value
}
execute(this@transaction)
}
}
if (newMetaByMangaId.isNotEmpty()) {
MangaMetaTable.batchInsert(newMetaByMangaId) { (mangaId, entry) ->
this[MangaMetaTable.ref] = EntityID(mangaId, MangaTable)
this[MangaMetaTable.key] = entry.key
this[MangaMetaTable.value] = entry.value
}
}
}
@@ -14,11 +14,12 @@ import eu.kanade.tachiyomi.source.sourcePreferences
import io.github.oshai.kotlinlogging.KotlinLogging
import io.javalin.json.JsonMapper
import io.javalin.json.fromJsonString
import org.jetbrains.exposed.dao.id.EntityID
import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.insert
import org.jetbrains.exposed.sql.batchInsert
import org.jetbrains.exposed.sql.selectAll
import org.jetbrains.exposed.sql.statements.BatchUpdateStatement
import org.jetbrains.exposed.sql.transactions.transaction
import org.jetbrains.exposed.sql.update
import suwayomi.tachidesk.manga.impl.extension.Extension.getExtensionIconUrl
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogueSourceOrNull
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogueSourceOrStub
@@ -27,7 +28,6 @@ import suwayomi.tachidesk.manga.model.dataclass.SourceDataClass
import suwayomi.tachidesk.manga.model.table.ExtensionTable
import suwayomi.tachidesk.manga.model.table.SourceMetaTable
import suwayomi.tachidesk.manga.model.table.SourceTable
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
import xyz.nulldev.androidcompat.androidimpl.CustomContext
@@ -151,26 +151,72 @@ object Source {
unregisterCatalogueSource(sourceId)
}
fun getSourcesMetaMaps(ids: List<Long>): Map<Long, Map<String, String>> =
transaction {
SourceMetaTable
.selectAll()
.where { SourceMetaTable.ref inList ids }
.groupBy { it[SourceMetaTable.ref] }
.mapValues { it.value.associate { it[SourceMetaTable.key] to it[SourceMetaTable.value] } }
.withDefault { emptyMap() }
}
fun modifyMeta(
sourceId: Long,
key: String,
value: String,
) {
transaction {
val meta =
transaction {
SourceMetaTable.selectAll().where { (SourceMetaTable.ref eq sourceId) and (SourceMetaTable.key eq key) }
}.firstOrNull()
modifySourceMetas(mapOf(sourceId to mapOf(key to value)))
}
if (meta == null) {
SourceMetaTable.insert {
it[SourceMetaTable.key] = key
it[SourceMetaTable.value] = value
it[SourceMetaTable.ref] = sourceId
fun modifySourceMetas(metaBySourceIds: Map<Long, Map<String, String>>) {
transaction {
val sourceIds = metaBySourceIds.keys
val metaKeys = metaBySourceIds.flatMap { it.value.keys }
val dbMetaBySourceId =
SourceMetaTable
.selectAll()
.where { (SourceMetaTable.ref inList sourceIds) and (SourceMetaTable.key inList metaKeys) }
.groupBy { it[SourceMetaTable.ref] }
val existingMetaByMetaId =
sourceIds.flatMap { sourceId ->
val metaByKey = dbMetaBySourceId[sourceId].orEmpty().associateBy { it[SourceMetaTable.key] }
val existingMetas = metaBySourceIds[sourceId].orEmpty().filter { (key) -> key in metaByKey.keys }
existingMetas.map { entry ->
val metaId = metaByKey[entry.key]!![SourceMetaTable.id].value
metaId to entry
}
}
} else {
SourceMetaTable.update({ (SourceMetaTable.ref eq sourceId) and (SourceMetaTable.key eq key) }) {
it[SourceMetaTable.value] = value
val newMetaBySourceId =
sourceIds.flatMap { sourceId ->
val metaByKey = dbMetaBySourceId[sourceId].orEmpty().associateBy { it[SourceMetaTable.key] }
metaBySourceIds[sourceId]
.orEmpty()
.filter { entry -> entry.key !in metaByKey.keys }
.map { entry -> sourceId to entry }
}
if (existingMetaByMetaId.isNotEmpty()) {
BatchUpdateStatement(SourceMetaTable).apply {
existingMetaByMetaId.forEach { (metaId, entry) ->
addBatch(EntityID(metaId, SourceMetaTable))
this[SourceMetaTable.value] = entry.value
}
execute(this@transaction)
}
}
if (newMetaBySourceId.isNotEmpty()) {
SourceMetaTable.batchInsert(newMetaBySourceId) { (sourceId, entry) ->
this[SourceMetaTable.ref] = sourceId
this[SourceMetaTable.key] = entry.key
this[SourceMetaTable.value] = entry.value
}
}
}
@@ -13,4 +13,6 @@ data class BackupFlags(
val includeChapters: Boolean,
val includeTracking: Boolean,
val includeHistory: Boolean,
val includeClientData: Boolean,
val includeServerSettings: Boolean,
)
@@ -22,6 +22,8 @@ interface Chapter :
var source_order: Int
var meta: Map<String, String>
val isRecognizedNumber: Boolean
get() = chapter_number >= 0f
}
@@ -27,6 +27,8 @@ class ChapterImpl : Chapter {
override var source_order: Int = 0
override var meta: Map<String, String> = emptyMap()
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other == null || javaClass != other.javaClass) return false
@@ -37,6 +37,8 @@ interface Manga : SManga {
var cover_last_modified: Long
var meta: Map<String, String>
fun setChapterOrder(order: Int) {
setChapterFlags(order, CHAPTER_SORT_MASK)
}
@@ -37,6 +37,8 @@ open class MangaImpl : Manga {
override var initialized: Boolean = false
override var meta: Map<String, String> = emptyMap()
/** Reader mode value
* ref: https://github.com/tachiyomiorg/tachiyomi/blob/ff369010074b058bb734ce24c66508300e6e9ac6/app/src/main/java/eu/kanade/tachiyomi/ui/reader/setting/ReadingModeType.kt#L8
* 0 -> Default
@@ -24,13 +24,18 @@ import org.jetbrains.exposed.sql.Query
import org.jetbrains.exposed.sql.SortOrder
import org.jetbrains.exposed.sql.selectAll
import org.jetbrains.exposed.sql.transactions.transaction
import suwayomi.tachidesk.global.impl.GlobalMeta
import suwayomi.tachidesk.manga.impl.Category
import suwayomi.tachidesk.manga.impl.CategoryManga
import suwayomi.tachidesk.manga.impl.Chapter
import suwayomi.tachidesk.manga.impl.Manga
import suwayomi.tachidesk.manga.impl.Source
import suwayomi.tachidesk.manga.impl.backup.BackupFlags
import suwayomi.tachidesk.manga.impl.backup.proto.models.Backup
import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupCategory
import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupChapter
import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupManga
import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupServerSettings
import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupSource
import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupTracking
import suwayomi.tachidesk.manga.impl.track.Track
@@ -123,6 +128,8 @@ object ProtoBackupExport : ProtoBackupBase() {
includeChapters = true,
includeTracking = true,
includeHistory = true,
includeClientData = true,
includeServerSettings = true,
),
).use { input ->
val automatedBackupDir = File(applicationDirs.automatedBackupRoot)
@@ -181,8 +188,10 @@ object ProtoBackupExport : ProtoBackupBase() {
transaction {
Backup(
backupManga(databaseManga, flags),
backupCategories(),
backupExtensionInfo(databaseManga),
backupCategories(flags),
backupExtensionInfo(databaseManga, flags),
backupGlobalMeta(flags),
backupServerSettings(flags),
)
}
@@ -220,6 +229,10 @@ object ProtoBackupExport : ProtoBackupBase() {
val mangaId = mangaRow[MangaTable.id].value
if (flags.includeClientData) {
backupManga.meta = Manga.getMangaMetaMap(mangaId)
}
if (flags.includeChapters) {
val chapters =
transaction {
@@ -231,6 +244,7 @@ object ProtoBackupExport : ProtoBackupBase() {
ChapterTable.toDataClass(it)
}
}
val chapterToMeta = Chapter.getChaptersMetaMaps(chapters.map { it.id })
backupManga.chapters =
chapters.map {
@@ -245,7 +259,11 @@ object ProtoBackupExport : ProtoBackupBase() {
it.uploadDate,
it.chapterNumber,
chapters.size - it.index,
)
).apply {
if (flags.includeClientData) {
this.meta = chapterToMeta[it.id] ?: emptyMap()
}
}
}
}
@@ -287,31 +305,135 @@ object ProtoBackupExport : ProtoBackupBase() {
backupManga
}
private fun backupCategories(): List<BackupCategory> =
CategoryTable
.selectAll()
.orderBy(CategoryTable.order to SortOrder.ASC)
.map {
CategoryTable.toDataClass(it)
}.filter { it.id != Category.DEFAULT_CATEGORY_ID }
.map {
BackupCategory(
it.name,
it.order,
0, // not supported in Tachidesk
)
}
private fun backupCategories(flags: BackupFlags): List<BackupCategory> {
val categories =
CategoryTable
.selectAll()
.orderBy(CategoryTable.order to SortOrder.ASC)
.map { CategoryTable.toDataClass(it) }
val categoryToMeta = Category.getCategoriesMetaMaps(categories.map { it.id })
private fun backupExtensionInfo(mangas: Query): List<BackupSource> =
mangas
.asSequence()
.map { it[MangaTable.sourceReference] }
.distinct()
.map {
val sourceRow = SourceTable.selectAll().where { SourceTable.id eq it }.firstOrNull()
return categories.map {
BackupCategory(
it.name,
it.order,
0, // not supported in Tachidesk
).apply {
if (flags.includeClientData) {
this.meta = categoryToMeta[it.id] ?: emptyMap()
}
}
}
}
private fun backupExtensionInfo(
mangas: Query,
flags: BackupFlags,
): List<BackupSource> {
val inLibraryMangaSourceIds =
mangas
.asSequence()
.map { it[MangaTable.sourceReference] }
.distinct()
.toList()
val sources = SourceTable.selectAll().where { SourceTable.id inList inLibraryMangaSourceIds }
val sourceToMeta = Source.getSourcesMetaMaps(sources.map { it[SourceTable.id].value })
return inLibraryMangaSourceIds
.map { mangaSourceId ->
val source = sources.firstOrNull { it[SourceTable.id].value == mangaSourceId }
BackupSource(
sourceRow?.get(SourceTable.name) ?: "",
it,
)
source?.get(SourceTable.name) ?: "",
mangaSourceId,
).apply {
if (flags.includeClientData) {
this.meta = sourceToMeta[mangaSourceId] ?: emptyMap()
}
}
}.toList()
}
private fun backupGlobalMeta(flags: BackupFlags): Map<String, String> {
if (!flags.includeClientData) {
return emptyMap()
}
return GlobalMeta.getMetaMap()
}
private fun backupServerSettings(flags: BackupFlags): BackupServerSettings? {
if (!flags.includeServerSettings) {
return null
}
return BackupServerSettings(
ip = serverConfig.ip.value,
port = serverConfig.port.value,
// socks
socksProxyEnabled = serverConfig.socksProxyEnabled.value,
socksProxyVersion = serverConfig.socksProxyVersion.value,
socksProxyHost = serverConfig.socksProxyHost.value,
socksProxyPort = serverConfig.socksProxyPort.value,
socksProxyUsername = serverConfig.socksProxyUsername.value,
socksProxyPassword = serverConfig.socksProxyPassword.value,
// webUI
webUIFlavor = serverConfig.webUIFlavor.value,
initialOpenInBrowserEnabled = serverConfig.initialOpenInBrowserEnabled.value,
webUIInterface = serverConfig.webUIInterface.value,
electronPath = serverConfig.electronPath.value,
webUIChannel = serverConfig.webUIChannel.value,
webUIUpdateCheckInterval = serverConfig.webUIUpdateCheckInterval.value,
// downloader
downloadAsCbz = serverConfig.downloadAsCbz.value,
downloadsPath = serverConfig.downloadsPath.value,
autoDownloadNewChapters = serverConfig.autoDownloadNewChapters.value,
excludeEntryWithUnreadChapters = serverConfig.excludeEntryWithUnreadChapters.value,
autoDownloadAheadLimit = 0, // deprecated
autoDownloadNewChaptersLimit = serverConfig.autoDownloadNewChaptersLimit.value,
autoDownloadIgnoreReUploads = serverConfig.autoDownloadIgnoreReUploads.value,
// extension
extensionRepos = serverConfig.extensionRepos.value,
// requests
maxSourcesInParallel = serverConfig.maxSourcesInParallel.value,
// updater
excludeUnreadChapters = serverConfig.excludeUnreadChapters.value,
excludeNotStarted = serverConfig.excludeNotStarted.value,
excludeCompleted = serverConfig.excludeCompleted.value,
globalUpdateInterval = serverConfig.globalUpdateInterval.value,
updateMangas = serverConfig.updateMangas.value,
// Authentication
basicAuthEnabled = serverConfig.basicAuthEnabled.value,
basicAuthUsername = serverConfig.basicAuthUsername.value,
basicAuthPassword = serverConfig.basicAuthPassword.value,
// misc
debugLogsEnabled = serverConfig.debugLogsEnabled.value,
gqlDebugLogsEnabled = false, // deprecated
systemTrayEnabled = serverConfig.systemTrayEnabled.value,
maxLogFiles = serverConfig.maxLogFiles.value,
maxLogFileSize = serverConfig.maxLogFileSize.value,
maxLogFolderSize = serverConfig.maxLogFolderSize.value,
// backup
backupPath = serverConfig.backupPath.value,
backupTime = serverConfig.backupTime.value,
backupInterval = serverConfig.backupInterval.value,
backupTTL = serverConfig.backupTTL.value,
// local source
localSourcePath = serverConfig.localSourcePath.value,
// cloudflare bypass
flareSolverrEnabled = serverConfig.flareSolverrEnabled.value,
flareSolverrUrl = serverConfig.flareSolverrUrl.value,
flareSolverrTimeout = serverConfig.flareSolverrTimeout.value,
flareSolverrSessionName = serverConfig.flareSolverrSessionName.value,
flareSolverrSessionTtl = serverConfig.flareSolverrSessionTtl.value,
flareSolverrAsResponseFallback = serverConfig.flareSolverrAsResponseFallback.value,
// opds
opdsUseBinaryFileSizes = serverConfig.opdsUseBinaryFileSizes.value,
opdsItemsPerPage = serverConfig.opdsItemsPerPage.value,
opdsEnablePageReadProgress = serverConfig.opdsEnablePageReadProgress.value,
opdsMarkAsReadOnDownload = serverConfig.opdsMarkAsReadOnDownload.value,
opdsShowOnlyUnreadChapters = serverConfig.opdsShowOnlyUnreadChapters.value,
opdsShowOnlyDownloadedChapters = serverConfig.opdsShowOnlyDownloadedChapters.value,
opdsChapterSortOrder = serverConfig.opdsChapterSortOrder.value,
)
}
}
@@ -30,10 +30,16 @@ import org.jetbrains.exposed.sql.selectAll
import org.jetbrains.exposed.sql.statements.BatchUpdateStatement
import org.jetbrains.exposed.sql.transactions.transaction
import org.jetbrains.exposed.sql.update
import suwayomi.tachidesk.global.impl.GlobalMeta
import suwayomi.tachidesk.graphql.mutations.SettingsMutation
import suwayomi.tachidesk.graphql.types.toStatus
import suwayomi.tachidesk.manga.impl.Category
import suwayomi.tachidesk.manga.impl.Category.modifyCategoriesMetas
import suwayomi.tachidesk.manga.impl.CategoryManga
import suwayomi.tachidesk.manga.impl.Chapter.modifyChaptersMetas
import suwayomi.tachidesk.manga.impl.Manga.clearThumbnail
import suwayomi.tachidesk.manga.impl.Manga.modifyMangasMetas
import suwayomi.tachidesk.manga.impl.Source.modifySourceMetas
import suwayomi.tachidesk.manga.impl.backup.models.Chapter
import suwayomi.tachidesk.manga.impl.backup.models.Manga
import suwayomi.tachidesk.manga.impl.backup.proto.ProtoBackupValidator.ValidationResult
@@ -42,6 +48,8 @@ import suwayomi.tachidesk.manga.impl.backup.proto.models.Backup
import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupCategory
import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupHistory
import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupManga
import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupServerSettings
import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupSource
import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupTracking
import suwayomi.tachidesk.manga.impl.track.tracker.TrackerManager
import suwayomi.tachidesk.manga.impl.track.tracker.model.toTrack
@@ -82,6 +90,17 @@ object ProtoBackupImport : ProtoBackupBase() {
data object Failure : BackupRestoreState()
data class RestoringCategories(
val current: Int,
val totalManga: Int,
) : BackupRestoreState()
data class RestoringMeta(
val current: Int,
val totalManga: Int,
) : BackupRestoreState()
data class RestoringSettings(
val current: Int,
val totalManga: Int,
) : BackupRestoreState()
@@ -177,12 +196,29 @@ object ProtoBackupImport : ProtoBackupBase() {
val validationResult = validate(backup)
restoreAmount = backup.backupManga.size + 1 // +1 for categories
val restoreCategories = 1
val restoreMeta = 1
val restoreSettings = 1
val getRestoreAmount = { size: Int -> size + restoreCategories + restoreMeta + restoreSettings }
restoreAmount = getRestoreAmount(backup.backupManga.size)
updateRestoreState(id, BackupRestoreState.RestoringCategories(backup.backupManga.size))
updateRestoreState(id, BackupRestoreState.RestoringCategories(restoreCategories, restoreAmount))
val categoryMapping = restoreCategories(backup.backupCategories)
updateRestoreState(id, BackupRestoreState.RestoringMeta(restoreCategories + restoreMeta, restoreAmount))
restoreGlobalMeta(backup.meta)
restoreSourceMeta(backup.backupSources)
updateRestoreState(
id,
BackupRestoreState.RestoringSettings(restoreCategories + restoreMeta + restoreSettings, restoreAmount),
)
restoreServerSettings(backup.serverSettings)
// Store source mapping for error messages
sourceMapping = backup.getSourceMap()
@@ -191,8 +227,8 @@ object ProtoBackupImport : ProtoBackupBase() {
updateRestoreState(
id,
BackupRestoreState.RestoringManga(
current = index + 1,
totalManga = backup.backupManga.size,
current = getRestoreAmount(index + 1),
totalManga = restoreAmount,
title = manga.title,
),
)
@@ -225,6 +261,15 @@ object ProtoBackupImport : ProtoBackupBase() {
private fun restoreCategories(backupCategories: List<BackupCategory>): Map<Int, Int> {
val categoryIds = Category.createCategories(backupCategories.map { it.name })
val metaEntryByCategoryId =
categoryIds
.zip(backupCategories)
.associate { (categoryId, backupCategory) ->
categoryId to backupCategory.meta
}
modifyCategoriesMetas(metaEntryByCategoryId)
return backupCategories.withIndex().associate { (index, backupCategory) ->
backupCategory.order to categoryIds[index]
}
@@ -318,6 +363,10 @@ object ProtoBackupImport : ProtoBackupBase() {
// delete thumbnail in case cached data still exists
clearThumbnail(mangaId)
if (manga.meta.isNotEmpty()) {
modifyMangasMetas(mapOf(mangaId to manga.meta))
}
// merge chapter data
restoreMangaChapterData(mangaId, restoreMode, chapters)
@@ -358,26 +407,28 @@ object ProtoBackupImport : ProtoBackupBase() {
) = dbTransaction {
val (chaptersToInsert, chaptersToUpdateToDbChapter) = getMangaChapterToRestoreInfo(mangaId, restoreMode, chapters)
ChapterTable.batchInsert(chaptersToInsert) { chapter ->
this[ChapterTable.url] = chapter.url
this[ChapterTable.name] = chapter.name
if (chapter.date_upload == 0L) {
this[ChapterTable.date_upload] = chapter.date_fetch
} else {
this[ChapterTable.date_upload] = chapter.date_upload
}
this[ChapterTable.chapter_number] = chapter.chapter_number
this[ChapterTable.scanlator] = chapter.scanlator
val insertedChapterIds =
ChapterTable
.batchInsert(chaptersToInsert) { chapter ->
this[ChapterTable.url] = chapter.url
this[ChapterTable.name] = chapter.name
if (chapter.date_upload == 0L) {
this[ChapterTable.date_upload] = chapter.date_fetch
} else {
this[ChapterTable.date_upload] = chapter.date_upload
}
this[ChapterTable.chapter_number] = chapter.chapter_number
this[ChapterTable.scanlator] = chapter.scanlator
this[ChapterTable.sourceOrder] = chaptersToInsert.size - chapter.source_order
this[ChapterTable.manga] = mangaId
this[ChapterTable.sourceOrder] = chaptersToInsert.size - chapter.source_order
this[ChapterTable.manga] = mangaId
this[ChapterTable.isRead] = chapter.read
this[ChapterTable.lastPageRead] = chapter.last_page_read.coerceAtLeast(0)
this[ChapterTable.isBookmarked] = chapter.bookmark
this[ChapterTable.isRead] = chapter.read
this[ChapterTable.lastPageRead] = chapter.last_page_read.coerceAtLeast(0)
this[ChapterTable.isBookmarked] = chapter.bookmark
this[ChapterTable.fetchedAt] = TimeUnit.MILLISECONDS.toSeconds(chapter.date_fetch)
}
this[ChapterTable.fetchedAt] = TimeUnit.MILLISECONDS.toSeconds(chapter.date_fetch)
}.map { it[ChapterTable.id].value }
if (chaptersToUpdateToDbChapter.isNotEmpty()) {
BatchUpdateStatement(ChapterTable).apply {
@@ -391,6 +442,20 @@ object ProtoBackupImport : ProtoBackupBase() {
execute(this@dbTransaction)
}
}
val chaptersToInsertByChapterId = insertedChapterIds.zip(chaptersToInsert)
val chapterToUpdateByChapterId =
chaptersToUpdateToDbChapter.map { (backupChapter, dbChapter) ->
dbChapter[ChapterTable.id].value to
backupChapter
}
val metaEntryByChapterId =
(chaptersToInsertByChapterId + chapterToUpdateByChapterId)
.associate { (chapterId, backupChapter) ->
chapterId to backupChapter.meta
}
modifyChaptersMetas(metaEntryByChapterId)
}
private fun restoreMangaCategoryData(
@@ -440,5 +505,21 @@ object ProtoBackupImport : ProtoBackupBase() {
Tracker.insertTrackRecords(newTracks)
}
private fun restoreGlobalMeta(meta: Map<String, String>) {
GlobalMeta.modifyMetas(meta)
}
private fun restoreSourceMeta(backupSources: List<BackupSource>) {
modifySourceMetas(backupSources.associateBy { it.sourceId }.mapValues { it.value.meta })
}
private fun restoreServerSettings(backupServerSettings: BackupServerSettings?) {
if (backupServerSettings == null) {
return
}
SettingsMutation().updateSettings(backupServerSettings)
}
private fun TrackRecordDataClass.forComparison() = this.copy(id = 0, mangaId = 0)
}
@@ -13,6 +13,9 @@ data class Backup(
// Bump by 100 to specify this is a 0.x value
// @ProtoNumber(100) var brokenBackupSources: List<BrokenBackupSource> = emptyList(),
@ProtoNumber(101) var backupSources: List<BackupSource> = emptyList(),
// suwayomi
@ProtoNumber(9000) var meta: Map<String, String> = emptyMap(),
@ProtoNumber(9001) var serverSettings: BackupServerSettings?,
) {
fun getSourceMap(): Map<Long, String> =
backupSources
@@ -10,4 +10,6 @@ class BackupCategory(
// @ProtoNumber(3) val updateInterval: Int = 0, 1.x value not used in 0.x
// Bump by 100 to specify this is a 0.x value
@ProtoNumber(100) var flags: Int = 0,
// suwayomi
@ProtoNumber(9000) var meta: Map<String, String> = emptyMap(),
)
@@ -20,6 +20,8 @@ data class BackupChapter(
// chapterNumber is called number is 1.x
@ProtoNumber(9) var chapterNumber: Float = 0F,
@ProtoNumber(10) var sourceOrder: Int = 0,
// suwayomi
@ProtoNumber(9000) var meta: Map<String, String> = emptyMap(),
) {
fun toChapterImpl(): ChapterImpl =
ChapterImpl().apply {
@@ -33,5 +35,6 @@ data class BackupChapter(
date_fetch = this@BackupChapter.dateFetch
date_upload = this@BackupChapter.dateUpload
source_order = this@BackupChapter.sourceOrder
meta = this@BackupChapter.meta
}
}
@@ -38,6 +38,8 @@ data class BackupManga(
@ProtoNumber(103) var viewer_flags: Int? = null,
@ProtoNumber(104) var history: List<BackupHistory> = emptyList(),
@ProtoNumber(105) var updateStrategy: UpdateStrategy = UpdateStrategy.ALWAYS_UPDATE,
// suwayomi
@ProtoNumber(9000) var meta: Map<String, String> = emptyMap(),
) {
fun getMangaImpl(): MangaImpl =
MangaImpl().apply {
@@ -55,6 +57,7 @@ data class BackupManga(
viewer_flags = this@BackupManga.viewer_flags ?: this@BackupManga.viewer
chapter_flags = this@BackupManga.chapterFlags
update_strategy = this@BackupManga.updateStrategy
meta = this@BackupManga.meta
}
fun getChaptersImpl(): List<ChapterImpl> =
@@ -0,0 +1,80 @@
package suwayomi.tachidesk.manga.impl.backup.proto.models
import kotlinx.serialization.Serializable
import kotlinx.serialization.protobuf.ProtoNumber
import org.jetbrains.exposed.sql.SortOrder
import suwayomi.tachidesk.graphql.types.Settings
import suwayomi.tachidesk.graphql.types.WebUIChannel
import suwayomi.tachidesk.graphql.types.WebUIFlavor
import suwayomi.tachidesk.graphql.types.WebUIInterface
@Serializable
data class BackupServerSettings(
@ProtoNumber(1) override var ip: String,
@ProtoNumber(2) override var port: Int,
// socks
@ProtoNumber(3) override var socksProxyEnabled: Boolean,
@ProtoNumber(4) override var socksProxyVersion: Int,
@ProtoNumber(5) override var socksProxyHost: String,
@ProtoNumber(6) override var socksProxyPort: String,
@ProtoNumber(7) override var socksProxyUsername: String,
@ProtoNumber(8) override var socksProxyPassword: String,
// webUI
@ProtoNumber(9) override var webUIFlavor: WebUIFlavor,
@ProtoNumber(10) override var initialOpenInBrowserEnabled: Boolean,
@ProtoNumber(11) override var webUIInterface: WebUIInterface,
@ProtoNumber(12) override var electronPath: String,
@ProtoNumber(13) override var webUIChannel: WebUIChannel,
@ProtoNumber(14) override var webUIUpdateCheckInterval: Double,
// downloader
@ProtoNumber(15) override var downloadAsCbz: Boolean,
@ProtoNumber(16) override var downloadsPath: String,
@ProtoNumber(17) override var autoDownloadNewChapters: Boolean,
@ProtoNumber(18) override var excludeEntryWithUnreadChapters: Boolean,
@ProtoNumber(19) override var autoDownloadAheadLimit: Int,
@ProtoNumber(20) override var autoDownloadNewChaptersLimit: Int,
@ProtoNumber(21) override var autoDownloadIgnoreReUploads: Boolean,
// extension
@ProtoNumber(22) override var extensionRepos: List<String>,
// requests
@ProtoNumber(23) override var maxSourcesInParallel: Int,
// updater
@ProtoNumber(24) override var excludeUnreadChapters: Boolean,
@ProtoNumber(25) override var excludeNotStarted: Boolean,
@ProtoNumber(26) override var excludeCompleted: Boolean,
@ProtoNumber(27) override var globalUpdateInterval: Double,
@ProtoNumber(28) override var updateMangas: Boolean,
// Authentication
@ProtoNumber(29) override var basicAuthEnabled: Boolean,
@ProtoNumber(30) override var basicAuthUsername: String,
@ProtoNumber(31) override var basicAuthPassword: String,
// misc
@ProtoNumber(32) override var debugLogsEnabled: Boolean,
@ProtoNumber(33) override var gqlDebugLogsEnabled: Boolean,
@ProtoNumber(34) override var systemTrayEnabled: Boolean,
@ProtoNumber(35) override var maxLogFiles: Int,
@ProtoNumber(36) override var maxLogFileSize: String,
@ProtoNumber(37) override var maxLogFolderSize: String,
// backup
@ProtoNumber(38) override var backupPath: String,
@ProtoNumber(39) override var backupTime: String,
@ProtoNumber(40) override var backupInterval: Int,
@ProtoNumber(41) override var backupTTL: Int,
// local source
@ProtoNumber(42) override var localSourcePath: String,
// cloudflare bypass
@ProtoNumber(43) override var flareSolverrEnabled: Boolean,
@ProtoNumber(44) override var flareSolverrUrl: String,
@ProtoNumber(45) override var flareSolverrTimeout: Int,
@ProtoNumber(46) override var flareSolverrSessionName: String,
@ProtoNumber(47) override var flareSolverrSessionTtl: Int,
@ProtoNumber(48) override var flareSolverrAsResponseFallback: Boolean,
// opds
@ProtoNumber(49) override var opdsUseBinaryFileSizes: Boolean,
@ProtoNumber(50) override var opdsItemsPerPage: Int,
@ProtoNumber(51) override var opdsEnablePageReadProgress: Boolean,
@ProtoNumber(52) override var opdsMarkAsReadOnDownload: Boolean,
@ProtoNumber(53) override var opdsShowOnlyUnreadChapters: Boolean,
@ProtoNumber(54) override var opdsShowOnlyDownloadedChapters: Boolean,
@ProtoNumber(55) override var opdsChapterSortOrder: SortOrder,
) : Settings
@@ -8,6 +8,8 @@ import kotlinx.serialization.protobuf.ProtoNumber
data class BackupSource(
@ProtoNumber(1) var name: String = "",
@ProtoNumber(2) var sourceId: Long,
// suwayomi
@ProtoNumber(9000) var meta: Map<String, String> = emptyMap(),
) {
companion object {
fun copyFrom(source: Source): BackupSource =
@@ -1,7 +1,5 @@
package suwayomi.tachidesk.server
import org.jetbrains.exposed.sql.SortOrder
interface ConfigAdapter<T> {
fun toType(configValue: String): T
}
@@ -22,6 +20,8 @@ object DoubleConfigAdapter : ConfigAdapter<Double> {
override fun toType(configValue: String): Double = configValue.toDouble()
}
object SortOrderConfigAdapter : ConfigAdapter<SortOrder> {
override fun toType(configValue: String): SortOrder = SortOrder.valueOf(configValue)
class EnumConfigAdapter<T : Enum<T>>(
private val enumClass: Class<T>,
) : ConfigAdapter<T> {
override fun toType(configValue: String): T = java.lang.Enum.valueOf(enumClass, configValue.uppercase())
}
@@ -24,6 +24,9 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.onEach
import org.jetbrains.exposed.sql.SortOrder
import suwayomi.tachidesk.graphql.types.WebUIChannel
import suwayomi.tachidesk.graphql.types.WebUIFlavor
import suwayomi.tachidesk.graphql.types.WebUIInterface
import xyz.nulldev.ts.config.GlobalConfigManager
import xyz.nulldev.ts.config.SystemPropertyOverridableConfigModule
import kotlin.reflect.KProperty
@@ -99,11 +102,11 @@ class ServerConfig(
// webUI
val webUIEnabled: MutableStateFlow<Boolean> by OverrideConfigValue(BooleanConfigAdapter)
val webUIFlavor: MutableStateFlow<String> by OverrideConfigValue(StringConfigAdapter)
val webUIFlavor: MutableStateFlow<WebUIFlavor> by OverrideConfigValue(EnumConfigAdapter(WebUIFlavor::class.java))
val initialOpenInBrowserEnabled: MutableStateFlow<Boolean> by OverrideConfigValue(BooleanConfigAdapter)
val webUIInterface: MutableStateFlow<String> by OverrideConfigValue(StringConfigAdapter)
val webUIInterface: MutableStateFlow<WebUIInterface> by OverrideConfigValue(EnumConfigAdapter(WebUIInterface::class.java))
val electronPath: MutableStateFlow<String> by OverrideConfigValue(StringConfigAdapter)
val webUIChannel: MutableStateFlow<String> by OverrideConfigValue(StringConfigAdapter)
val webUIChannel: MutableStateFlow<WebUIChannel> by OverrideConfigValue(EnumConfigAdapter(WebUIChannel::class.java))
val webUIUpdateCheckInterval: MutableStateFlow<Double> by OverrideConfigValue(DoubleConfigAdapter)
// downloader
@@ -171,7 +174,7 @@ class ServerConfig(
val opdsMarkAsReadOnDownload: MutableStateFlow<Boolean> by OverrideConfigValue(BooleanConfigAdapter)
val opdsShowOnlyUnreadChapters: MutableStateFlow<Boolean> by OverrideConfigValue(BooleanConfigAdapter)
val opdsShowOnlyDownloadedChapters: MutableStateFlow<Boolean> by OverrideConfigValue(BooleanConfigAdapter)
val opdsChapterSortOrder: MutableStateFlow<SortOrder> by OverrideConfigValue(SortOrderConfigAdapter)
val opdsChapterSortOrder: MutableStateFlow<SortOrder> by OverrideConfigValue(EnumConfigAdapter(SortOrder::class.java))
@OptIn(ExperimentalCoroutinesApi::class)
fun <T> subscribeTo(
@@ -25,7 +25,7 @@ object Browser {
if (serverConfig.webUIEnabled.value) {
val appBaseUrl = getAppBaseUrl()
if (serverConfig.webUIInterface.value == WebUIInterface.ELECTRON.name.lowercase()) {
if (serverConfig.webUIInterface.value == WebUIInterface.ELECTRON) {
try {
val electronPath = serverConfig.electronPath.value
electronInstances.add(ProcessBuilder(electronPath, appBaseUrl).start())
@@ -125,7 +125,7 @@ object WebInterfaceManager {
}
return AboutWebUI(
channel = WebUIChannel.from(serverConfig.webUIChannel.value),
channel = serverConfig.webUIChannel.value,
tag = currentVersion,
)
}
@@ -138,7 +138,7 @@ object WebInterfaceManager {
WebUIUpdateStatus(
info =
WebUIUpdateInfo(
channel = WebUIChannel.from(serverConfig.webUIChannel.value),
channel = serverConfig.webUIChannel.value,
tag = version,
),
state,
@@ -168,7 +168,7 @@ object WebInterfaceManager {
private fun scheduleWebUIUpdateCheck() {
HAScheduler.descheduleCron(currentUpdateTaskId)
val isAutoUpdateDisabled = !isAutoUpdateEnabled() || serverConfig.webUIFlavor.value == WebUIFlavor.CUSTOM.uiName
val isAutoUpdateDisabled = !isAutoUpdateEnabled() || serverConfig.webUIFlavor.value == WebUIFlavor.CUSTOM
if (isAutoUpdateDisabled) {
return
}
@@ -216,7 +216,7 @@ object WebInterfaceManager {
}
suspend fun setupWebUI() {
if (serverConfig.webUIFlavor.value == WebUIFlavor.CUSTOM.uiName) {
if (serverConfig.webUIFlavor.value == WebUIFlavor.CUSTOM) {
return
}
@@ -320,7 +320,7 @@ object WebInterfaceManager {
if (!flavor.isDefault()) {
log.warn { "fallback to default webUI \"${WebUIFlavor.default.uiName}\"" }
serverConfig.webUIFlavor.value = WebUIFlavor.default.uiName
serverConfig.webUIFlavor.value = WebUIFlavor.default
val fallbackToBundledVersion = !doDownload { getLatestCompatibleVersion(flavor) }
if (!fallbackToBundledVersion) {
@@ -523,7 +523,7 @@ object WebInterfaceManager {
)
private suspend fun getLatestCompatibleVersion(flavor: WebUIFlavor): String {
if (WebUIChannel.doesConfigChannelEqual(WebUIChannel.BUNDLED)) {
if (serverConfig.webUIChannel.value == WebUIChannel.BUNDLED) {
logger.debug { "getLatestCompatibleVersion: Channel is \"${WebUIChannel.BUNDLED}\", do not check for update" }
return BuildConfig.WEBUI_TAG
}
@@ -558,9 +558,9 @@ object WebInterfaceManager {
// is a STABLE webUI release, without a specified webUI version, which requires same handling as the PREVIEW release
val isUnknownStableVersion = webUIVersion == "STABLEPREVIEW"
if (!WebUIChannel.doesConfigChannelEqual(WebUIChannel.from(webUIVersion))) {
if (serverConfig.webUIChannel.value != WebUIChannel.from(webUIVersion)) {
// allow only STABLE versions for STABLE channel
if (WebUIChannel.doesConfigChannelEqual(WebUIChannel.STABLE) && !isUnknownStableVersion) {
if (serverConfig.webUIChannel.value == WebUIChannel.STABLE && !isUnknownStableVersion) {
continue
}