Add support for configuring which categories are downloaded automatically (#832)

* Rename IncludeInUpdate class to IncludeOrExclude

Signed-off-by: Chance Zibolski <chance.zibolski@gmail.com>

* Add support for configuring which categories are downloaded automatically

If a manga has no configured categories, behavior remains the same and
the automatic download functionality will download new chapters without
consulting the category includeInDownload configuration.

Signed-off-by: Chance Zibolski <chance.zibolski@gmail.com>

---------

Signed-off-by: Chance Zibolski <chance.zibolski@gmail.com>
This commit is contained in:
Chance Zibolski
2024-01-20 16:41:47 -08:00
committed by GitHub
parent f224918f33
commit 57d5bc6480
10 changed files with 114 additions and 26 deletions

View File

@@ -19,7 +19,7 @@ import suwayomi.tachidesk.manga.impl.Category
import suwayomi.tachidesk.manga.impl.Category.DEFAULT_CATEGORY_ID import suwayomi.tachidesk.manga.impl.Category.DEFAULT_CATEGORY_ID
import suwayomi.tachidesk.manga.impl.util.lang.isEmpty import suwayomi.tachidesk.manga.impl.util.lang.isEmpty
import suwayomi.tachidesk.manga.impl.util.lang.isNotEmpty import suwayomi.tachidesk.manga.impl.util.lang.isNotEmpty
import suwayomi.tachidesk.manga.model.dataclass.IncludeInUpdate import suwayomi.tachidesk.manga.model.dataclass.IncludeOrExclude
import suwayomi.tachidesk.manga.model.table.CategoryMangaTable import suwayomi.tachidesk.manga.model.table.CategoryMangaTable
import suwayomi.tachidesk.manga.model.table.CategoryMetaTable import suwayomi.tachidesk.manga.model.table.CategoryMetaTable
import suwayomi.tachidesk.manga.model.table.CategoryTable import suwayomi.tachidesk.manga.model.table.CategoryTable
@@ -85,7 +85,8 @@ class CategoryMutation {
data class UpdateCategoryPatch( data class UpdateCategoryPatch(
val name: String? = null, val name: String? = null,
val default: Boolean? = null, val default: Boolean? = null,
val includeInUpdate: IncludeInUpdate? = null, val includeInUpdate: IncludeOrExclude? = null,
val includeInDownload: IncludeOrExclude? = null,
) )
data class UpdateCategoryPayload( data class UpdateCategoryPayload(
@@ -136,6 +137,13 @@ class CategoryMutation {
} }
} }
} }
if (patch.includeInDownload != null) {
CategoryTable.update({ CategoryTable.id inList ids }) { update ->
patch.includeInDownload.also {
update[includeInDownload] = it.value
}
}
}
} }
} }
@@ -229,7 +237,8 @@ class CategoryMutation {
val name: String, val name: String,
val order: Int? = null, val order: Int? = null,
val default: Boolean? = null, val default: Boolean? = null,
val includeInUpdate: IncludeInUpdate? = null, val includeInUpdate: IncludeOrExclude? = null,
val includeInDownload: IncludeOrExclude? = null,
) )
data class CreateCategoryPayload( data class CreateCategoryPayload(
@@ -238,7 +247,7 @@ class CategoryMutation {
) )
fun createCategory(input: CreateCategoryInput): CreateCategoryPayload { fun createCategory(input: CreateCategoryInput): CreateCategoryPayload {
val (clientMutationId, name, order, default, includeInUpdate) = input val (clientMutationId, name, order, default, includeInUpdate, includeInDownload) = input
transaction { transaction {
require(CategoryTable.select { CategoryTable.name eq input.name }.isEmpty()) { require(CategoryTable.select { CategoryTable.name eq input.name }.isEmpty()) {
"'name' must be unique" "'name' must be unique"
@@ -271,6 +280,9 @@ class CategoryMutation {
if (includeInUpdate != null) { if (includeInUpdate != null) {
it[CategoryTable.includeInUpdate] = includeInUpdate.value it[CategoryTable.includeInUpdate] = includeInUpdate.value
} }
if (includeInDownload != null) {
it[CategoryTable.includeInDownload] = includeInDownload.value
}
} }
Category.normalizeCategories() Category.normalizeCategories()

View File

@@ -15,7 +15,7 @@ import suwayomi.tachidesk.graphql.server.primitives.Edge
import suwayomi.tachidesk.graphql.server.primitives.Node import suwayomi.tachidesk.graphql.server.primitives.Node
import suwayomi.tachidesk.graphql.server.primitives.NodeList import suwayomi.tachidesk.graphql.server.primitives.NodeList
import suwayomi.tachidesk.graphql.server.primitives.PageInfo import suwayomi.tachidesk.graphql.server.primitives.PageInfo
import suwayomi.tachidesk.manga.model.dataclass.IncludeInUpdate import suwayomi.tachidesk.manga.model.dataclass.IncludeOrExclude
import suwayomi.tachidesk.manga.model.table.CategoryTable import suwayomi.tachidesk.manga.model.table.CategoryTable
import java.util.concurrent.CompletableFuture import java.util.concurrent.CompletableFuture
@@ -24,14 +24,16 @@ class CategoryType(
val order: Int, val order: Int,
val name: String, val name: String,
val default: Boolean, val default: Boolean,
val includeInUpdate: IncludeInUpdate, val includeInUpdate: IncludeOrExclude,
val includeInDownload: IncludeOrExclude,
) : Node { ) : Node {
constructor(row: ResultRow) : this( constructor(row: ResultRow) : this(
row[CategoryTable.id].value, row[CategoryTable.id].value,
row[CategoryTable.order], row[CategoryTable.order],
row[CategoryTable.name], row[CategoryTable.name],
row[CategoryTable.isDefault], row[CategoryTable.isDefault],
IncludeInUpdate.fromValue(row[CategoryTable.includeInUpdate]), IncludeOrExclude.fromValue(row[CategoryTable.includeInUpdate]),
IncludeOrExclude.fromValue(row[CategoryTable.includeInDownload]),
) )
fun mangas(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture<MangaNodeList> { fun mangas(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture<MangaNodeList> {

View File

@@ -65,14 +65,15 @@ object CategoryController {
formParam<String?>("name"), formParam<String?>("name"),
formParam<Boolean?>("default"), formParam<Boolean?>("default"),
formParam<Int?>("includeInUpdate"), formParam<Int?>("includeInUpdate"),
formParam<Int?>("includeInDownload"),
documentWith = { documentWith = {
withOperation { withOperation {
summary("Category modify") summary("Category modify")
description("Modify a category") description("Modify a category")
} }
}, },
behaviorOf = { ctx, categoryId, name, isDefault, includeInUpdate -> behaviorOf = { ctx, categoryId, name, isDefault, includeInUpdate, includeInDownload ->
Category.updateCategory(categoryId, name, isDefault, includeInUpdate) Category.updateCategory(categoryId, name, isDefault, includeInUpdate, includeInDownload)
ctx.status(200) ctx.status(200)
}, },
withResults = { withResults = {

View File

@@ -55,6 +55,7 @@ object Category {
name: String?, name: String?,
isDefault: Boolean?, isDefault: Boolean?,
includeInUpdate: Int?, includeInUpdate: Int?,
includeInDownload: Int?,
) { ) {
transaction { transaction {
CategoryTable.update({ CategoryTable.id eq categoryId }) { CategoryTable.update({ CategoryTable.id eq categoryId }) {
@@ -66,6 +67,7 @@ object Category {
} }
if (categoryId != DEFAULT_CATEGORY_ID && isDefault != null) it[CategoryTable.isDefault] = isDefault if (categoryId != DEFAULT_CATEGORY_ID && isDefault != null) it[CategoryTable.isDefault] = isDefault
if (includeInUpdate != null) it[CategoryTable.includeInUpdate] = includeInUpdate if (includeInUpdate != null) it[CategoryTable.includeInUpdate] = includeInUpdate
if (includeInDownload != null) it[CategoryTable.includeInDownload] = includeInDownload
} }
} }
} }

View File

@@ -37,6 +37,7 @@ import suwayomi.tachidesk.manga.impl.download.DownloadManager.EnqueueInput
import suwayomi.tachidesk.manga.impl.track.Track import suwayomi.tachidesk.manga.impl.track.Track
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogueSourceOrStub import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogueSourceOrStub
import suwayomi.tachidesk.manga.model.dataclass.ChapterDataClass import suwayomi.tachidesk.manga.model.dataclass.ChapterDataClass
import suwayomi.tachidesk.manga.model.dataclass.IncludeOrExclude
import suwayomi.tachidesk.manga.model.dataclass.MangaChapterDataClass import suwayomi.tachidesk.manga.model.dataclass.MangaChapterDataClass
import suwayomi.tachidesk.manga.model.dataclass.PaginatedList import suwayomi.tachidesk.manga.model.dataclass.PaginatedList
import suwayomi.tachidesk.manga.model.dataclass.paginatedFrom import suwayomi.tachidesk.manga.model.dataclass.paginatedFrom
@@ -49,6 +50,7 @@ import suwayomi.tachidesk.server.serverConfig
import java.time.Instant import java.time.Instant
import java.util.TreeSet import java.util.TreeSet
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import kotlin.collections.listOf
import kotlin.math.max import kotlin.math.max
object Chapter { object Chapter {
@@ -309,20 +311,65 @@ object Chapter {
")", ")",
) )
if (!serverConfig.autoDownloadNewChapters.value) {
log.debug { "automatic download is not configured" }
return
}
// Only download if there are new chapters, or if this is the first fetch
val newNumberOfChapters = updatedChapterList.size val newNumberOfChapters = updatedChapterList.size
val numberOfNewChapters = newNumberOfChapters - prevNumberOfChapters val numberOfNewChapters = newNumberOfChapters - prevNumberOfChapters
val areNewChaptersAvailable = numberOfNewChapters > 0 val areNewChaptersAvailable = numberOfNewChapters > 0
val wasInitialFetch = prevNumberOfChapters == 0 val wasInitialFetch = prevNumberOfChapters == 0
// make sure to ignore initial fetch if (!areNewChaptersAvailable) {
val isDownloadPossible = log.debug { "no new chapters available" }
serverConfig.autoDownloadNewChapters.value && areNewChaptersAvailable && !wasInitialFetch
if (!isDownloadPossible) {
log.debug { "download is not allowed/possible" }
return return
} }
if (wasInitialFetch) {
log.debug { "skipping download on initial fetch" }
return
}
// Verify the manga is configured to be downloaded based on it's categories.
var mangaCategories = CategoryManga.getMangaCategories(mangaId).toSet()
// if the manga has no categories, then it's implicitly in the default category
if (mangaCategories.isEmpty()) {
val defaultCategory = Category.getCategoryById(Category.DEFAULT_CATEGORY_ID)
if (defaultCategory != null) {
mangaCategories = setOf(defaultCategory)
} else {
log.warn { "missing default category" }
}
}
if (mangaCategories.isNotEmpty()) {
var downloadCategoriesMap = Category.getCategoryList().groupBy { it.includeInDownload }
val unsetCategories = downloadCategoriesMap[IncludeOrExclude.UNSET].orEmpty()
// We only download if it's in the include list, and not in the exclude list.
// Use the unset categories as the included categories if the included categories is
// empty
val includedCategories = downloadCategoriesMap[IncludeOrExclude.INCLUDE].orEmpty().ifEmpty { unsetCategories }
val excludedCategories = downloadCategoriesMap[IncludeOrExclude.EXCLUDE].orEmpty()
// Only download manga that aren't in any excluded categories
val mangaExcludeCategories = mangaCategories.intersect(excludedCategories)
if (mangaExcludeCategories.isNotEmpty()) {
log.debug { "download excluded by categories: '${mangaExcludeCategories.joinToString("', '") { it.name }}'" }
return
}
val mangaDownloadCategories = mangaCategories.intersect(includedCategories)
if (mangaDownloadCategories.isNotEmpty()) {
log.debug { "download inluded by categories: '${mangaDownloadCategories.joinToString("', '") { it.name }}'" }
} else {
log.debug { "skipping download due to download categories configuration" }
return
}
} else {
log.debug { "no categories configured, skipping check for category download include/excludes" }
}
val newChapters = updatedChapterList.subList(0, numberOfNewChapters) val newChapters = updatedChapterList.subList(0, numberOfNewChapters)
// make sure to only consider the latest chapters. e.g. old unread chapters should be ignored // make sure to only consider the latest chapters. e.g. old unread chapters should be ignored

View File

@@ -26,7 +26,7 @@ import suwayomi.tachidesk.manga.impl.CategoryManga
import suwayomi.tachidesk.manga.impl.Chapter import suwayomi.tachidesk.manga.impl.Chapter
import suwayomi.tachidesk.manga.impl.Manga import suwayomi.tachidesk.manga.impl.Manga
import suwayomi.tachidesk.manga.model.dataclass.CategoryDataClass import suwayomi.tachidesk.manga.model.dataclass.CategoryDataClass
import suwayomi.tachidesk.manga.model.dataclass.IncludeInUpdate import suwayomi.tachidesk.manga.model.dataclass.IncludeOrExclude
import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass
import suwayomi.tachidesk.manga.model.table.MangaStatus import suwayomi.tachidesk.manga.model.table.MangaStatus
import suwayomi.tachidesk.server.serverConfig import suwayomi.tachidesk.server.serverConfig
@@ -222,9 +222,9 @@ class Updater : IUpdater {
} }
val includeInUpdateStatusToCategoryMap = categories.groupBy { it.includeInUpdate } val includeInUpdateStatusToCategoryMap = categories.groupBy { it.includeInUpdate }
val excludedCategories = includeInUpdateStatusToCategoryMap[IncludeInUpdate.EXCLUDE].orEmpty() val excludedCategories = includeInUpdateStatusToCategoryMap[IncludeOrExclude.EXCLUDE].orEmpty()
val includedCategories = includeInUpdateStatusToCategoryMap[IncludeInUpdate.INCLUDE].orEmpty() val includedCategories = includeInUpdateStatusToCategoryMap[IncludeOrExclude.INCLUDE].orEmpty()
val unsetCategories = includeInUpdateStatusToCategoryMap[IncludeInUpdate.UNSET].orEmpty() val unsetCategories = includeInUpdateStatusToCategoryMap[IncludeOrExclude.UNSET].orEmpty()
val categoriesToUpdate = val categoriesToUpdate =
if (forceAll) { if (forceAll) {
categories categories
@@ -277,6 +277,8 @@ class Updater : IUpdater {
// In case no manga gets updated and no update job was running before, the client would never receive an info about its update request // In case no manga gets updated and no update job was running before, the client would never receive an info about its update request
updateStatus(emptyList(), mangasToUpdate.isNotEmpty(), updateStatusCategories, skippedMangas) updateStatus(emptyList(), mangasToUpdate.isNotEmpty(), updateStatusCategories, skippedMangas)
logger.debug { "mangasToUpdate $mangasToUpdate" }
if (mangasToUpdate.isEmpty()) { if (mangasToUpdate.isEmpty()) {
return return
} }

View File

@@ -9,7 +9,7 @@ import com.fasterxml.jackson.annotation.JsonValue
* License, v. 2.0. If a copy of the MPL was not distributed with this * 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/. */ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
enum class IncludeInUpdate( enum class IncludeOrExclude(
@JsonValue val value: Int, @JsonValue val value: Int,
) { ) {
EXCLUDE(0), EXCLUDE(0),
@@ -18,7 +18,7 @@ enum class IncludeInUpdate(
; ;
companion object { companion object {
fun fromValue(value: Int) = IncludeInUpdate.values().find { it.value == value } ?: UNSET fun fromValue(value: Int) = IncludeOrExclude.values().find { it.value == value } ?: UNSET
} }
} }
@@ -28,6 +28,7 @@ data class CategoryDataClass(
val name: String, val name: String,
val default: Boolean, val default: Boolean,
val size: Int, val size: Int,
val includeInUpdate: IncludeInUpdate, val includeInUpdate: IncludeOrExclude,
val includeInDownload: IncludeOrExclude,
val meta: Map<String, String> = emptyMap(), val meta: Map<String, String> = emptyMap(),
) )

View File

@@ -11,13 +11,14 @@ import org.jetbrains.exposed.dao.id.IntIdTable
import org.jetbrains.exposed.sql.ResultRow import org.jetbrains.exposed.sql.ResultRow
import suwayomi.tachidesk.manga.impl.Category import suwayomi.tachidesk.manga.impl.Category
import suwayomi.tachidesk.manga.model.dataclass.CategoryDataClass import suwayomi.tachidesk.manga.model.dataclass.CategoryDataClass
import suwayomi.tachidesk.manga.model.dataclass.IncludeInUpdate import suwayomi.tachidesk.manga.model.dataclass.IncludeOrExclude
object CategoryTable : IntIdTable() { object CategoryTable : IntIdTable() {
val name = varchar("name", 64) val name = varchar("name", 64)
val order = integer("order").default(0) val order = integer("order").default(0)
val isDefault = bool("is_default").default(false) val isDefault = bool("is_default").default(false)
val includeInUpdate = integer("include_in_update").default(IncludeInUpdate.UNSET.value) val includeInUpdate = integer("include_in_update").default(IncludeOrExclude.UNSET.value)
val includeInDownload = integer("include_in_download").default(IncludeOrExclude.UNSET.value)
} }
fun CategoryTable.toDataClass(categoryEntry: ResultRow) = fun CategoryTable.toDataClass(categoryEntry: ResultRow) =
@@ -27,6 +28,7 @@ fun CategoryTable.toDataClass(categoryEntry: ResultRow) =
categoryEntry[name], categoryEntry[name],
categoryEntry[isDefault], categoryEntry[isDefault],
Category.getCategorySize(categoryEntry[id].value), Category.getCategorySize(categoryEntry[id].value),
IncludeInUpdate.fromValue(categoryEntry[includeInUpdate]), IncludeOrExclude.fromValue(categoryEntry[includeInUpdate]),
IncludeOrExclude.fromValue(categoryEntry[includeInDownload]),
Category.getCategoryMetaMap(categoryEntry[id].value), Category.getCategoryMetaMap(categoryEntry[id].value),
) )

View File

@@ -8,12 +8,12 @@ package suwayomi.tachidesk.server.database.migration
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import de.neonew.exposed.migrations.helpers.AddColumnMigration import de.neonew.exposed.migrations.helpers.AddColumnMigration
import suwayomi.tachidesk.manga.model.dataclass.IncludeInUpdate import suwayomi.tachidesk.manga.model.dataclass.IncludeOrExclude
@Suppress("ClassName", "unused") @Suppress("ClassName", "unused")
class M0026_CategoryIncludeInUpdate : AddColumnMigration( class M0026_CategoryIncludeInUpdate : AddColumnMigration(
"Category", "Category",
"include_in_update", "include_in_update",
"INT", "INT",
IncludeInUpdate.UNSET.value.toString(), IncludeOrExclude.UNSET.value.toString(),
) )

View File

@@ -0,0 +1,19 @@
package suwayomi.tachidesk.server.database.migration
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* 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 de.neonew.exposed.migrations.helpers.AddColumnMigration
import suwayomi.tachidesk.manga.model.dataclass.IncludeOrExclude
@Suppress("ClassName", "unused")
class M0034_CategoryIncludeInDownload : AddColumnMigration(
"Category",
"include_in_download",
"INT",
IncludeOrExclude.UNSET.value.toString(),
)