diff --git a/server/src/main/kotlin/suwayomi/tachidesk/global/impl/GlobalMeta.kt b/server/src/main/kotlin/suwayomi/tachidesk/global/impl/GlobalMeta.kt index 0feacc20..8d51d055 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/global/impl/GlobalMeta.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/global/impl/GlobalMeta.kt @@ -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) { + 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 } } } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/BackupMutation.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/BackupMutation.kt index 916f8a43..3590e7c6 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/BackupMutation.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/BackupMutation.kt @@ -64,6 +64,8 @@ class BackupMutation { includeChapters = input?.includeChapters ?: true, includeTracking = true, includeHistory = true, + includeClientData = true, + includeServerSettings = true, ), ) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/SettingsMutation.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/SettingsMutation.kt index b7bb0d6e..11dcd557 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/SettingsMutation.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/SettingsMutation.kt @@ -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) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/InfoQuery.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/InfoQuery.kt index f9471057..11c3a4f3 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/InfoQuery.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/InfoQuery.kt @@ -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, ) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/BackupTypes.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/BackupTypes.kt index 10ab916f..90f19c66 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/BackupTypes.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/BackupTypes.kt @@ -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( diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/SettingsType.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/SettingsType.kt index 089630c6..9182e124 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/SettingsType.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/SettingsType.kt @@ -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, diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/WebUIUpdateType.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/WebUIUpdateType.kt index b068f808..2378cfa0 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/WebUIUpdateType.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/WebUIUpdateType.kt @@ -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 } } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/controller/BackupController.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/controller/BackupController.kt index 1713e597..01f87660 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/controller/BackupController.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/controller/BackupController.kt @@ -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) } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Category.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Category.kt index 3919ad3e..ea1ba176 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Category.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Category.kt @@ -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): Map> = + 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>) { + 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 } } } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Chapter.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Chapter.kt index 2ccf6fa2..55017f28 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Chapter.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Chapter.kt @@ -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>): Map, Map> = + fun getChaptersMetaMaps(chapterIds: List): Map> = 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>) { 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 } } } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Manga.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Manga.kt index 5bfcbf2b..8b026208 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Manga.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Manga.kt @@ -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>) { 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 } } } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Source.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Source.kt index 916b2a61..f83ea3ba 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Source.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Source.kt @@ -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): Map> = + 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>) { + 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 } } } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/BackupFlags.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/BackupFlags.kt index 05c69546..0ec14c0b 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/BackupFlags.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/BackupFlags.kt @@ -13,4 +13,6 @@ data class BackupFlags( val includeChapters: Boolean, val includeTracking: Boolean, val includeHistory: Boolean, + val includeClientData: Boolean, + val includeServerSettings: Boolean, ) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/models/Chapter.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/models/Chapter.kt index 3ecb6c61..6255ad37 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/models/Chapter.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/models/Chapter.kt @@ -22,6 +22,8 @@ interface Chapter : var source_order: Int + var meta: Map + val isRecognizedNumber: Boolean get() = chapter_number >= 0f } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/models/ChapterImpl.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/models/ChapterImpl.kt index 4b7afac9..1cc5bb78 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/models/ChapterImpl.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/models/ChapterImpl.kt @@ -27,6 +27,8 @@ class ChapterImpl : Chapter { override var source_order: Int = 0 + override var meta: Map = emptyMap() + override fun equals(other: Any?): Boolean { if (this === other) return true if (other == null || javaClass != other.javaClass) return false diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/models/Manga.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/models/Manga.kt index 90cca6aa..fa518de7 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/models/Manga.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/models/Manga.kt @@ -37,6 +37,8 @@ interface Manga : SManga { var cover_last_modified: Long + var meta: Map + fun setChapterOrder(order: Int) { setChapterFlags(order, CHAPTER_SORT_MASK) } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/models/MangaImpl.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/models/MangaImpl.kt index df936905..c43c3463 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/models/MangaImpl.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/models/MangaImpl.kt @@ -37,6 +37,8 @@ open class MangaImpl : Manga { override var initialized: Boolean = false + override var meta: Map = 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 diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/ProtoBackupExport.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/ProtoBackupExport.kt index aed35277..65399bef 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/ProtoBackupExport.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/ProtoBackupExport.kt @@ -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 = - 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 { + 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 = - 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 { + 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 { + 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, + ) + } } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/ProtoBackupImport.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/ProtoBackupImport.kt index d1941375..243fa97a 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/ProtoBackupImport.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/ProtoBackupImport.kt @@ -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): Map { 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) { + GlobalMeta.modifyMetas(meta) + } + + private fun restoreSourceMeta(backupSources: List) { + 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) } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/models/Backup.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/models/Backup.kt index 680d3e75..6a5bbcbc 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/models/Backup.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/models/Backup.kt @@ -13,6 +13,9 @@ data class Backup( // Bump by 100 to specify this is a 0.x value // @ProtoNumber(100) var brokenBackupSources: List = emptyList(), @ProtoNumber(101) var backupSources: List = emptyList(), + // suwayomi + @ProtoNumber(9000) var meta: Map = emptyMap(), + @ProtoNumber(9001) var serverSettings: BackupServerSettings?, ) { fun getSourceMap(): Map = backupSources diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/models/BackupCategory.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/models/BackupCategory.kt index 0122b2bf..83a5c2b6 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/models/BackupCategory.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/models/BackupCategory.kt @@ -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 = emptyMap(), ) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/models/BackupChapter.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/models/BackupChapter.kt index 3e3d4277..8520ff96 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/models/BackupChapter.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/models/BackupChapter.kt @@ -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 = 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 } } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/models/BackupManga.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/models/BackupManga.kt index 10d98f2d..d29d8e64 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/models/BackupManga.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/models/BackupManga.kt @@ -38,6 +38,8 @@ data class BackupManga( @ProtoNumber(103) var viewer_flags: Int? = null, @ProtoNumber(104) var history: List = emptyList(), @ProtoNumber(105) var updateStrategy: UpdateStrategy = UpdateStrategy.ALWAYS_UPDATE, + // suwayomi + @ProtoNumber(9000) var meta: Map = 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 = diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/models/BackupServerSettings.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/models/BackupServerSettings.kt new file mode 100644 index 00000000..760b6f78 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/models/BackupServerSettings.kt @@ -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, + // 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 diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/models/BackupSource.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/models/BackupSource.kt index 140d6e4b..0673f159 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/models/BackupSource.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/models/BackupSource.kt @@ -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 = emptyMap(), ) { companion object { fun copyFrom(source: Source): BackupSource = diff --git a/server/src/main/kotlin/suwayomi/tachidesk/server/ConfigAdapters.kt b/server/src/main/kotlin/suwayomi/tachidesk/server/ConfigAdapters.kt index c379939a..8276bed6 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/server/ConfigAdapters.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/server/ConfigAdapters.kt @@ -1,7 +1,5 @@ package suwayomi.tachidesk.server -import org.jetbrains.exposed.sql.SortOrder - interface ConfigAdapter { fun toType(configValue: String): T } @@ -22,6 +20,8 @@ object DoubleConfigAdapter : ConfigAdapter { override fun toType(configValue: String): Double = configValue.toDouble() } -object SortOrderConfigAdapter : ConfigAdapter { - override fun toType(configValue: String): SortOrder = SortOrder.valueOf(configValue) +class EnumConfigAdapter>( + private val enumClass: Class, +) : ConfigAdapter { + override fun toType(configValue: String): T = java.lang.Enum.valueOf(enumClass, configValue.uppercase()) } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/server/ServerConfig.kt b/server/src/main/kotlin/suwayomi/tachidesk/server/ServerConfig.kt index 28bb7163..cb2e778b 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/server/ServerConfig.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/server/ServerConfig.kt @@ -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 by OverrideConfigValue(BooleanConfigAdapter) - val webUIFlavor: MutableStateFlow by OverrideConfigValue(StringConfigAdapter) + val webUIFlavor: MutableStateFlow by OverrideConfigValue(EnumConfigAdapter(WebUIFlavor::class.java)) val initialOpenInBrowserEnabled: MutableStateFlow by OverrideConfigValue(BooleanConfigAdapter) - val webUIInterface: MutableStateFlow by OverrideConfigValue(StringConfigAdapter) + val webUIInterface: MutableStateFlow by OverrideConfigValue(EnumConfigAdapter(WebUIInterface::class.java)) val electronPath: MutableStateFlow by OverrideConfigValue(StringConfigAdapter) - val webUIChannel: MutableStateFlow by OverrideConfigValue(StringConfigAdapter) + val webUIChannel: MutableStateFlow by OverrideConfigValue(EnumConfigAdapter(WebUIChannel::class.java)) val webUIUpdateCheckInterval: MutableStateFlow by OverrideConfigValue(DoubleConfigAdapter) // downloader @@ -171,7 +174,7 @@ class ServerConfig( val opdsMarkAsReadOnDownload: MutableStateFlow by OverrideConfigValue(BooleanConfigAdapter) val opdsShowOnlyUnreadChapters: MutableStateFlow by OverrideConfigValue(BooleanConfigAdapter) val opdsShowOnlyDownloadedChapters: MutableStateFlow by OverrideConfigValue(BooleanConfigAdapter) - val opdsChapterSortOrder: MutableStateFlow by OverrideConfigValue(SortOrderConfigAdapter) + val opdsChapterSortOrder: MutableStateFlow by OverrideConfigValue(EnumConfigAdapter(SortOrder::class.java)) @OptIn(ExperimentalCoroutinesApi::class) fun subscribeTo( diff --git a/server/src/main/kotlin/suwayomi/tachidesk/server/util/Browser.kt b/server/src/main/kotlin/suwayomi/tachidesk/server/util/Browser.kt index 88a4b8bc..0c1d1718 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/server/util/Browser.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/server/util/Browser.kt @@ -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()) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/server/util/WebInterfaceManager.kt b/server/src/main/kotlin/suwayomi/tachidesk/server/util/WebInterfaceManager.kt index 959c2999..1d877600 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/server/util/WebInterfaceManager.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/server/util/WebInterfaceManager.kt @@ -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 }