Compare commits

...

18 Commits

Author SHA1 Message Date
Constantin Piber
396cfa734a fix: re-sync (#2121) 2026-06-17 17:22:54 -04:00
Syer10
a1fdf6d77a Improve extension store sync 2026-06-17 16:12:51 -04:00
Syer10
07ae17105b Add ContentRatingFilter 2026-06-17 16:02:14 -04:00
Syer10
b8772f60bf Optimize import fixes 2026-06-17 14:49:57 -04:00
Syer10
733b9c9919 Proper extension store queries 2026-06-17 14:47:28 -04:00
Syer10
0b0c056bcb Optimize imports and fix unchecked cast warning 2026-06-17 14:13:57 -04:00
Syer10
aff95bfc37 Lint 2026-06-17 14:06:06 -04:00
Syer10
00bc3e39b6 Lint 2026-06-17 14:04:02 -04:00
Syer10
e9c2cc49a6 Fix SearchTest 2026-06-17 13:57:58 -04:00
Syer10
ea310ba54b No magic numbers for ContentRating, improves safety for future versions of extension api 2026-06-17 13:53:54 -04:00
Syer10
72347f45cc Simplify isNsfw in SourceType 2026-06-17 13:44:02 -04:00
Syer10
1e73e526c6 Simplify ContentRating in Source.kt 2026-06-17 13:43:02 -04:00
Syer10
8fd0fdba08 Simplify deprecated isNsfw in SourceQuery 2026-06-17 13:42:15 -04:00
Syer10
4b61d375ed Fixes 2026-06-17 13:39:53 -04:00
Syer10
3cf4cf6cf8 Improve Fetch Extension Store 2026-06-17 13:31:31 -04:00
Mitchell Syer
74ade8a3a3 Update docs/Configuring-Suwayomi‐Server.md
Co-authored-by: Constantin Piber <59023762+cpiber@users.noreply.github.com>
2026-06-17 13:26:09 -04:00
Syer10
3bb2e4329e Use EMPTY JsonObject 2026-06-17 13:25:31 -04:00
Syer10
33ec15c136 Simplify fetching manga and chapters 2026-06-17 13:24:38 -04:00
30 changed files with 414 additions and 126 deletions

View File

@@ -172,7 +172,7 @@ server.maxLogFolderSize = "100mb"
server.extensionStores = []
server.maxSourcesInParallel = 6
```
- `server.extensionStores` is a list of extension stores(previously called repositories) for custom sources. Uses the same format as Mihon; each entry is expected to be a string URL pointing to a JSON or PROTOBUF file representing the repository.
- `server.extensionStores` is a list of extension stores (previously called repositories) for custom sources. Uses the same format as Mihon; each entry is expected to be a string URL pointing to a JSON or PROTOBUF file representing the repository.
- `server.maxSourcesInParallel = 6` sets how many sources can do requests (updates, downloads) in parallel. Updates/downloads are grouped by source and all mangas of a source are updated/downloaded synchronously. Range: 1 <= n <= 20.
### Backup

View File

@@ -287,9 +287,10 @@ class ServerConfig(
replaceWith = "extensionStores",
message = "Replaced with extensionStores",
migrateConfigValue = {
@Suppress("UNCHECKED_CAST")
(it.unwrapped() as? List<String>)
?.map {
if (it.contains("github")) {
if (it.contains("github.com")) {
it.replace(repoMatchRegex) {
"https://raw.githubusercontent.com/${it.groupValues[2]}/${it.groupValues[3]}/" +
(it.groupValues.getOrNull(4)?.ifBlank { null } ?: "repo") +
@@ -303,7 +304,7 @@ class ServerConfig(
},
),
readMigrated = { extensionStores.value },
setMigrated = { extensionStores.value = it },
setMigrated = { extensionStores.value = (extensionStores.value + it).distinct() },
typeInfo =
SettingsRegistry.PartialTypeInfo(
specificType = "List<String>",
@@ -1117,7 +1118,7 @@ class ServerConfig(
val extensionStores: MutableStateFlow<List<String>> by ListSetting<String>(
protoNumber = 97,
group = SettingGroup.EXTENSION,
privacySafe = false,
privacySafe = true,
defaultValue = emptyList(),
itemValidator = { url ->
if (url.isNotEmpty()) {
@@ -1127,11 +1128,7 @@ class ServerConfig(
}
},
itemToValidValue = { url ->
if (url.isNotEmpty()) {
url
} else {
null
}
url.ifEmpty { null }
},
typeInfo =
SettingsRegistry.PartialTypeInfo(

View File

@@ -3,6 +3,7 @@
package eu.kanade.tachiyomi.source.model
import kotlinx.serialization.json.JsonObject
import suwayomi.tachidesk.manga.impl.util.lang.EMPTY
class SChapterImpl : SChapter {
override lateinit var url: String
@@ -15,5 +16,5 @@ class SChapterImpl : SChapter {
override var date_upload: Long = 0
override var memo: JsonObject = JsonObject(emptyMap())
override var memo: JsonObject = JsonObject.EMPTY
}

View File

@@ -3,6 +3,7 @@
package eu.kanade.tachiyomi.source.model
import kotlinx.serialization.json.JsonObject
import suwayomi.tachidesk.manga.impl.util.lang.EMPTY
class SMangaImpl : SManga {
override lateinit var url: String
@@ -25,5 +26,5 @@ class SMangaImpl : SManga {
override var initialized: Boolean = false
override var memo: JsonObject = JsonObject(emptyMap())
override var memo: JsonObject = JsonObject.EMPTY
}

View File

@@ -16,6 +16,26 @@ import suwayomi.tachidesk.manga.model.table.ExtensionStoreTable
import suwayomi.tachidesk.manga.model.table.ExtensionTable
import suwayomi.tachidesk.server.JavalinSetup.future
class ExtensionStoreDataLoader : KotlinDataLoader<String, ExtensionStoreType> {
override val dataLoaderName = "ExtensionStoreDataLoader"
override fun getDataLoader(graphQLContext: GraphQLContext): DataLoader<String, ExtensionStoreType> =
DataLoaderFactory.newDataLoader { ids ->
future {
transaction {
addLogger(Slf4jSqlDebugLogger)
val manga =
ExtensionStoreTable
.selectAll()
.where { ExtensionStoreTable.indexUrl inList ids }
.map { ExtensionStoreType(it) }
.associateBy { it.indexUrl }
ids.map { manga[it] }
}
}
}
}
class ExtensionStoreForExtension : KotlinDataLoader<String, ExtensionStoreType> {
override val dataLoaderName = "ExtensionStoreForExtension"

View File

@@ -26,6 +26,7 @@ import suwayomi.tachidesk.graphql.types.ChapterType
import suwayomi.tachidesk.graphql.types.MetaInput
import suwayomi.tachidesk.graphql.types.SyncConflictInfoType
import suwayomi.tachidesk.manga.impl.Chapter
import suwayomi.tachidesk.manga.impl.Manga
import suwayomi.tachidesk.manga.impl.chapter.getChapterDownloadReadyById
import suwayomi.tachidesk.manga.impl.sync.KoreaderSyncService
import suwayomi.tachidesk.manga.model.table.ChapterMetaTable
@@ -173,7 +174,7 @@ class ChapterMutation {
val (clientMutationId, mangaId) = input
return future {
Chapter.fetchChapterList(mangaId)
Manga.updateMangaAndChapters(mangaId, updateManga = false)
val chapters =
transaction {

View File

@@ -19,11 +19,9 @@ import suwayomi.tachidesk.graphql.types.ChapterType
import suwayomi.tachidesk.graphql.types.MangaMetaType
import suwayomi.tachidesk.graphql.types.MangaType
import suwayomi.tachidesk.graphql.types.MetaInput
import suwayomi.tachidesk.manga.impl.Chapter
import suwayomi.tachidesk.manga.impl.Library
import suwayomi.tachidesk.manga.impl.Manga
import suwayomi.tachidesk.manga.impl.update.IUpdater
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogueSourceOrStub
import suwayomi.tachidesk.manga.model.table.ChapterTable
import suwayomi.tachidesk.manga.model.table.MangaMetaTable
import suwayomi.tachidesk.manga.model.table.MangaTable
@@ -156,7 +154,7 @@ class MangaMutation {
val (clientMutationId, id) = input
return future {
Manga.fetchManga(id)
Manga.updateMangaAndChapters(id, updateChapters = false)
val manga =
transaction {
@@ -187,20 +185,11 @@ class MangaMutation {
val (clientMutationId, id, fetchManga, fetchChapters) = input
return future {
var mangaEntry =
transaction { MangaTable.selectAll().where { MangaTable.id eq id }.first() }
val source = getCatalogueSourceOrStub(mangaEntry[MangaTable.sourceReference])
val sMangaUpdate =
Manga.fetchMangaAndChapters(
mangaEntry = mangaEntry,
source = source,
fetchDetails = fetchManga,
fetchChapters = fetchChapters,
)
Manga.updateMangaDatabase(mangaEntry, source, sMangaUpdate.manga)
mangaEntry = transaction { MangaTable.selectAll().where { MangaTable.id eq id }.first() }
Chapter.updateChapterListDatabase(mangaEntry, sMangaUpdate.chapters, source)
Manga.updateMangaAndChapters(
mangaId = id,
updateManga = fetchManga,
updateChapters = fetchChapters,
)
val (manga, chapters) =
transaction {

View File

@@ -21,6 +21,7 @@ import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import suwayomi.tachidesk.graphql.directives.RequireAuth
import suwayomi.tachidesk.graphql.queries.filter.BooleanFilter
import suwayomi.tachidesk.graphql.queries.filter.ContentRatingFilter
import suwayomi.tachidesk.graphql.queries.filter.Filter
import suwayomi.tachidesk.graphql.queries.filter.HasGetOp
import suwayomi.tachidesk.graphql.queries.filter.IntFilter
@@ -28,6 +29,7 @@ import suwayomi.tachidesk.graphql.queries.filter.LongFilter
import suwayomi.tachidesk.graphql.queries.filter.OpAnd
import suwayomi.tachidesk.graphql.queries.filter.StringFilter
import suwayomi.tachidesk.graphql.queries.filter.andFilterWithCompare
import suwayomi.tachidesk.graphql.queries.filter.andFilterWithCompareEnum
import suwayomi.tachidesk.graphql.queries.filter.andFilterWithCompareString
import suwayomi.tachidesk.graphql.queries.filter.applyOps
import suwayomi.tachidesk.graphql.server.primitives.Cursor
@@ -126,7 +128,10 @@ class ExtensionQuery {
opAnd.eq(versionCode?.toLong(), ExtensionTable.versionCode)
opAnd.eq(versionCodeLong, ExtensionTable.versionCode)
opAnd.eq(lang, ExtensionTable.lang)
opAnd.eq(isNsfw?.let { if (it) 3 else 0 }, ExtensionTable.contentRating)
opAnd.eq(
isNsfw?.let { if (it) ContentRating.PORNOGRAPHIC.ordinal else ContentRating.SAFE.ordinal },
ExtensionTable.contentRating,
)
opAnd.eq(contentRating?.ordinal, ExtensionTable.contentRating)
opAnd.eq(isInstalled, ExtensionTable.isInstalled)
opAnd.eq(hasUpdate, ExtensionTable.hasUpdate)
@@ -153,7 +158,7 @@ class ExtensionQuery {
val lang: StringFilter? = null,
@GraphQLDeprecated("", ReplaceWith("storeIndexUrl"))
val isNsfw: BooleanFilter? = null,
// val contentRating: EnumFilter<ContentRating>? = null,
val contentRating: ContentRatingFilter? = null,
val isInstalled: BooleanFilter? = null,
val hasUpdate: BooleanFilter? = null,
val isObsolete: BooleanFilter? = null,
@@ -174,7 +179,7 @@ class ExtensionQuery {
andFilterWithCompareString(ExtensionTable.versionName, versionName),
andFilterWithCompare(ExtensionTable.versionCode, versionCodeLong),
andFilterWithCompareString(ExtensionTable.lang, lang),
// andFilterWithCompareEnum(ExtensionTable.contentRating, contentRating),
andFilterWithCompareEnum(ExtensionTable.contentRating, contentRating),
andFilterWithCompare(ExtensionTable.isInstalled, isInstalled),
andFilterWithCompare(ExtensionTable.hasUpdate, hasUpdate),
andFilterWithCompare(ExtensionTable.isObsolete, isObsolete),

View File

@@ -7,33 +7,185 @@ package suwayomi.tachidesk.graphql.queries
* 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.v1.core.eq
import com.expediagroup.graphql.generator.annotations.GraphQLDeprecated
import com.expediagroup.graphql.server.extensions.getValueFromDataLoader
import graphql.schema.DataFetchingEnvironment
import org.jetbrains.exposed.v1.core.Column
import org.jetbrains.exposed.v1.core.Op
import org.jetbrains.exposed.v1.core.SortOrder
import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import suwayomi.tachidesk.graphql.directives.RequireAuth
import suwayomi.tachidesk.graphql.queries.filter.Filter
import suwayomi.tachidesk.graphql.queries.filter.HasGetOp
import suwayomi.tachidesk.graphql.queries.filter.OpAnd
import suwayomi.tachidesk.graphql.queries.filter.StringFilter
import suwayomi.tachidesk.graphql.queries.filter.andFilterWithCompareString
import suwayomi.tachidesk.graphql.queries.filter.applyOps
import suwayomi.tachidesk.graphql.server.primitives.Cursor
import suwayomi.tachidesk.graphql.server.primitives.Order
import suwayomi.tachidesk.graphql.server.primitives.OrderBy
import suwayomi.tachidesk.graphql.server.primitives.PageInfo
import suwayomi.tachidesk.graphql.server.primitives.QueryResults
import suwayomi.tachidesk.graphql.server.primitives.applyBeforeAfter
import suwayomi.tachidesk.graphql.server.primitives.greaterNotUnique
import suwayomi.tachidesk.graphql.server.primitives.lessNotUnique
import suwayomi.tachidesk.graphql.server.primitives.maybeSwap
import suwayomi.tachidesk.graphql.types.ExtensionStoreNodeList
import suwayomi.tachidesk.graphql.types.ExtensionStoreType
import suwayomi.tachidesk.manga.model.table.ExtensionStoreTable
import java.util.concurrent.CompletableFuture
class ExtensionStoreQuery {
@RequireAuth
fun extensionStore(indexUrl: String): CompletableFuture<ExtensionStoreType?> =
CompletableFuture.supplyAsync {
transaction {
ExtensionStoreTable
.selectAll()
.where { ExtensionStoreTable.indexUrl eq indexUrl }
.firstOrNull()
?.let { ExtensionStoreType(it) }
fun extensionStore(
dataFetchingEnvironment: DataFetchingEnvironment,
indexUrl: String,
): CompletableFuture<ExtensionStoreType> = dataFetchingEnvironment.getValueFromDataLoader("ExtensionStoreDataLoader", indexUrl)
enum class ExtensionStoreOrderBy(
override val column: Column<*>,
) : OrderBy<ExtensionStoreType> {
NAME(ExtensionStoreTable.name),
INDEX_URL(ExtensionStoreTable.indexUrl),
;
override fun greater(cursor: Cursor): Op<Boolean> =
when (this) {
NAME -> greaterNotUnique(ExtensionStoreTable.name, ExtensionStoreTable.id, cursor, String::toString)
INDEX_URL -> greaterNotUnique(ExtensionStoreTable.indexUrl, ExtensionStoreTable.id, cursor, String::toString)
}
override fun less(cursor: Cursor): Op<Boolean> =
when (this) {
NAME -> lessNotUnique(ExtensionStoreTable.name, ExtensionStoreTable.id, cursor, String::toString)
INDEX_URL -> lessNotUnique(ExtensionStoreTable.indexUrl, ExtensionStoreTable.id, cursor, String::toString)
}
override fun asCursor(type: ExtensionStoreType): Cursor {
val value =
when (this) {
INDEX_URL -> type.indexUrl
NAME -> type.indexUrl + "-" + type.name
}
return Cursor(value)
}
}
data class ExtensionStoreOrder(
override val by: ExtensionStoreOrderBy,
override val byType: SortOrder? = null,
) : Order<ExtensionStoreOrderBy>
data class ExtensionStoreCondition(
val id: Int? = null,
val indexUrl: String? = null,
val name: String? = null,
) : HasGetOp {
override fun getOp(): Op<Boolean>? {
val opAnd = OpAnd()
opAnd.eq(id, ExtensionStoreTable.id)
opAnd.eq(indexUrl, ExtensionStoreTable.indexUrl)
opAnd.eq(name, ExtensionStoreTable.name)
return opAnd.op
}
}
data class ExtensionStoreFilter(
val indexUrl: StringFilter? = null,
val name: StringFilter? = null,
override val and: List<ExtensionStoreFilter>? = null,
override val or: List<ExtensionStoreFilter>? = null,
override val not: ExtensionStoreFilter? = null,
) : Filter<ExtensionStoreFilter> {
override fun getOpList(): List<Op<Boolean>> =
listOfNotNull(
andFilterWithCompareString(ExtensionStoreTable.indexUrl, indexUrl),
andFilterWithCompareString(ExtensionStoreTable.name, name),
)
}
@RequireAuth
fun extensionStores(): List<ExtensionStoreType> =
transaction {
ExtensionStoreTable
.selectAll()
.toList()
.map { ExtensionStoreType(it) }
}
fun extensionStores(
condition: ExtensionStoreCondition? = null,
filter: ExtensionStoreFilter? = null,
order: List<ExtensionStoreOrder>? = null,
before: Cursor? = null,
after: Cursor? = null,
first: Int? = null,
last: Int? = null,
offset: Int? = null,
): ExtensionStoreNodeList {
val queryResults =
transaction {
val res = ExtensionStoreTable.selectAll()
res.applyOps(condition, filter)
if (order != null || (last != null || before != null)) {
val baseSort = listOf(ExtensionStoreOrder(ExtensionStoreOrderBy.INDEX_URL, SortOrder.ASC))
val actualSort = (order.orEmpty() + baseSort)
actualSort.forEach { (orderBy, orderByType) ->
val orderByColumn = orderBy.column
val orderType = orderByType.maybeSwap(last ?: before)
res.orderBy(orderByColumn to orderType)
}
}
val total = res.count()
val firstResult = res.firstOrNull()?.get(ExtensionStoreTable.indexUrl)
val lastResult = res.lastOrNull()?.get(ExtensionStoreTable.indexUrl)
res.applyBeforeAfter(
before = before,
after = after,
orderBy = order?.firstOrNull()?.by ?: ExtensionStoreOrderBy.INDEX_URL,
orderByType = order?.firstOrNull()?.byType,
)
if (first != null) {
res.limit(first).offset(offset?.toLong() ?: 0)
} else if (last != null) {
res.limit(last)
}
QueryResults(total, firstResult, lastResult, res.toList())
}
val getAsCursor: (ExtensionStoreType) -> Cursor = (order?.firstOrNull()?.by ?: ExtensionStoreOrderBy.INDEX_URL)::asCursor
val resultsAsType = queryResults.results.map { ExtensionStoreType(it) }
return ExtensionStoreNodeList(
resultsAsType,
if (resultsAsType.isEmpty()) {
emptyList()
} else {
listOfNotNull(
resultsAsType.firstOrNull()?.let {
ExtensionStoreNodeList.ExtensionStoreEdge(
getAsCursor(it),
it,
)
},
resultsAsType.lastOrNull()?.let {
ExtensionStoreNodeList.ExtensionStoreEdge(
getAsCursor(it),
it,
)
},
)
},
pageInfo =
PageInfo(
hasNextPage = queryResults.lastKey != resultsAsType.lastOrNull()?.indexUrl,
hasPreviousPage = queryResults.firstKey != resultsAsType.firstOrNull()?.indexUrl,
startCursor = resultsAsType.firstOrNull()?.let { getAsCursor(it) },
endCursor = resultsAsType.lastOrNull()?.let { getAsCursor(it) },
),
totalCount = queryResults.total.toInt(),
)
}
}

View File

@@ -15,18 +15,20 @@ import org.jetbrains.exposed.v1.core.Op
import org.jetbrains.exposed.v1.core.SortOrder
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.core.greater
import org.jetbrains.exposed.v1.core.greaterEq
import org.jetbrains.exposed.v1.core.less
import org.jetbrains.exposed.v1.core.neq
import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import suwayomi.tachidesk.graphql.directives.RequireAuth
import suwayomi.tachidesk.graphql.queries.filter.BooleanFilter
import suwayomi.tachidesk.graphql.queries.filter.ContentRatingFilter
import suwayomi.tachidesk.graphql.queries.filter.Filter
import suwayomi.tachidesk.graphql.queries.filter.HasGetOp
import suwayomi.tachidesk.graphql.queries.filter.LongFilter
import suwayomi.tachidesk.graphql.queries.filter.OpAnd
import suwayomi.tachidesk.graphql.queries.filter.StringFilter
import suwayomi.tachidesk.graphql.queries.filter.andFilterWithCompareEntity
import suwayomi.tachidesk.graphql.queries.filter.andFilterWithCompareEnum
import suwayomi.tachidesk.graphql.queries.filter.andFilterWithCompareString
import suwayomi.tachidesk.graphql.queries.filter.applyOps
import suwayomi.tachidesk.graphql.server.primitives.Cursor
@@ -93,7 +95,7 @@ class SourceQuery {
val id: Long? = null,
val name: String? = null,
val lang: String? = null,
@GraphQLDeprecated("replace with contentRating == 3", ReplaceWith("contentRating"))
@GraphQLDeprecated("replace with contentRating == ContentRating.PORNOGRAPHIC", ReplaceWith("contentRating"))
val isNsfw: Boolean? = null,
val contentRating: ContentRating? = null,
) : HasGetOp {
@@ -102,7 +104,14 @@ class SourceQuery {
opAnd.eq(id, SourceTable.id)
opAnd.eq(name, SourceTable.name)
opAnd.eq(lang, SourceTable.lang)
opAnd.andWhere(isNsfw) { if (it) SourceTable.contentRating greaterEq 3 else SourceTable.contentRating less 3 }
opAnd.andWhere(isNsfw) {
if (it) {
SourceTable.contentRating eq ContentRating.PORNOGRAPHIC.ordinal
} else {
SourceTable.contentRating neq
ContentRating.PORNOGRAPHIC.ordinal
}
}
opAnd.andWhere(contentRating) { SourceTable.contentRating eq it.getValue() }
return opAnd.op
@@ -113,9 +122,9 @@ class SourceQuery {
val id: LongFilter? = null,
val name: StringFilter? = null,
val lang: StringFilter? = null,
@GraphQLDeprecated("replace with contentRating == 3", ReplaceWith("contentRating"))
@GraphQLDeprecated("replace with contentRating == ContentRating.PORNOGRAPHIC", ReplaceWith("contentRating"))
val isNsfw: BooleanFilter? = null,
// val contentRating: EnumFilter<ContentRating>? = null,
val contentRating: ContentRatingFilter? = null,
override val and: List<SourceFilter>? = null,
override val or: List<SourceFilter>? = null,
override val not: SourceFilter? = null,
@@ -125,7 +134,7 @@ class SourceQuery {
andFilterWithCompareEntity(SourceTable.id, id),
andFilterWithCompareString(SourceTable.name, name),
andFilterWithCompareString(SourceTable.lang, lang),
// andFilterWithCompareEnum(SourceTable.contentRating, contentRating)
andFilterWithCompareEnum(SourceTable.contentRating, contentRating),
)
}

View File

@@ -28,6 +28,7 @@ import org.jetbrains.exposed.v1.core.upperCase
import org.jetbrains.exposed.v1.core.wrap
import org.jetbrains.exposed.v1.jdbc.Query
import org.jetbrains.exposed.v1.jdbc.andWhere
import suwayomi.tachidesk.graphql.types.ContentRating
class ILikeEscapeOp(
expr1: Expression<*>,
@@ -329,23 +330,23 @@ data class DoubleFilter(
)
}
data class EnumFilter<T : Enum<T>>(
data class ContentRatingFilter(
override val isNull: Boolean? = null,
override val equalTo: T? = null,
override val notEqualTo: T? = null,
override val notEqualToAll: List<T>? = null,
override val notEqualToAny: List<T>? = null,
override val distinctFrom: T? = null,
override val distinctFromAll: List<T>? = null,
override val distinctFromAny: List<T>? = null,
override val notDistinctFrom: T? = null,
override val `in`: List<T>? = null,
override val notIn: List<T>? = null,
override val lessThan: T? = null,
override val lessThanOrEqualTo: T? = null,
override val greaterThan: T? = null,
override val greaterThanOrEqualTo: T? = null,
) : ComparableScalarFilter<T>
override val equalTo: ContentRating? = null,
override val notEqualTo: ContentRating? = null,
override val notEqualToAll: List<ContentRating>? = null,
override val notEqualToAny: List<ContentRating>? = null,
override val distinctFrom: ContentRating? = null,
override val distinctFromAll: List<ContentRating>? = null,
override val distinctFromAny: List<ContentRating>? = null,
override val notDistinctFrom: ContentRating? = null,
override val `in`: List<ContentRating>? = null,
override val notIn: List<ContentRating>? = null,
override val lessThan: ContentRating? = null,
override val lessThanOrEqualTo: ContentRating? = null,
override val greaterThan: ContentRating? = null,
override val greaterThanOrEqualTo: ContentRating? = null,
) : ComparableScalarFilter<ContentRating>
data class StringFilter(
override val isNull: Boolean? = null,

View File

@@ -22,6 +22,7 @@ import suwayomi.tachidesk.graphql.dataLoaders.DownloadedChapterCountForMangaData
import suwayomi.tachidesk.graphql.dataLoaders.ExtensionDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.ExtensionForExtensionStore
import suwayomi.tachidesk.graphql.dataLoaders.ExtensionForSourceDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.ExtensionStoreDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.ExtensionStoreForExtension
import suwayomi.tachidesk.graphql.dataLoaders.FirstUnreadChapterForMangaDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.GlobalMetaDataLoader
@@ -81,6 +82,7 @@ class TachideskDataLoaderRegistryFactory {
ExtensionDataLoader(),
ExtensionForSourceDataLoader(),
ExtensionForExtensionStore(),
ExtensionStoreDataLoader(),
ExtensionStoreForExtension(),
TrackerDataLoader(),
TrackerStatusesDataLoader(),

View File

@@ -10,7 +10,11 @@ package suwayomi.tachidesk.graphql.types
import com.expediagroup.graphql.server.extensions.getValueFromDataLoader
import graphql.schema.DataFetchingEnvironment
import org.jetbrains.exposed.v1.core.ResultRow
import suwayomi.tachidesk.graphql.server.primitives.Cursor
import suwayomi.tachidesk.graphql.server.primitives.Edge
import suwayomi.tachidesk.graphql.server.primitives.Node
import suwayomi.tachidesk.graphql.server.primitives.NodeList
import suwayomi.tachidesk.graphql.server.primitives.PageInfo
import suwayomi.tachidesk.manga.model.table.ExtensionStoreTable
import java.util.concurrent.CompletableFuture
@@ -36,3 +40,45 @@ class ExtensionStoreType(
fun extension(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture<ExtensionNodeList> =
dataFetchingEnvironment.getValueFromDataLoader<String, ExtensionNodeList>("ExtensionForExtensionStore", indexUrl)
}
data class ExtensionStoreNodeList(
override val nodes: List<ExtensionStoreType>,
override val edges: List<ExtensionStoreEdge>,
override val pageInfo: PageInfo,
override val totalCount: Int,
) : NodeList() {
data class ExtensionStoreEdge(
override val cursor: Cursor,
override val node: ExtensionStoreType,
) : Edge()
companion object {
fun List<ExtensionStoreType>.toNodeList(): ExtensionStoreNodeList =
ExtensionStoreNodeList(
nodes = this,
edges = getEdges(),
pageInfo =
PageInfo(
hasNextPage = false,
hasPreviousPage = false,
startCursor = Cursor(0.toString()),
endCursor = Cursor(lastIndex.toString()),
),
totalCount = size,
)
private fun List<ExtensionStoreType>.getEdges(): List<ExtensionStoreEdge> {
if (isEmpty()) return emptyList()
return listOf(
ExtensionStoreEdge(
cursor = Cursor("0"),
node = first(),
),
ExtensionStoreEdge(
cursor = Cursor(lastIndex.toString()),
node = last(),
),
)
}
}
}

View File

@@ -60,7 +60,7 @@ class ExtensionType(
versionCode = row[ExtensionTable.versionCode].toInt(),
versionCodeLong = row[ExtensionTable.versionCode],
lang = row[ExtensionTable.lang],
isNsfw = row[ExtensionTable.contentRating] == 3,
isNsfw = row[ExtensionTable.contentRating] == ContentRating.PORNOGRAPHIC.ordinal,
contentRating = ContentRating.valueOf(row[ExtensionTable.contentRating]),
isInstalled = row[ExtensionTable.isInstalled],
hasUpdate = row[ExtensionTable.hasUpdate],

View File

@@ -63,7 +63,7 @@ class SourceType(
iconUrl = Extension.proxyExtensionIconUrl(sourceExtension[ExtensionTable.pkgName]),
supportsLatest = catalogueSource.supportsLatest,
isConfigurable = catalogueSource is ConfigurableSource,
isNsfw = row[SourceTable.contentRating] >= 3,
isNsfw = row[SourceTable.contentRating] == ContentRating.PORNOGRAPHIC.ordinal,
displayName = catalogueSource.toString(),
homeUrl = runCatching { (catalogueSource as? HttpSource)?.getHomeUrl() }.getOrNull(),
baseUrl = runCatching { (catalogueSource as? HttpSource)?.baseUrl }.getOrNull(),

View File

@@ -55,7 +55,7 @@ object UpdateController {
)
/**
* Class made for handling return type in the documentation for [recentChapters],
* Class made for handling return type in the documentation for [UpdateController.recentChapters],
* since OpenApi cannot handle runtime generics.
*/
private class PagedMangaChapterListDataClass : PaginatedList<MangaChapterDataClass>(emptyList(), false)

View File

@@ -81,7 +81,7 @@ object Manga {
return if (!onlineFetch && mangaEntry[MangaTable.initialized]) {
MangaTable.toDataClass(mangaEntry)
} else { // initialize manga
fetchManga(mangaId) ?: return MangaTable.toDataClass(mangaEntry)
updateMangaAndChapters(mangaId, updateChapters = false)
mangaEntry = transaction { MangaTable.selectAll().where { MangaTable.id eq mangaId }.first() }
@@ -152,6 +152,38 @@ object Manga {
}
}
suspend fun updateMangaAndChapters(
mangaId: Int,
updateManga: Boolean = true,
updateChapters: Boolean = true,
) {
mangaInfoMutex.get(mangaId) { Mutex() }.withLock {
var mangaEntry =
transaction { MangaTable.selectAll().where { MangaTable.id eq mangaId }.first() }
val source =
getCatalogueSourceOrNull(mangaEntry[MangaTable.sourceReference])
?: throw NullPointerException("Missing source ${mangaEntry[MangaTable.sourceReference]}")
val mangaUpdate =
fetchMangaAndChapters(
mangaEntry,
source,
fetchDetails = updateManga,
fetchChapters = updateChapters,
)
if (updateManga) {
updateMangaDatabase(mangaEntry, source, mangaUpdate.manga)
mangaEntry =
transaction {
MangaTable.selectAll().where { MangaTable.id eq mangaId }.first()
}
}
if (updateChapters) {
Chapter.updateChapterListDatabase(mangaEntry, mangaUpdate.chapters, source)
}
}
}
fun updateMangaDatabase(
mangaEntry: ResultRow,
source: CatalogueSource,

View File

@@ -29,6 +29,7 @@ import suwayomi.tachidesk.manga.impl.extension.Extension.proxyExtensionIconUrl
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogueSourceOrNull
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogueSourceOrStub
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.unregisterCatalogueSource
import suwayomi.tachidesk.manga.model.dataclass.ContentRating
import suwayomi.tachidesk.manga.model.dataclass.SourceDataClass
import suwayomi.tachidesk.manga.model.table.ExtensionTable
import suwayomi.tachidesk.manga.model.table.SourceMetaTable
@@ -52,7 +53,7 @@ object Source {
iconUrl = proxyExtensionIconUrl(sourceExtension[ExtensionTable.pkgName]),
supportsLatest = catalogueSource.supportsLatest,
isConfigurable = catalogueSource is ConfigurableSource,
isNsfw = it[SourceTable.contentRating] >= 3,
isNsfw = it[SourceTable.contentRating] == ContentRating.PORNOGRAPHIC.ordinal,
displayName = catalogueSource.toString(),
baseUrl = runCatching { (catalogueSource as? HttpSource)?.baseUrl }.getOrNull(),
)
@@ -73,7 +74,7 @@ object Source {
iconUrl = proxyExtensionIconUrl(extension[ExtensionTable.pkgName]),
supportsLatest = catalogueSource.supportsLatest,
isConfigurable = catalogueSource is ConfigurableSource,
isNsfw = source[SourceTable.contentRating] >= 3,
isNsfw = source[SourceTable.contentRating] == ContentRating.PORNOGRAPHIC.ordinal,
displayName = catalogueSource.toString(),
baseUrl = runCatching { (catalogueSource as? HttpSource)?.baseUrl }.getOrNull(),
)

View File

@@ -28,6 +28,7 @@ import org.jetbrains.exposed.v1.jdbc.select
import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import org.jetbrains.exposed.v1.jdbc.update
import suwayomi.tachidesk.graphql.types.ContentRating
import suwayomi.tachidesk.manga.impl.util.PackageTools
import suwayomi.tachidesk.manga.impl.util.PackageTools.EXTENSION_FEATURE
import suwayomi.tachidesk.manga.impl.util.PackageTools.LIB_VERSION_MAX
@@ -197,7 +198,13 @@ object Extension {
it[versionName] = packageInfo.versionName
it[versionCode] = packageInfo.versionCode.toLong()
it[lang] = extensionLang
it[contentRating] = if (isNsfw) 3 else 0 // todo will change
// todo will change
it[contentRating] =
if (isNsfw) {
ContentRating.PORNOGRAPHIC.ordinal
} else {
ContentRating.SAFE.ordinal
}
}
}
@@ -222,7 +229,7 @@ object Extension {
it[name] = httpSource.name
it[lang] = httpSource.lang
it[extension] = extensionId
it[contentRating] = if (isNsfw) 3 else 0
it[contentRating] = if (isNsfw) ContentRating.PORNOGRAPHIC.ordinal else ContentRating.SAFE.ordinal
}
logger.debug { "Installed source ${httpSource.name} (${httpSource.lang}) with id:${httpSource.id}" }
}

View File

@@ -49,44 +49,41 @@ object ExtensionStoreService {
): ExtensionStore {
var updatedIndexUrl = indexUrl
return try {
val response = network.client.newCall(GET(indexUrl)).awaitSuccess()
response.body
.source()
.use { source ->
network.client.newCall(GET(indexUrl)).awaitSuccess().body.source().use { source ->
try {
protoBuf.decodeFromByteArray<NetworkExtensionStore>(source.peek().readByteArray())
} catch (e: IllegalArgumentException) {
logger.debug { "Failed to decode as protobuf, trying JSON" }
try {
protoBuf.decodeFromByteArray<NetworkExtensionStore>(source.peek().readByteArray())
json.decodeFromBufferedSource<NetworkExtensionStore>(source.peek())
} catch (e: IllegalArgumentException) {
logger.debug { "Failed to decode as protobuf, trying JSON" }
if (forceV2) throw e
try {
json.decodeFromBufferedSource<NetworkExtensionStore>(source.peek())
} catch (_: IllegalArgumentException) {
logger.debug { "Failed to decode as NetworkExtensionStore, trying LegacyExtensionRepo" }
val legacyIndex =
try {
json.decodeFromBufferedSource<NetworkLegacyExtensionRepo>(source.peek())
} catch (e: IllegalArgumentException) {
if (!indexUrl.endsWith("/index.min.json")) {
throw e
}
logger.debug { "Retrying with /index.min.json" }
updatedIndexUrl = indexUrl.replace("/index.min.json", "/repo.json")
network.client.newCall(GET(updatedIndexUrl)).awaitSuccess().body.source().use {
json.decodeFromBufferedSource<NetworkLegacyExtensionRepo>(it)
}
logger.debug { "Failed to decode as NetworkExtensionStore, trying LegacyExtensionRepo" }
val legacyIndex =
try {
json.decodeFromBufferedSource<NetworkLegacyExtensionRepo>(source.peek())
} catch (e: IllegalArgumentException) {
if (!indexUrl.endsWith("/index.min.json")) {
throw e
}
logger.debug { "Checking for new repo.json format" }
updatedIndexUrl = indexUrl.replace("/index.min.json", "/repo.json")
network.client.newCall(GET(updatedIndexUrl)).awaitSuccess().body.source().use {
json.decodeFromBufferedSource<NetworkLegacyExtensionRepo>(it)
}
if (legacyIndex.indexV2 != null) {
return fetch(legacyIndex.indexV2, forceV2 = true)
} else {
legacyIndex
}
if (legacyIndex.indexV2 != null) {
return fetch(legacyIndex.indexV2, forceV2 = true)
} else {
legacyIndex
}
}
}.toExtensionStore(updatedIndexUrl)
}
} catch (e: Exception) {
if (e is CancellationException) throw e
logger.debug(e) { "Failed to fetch extension store '$indexUrl'" }
logger.error(e) { "Failed to fetch extension store '$indexUrl'" }
throw e
}
}
@@ -127,24 +124,27 @@ object ExtensionStoreService {
transaction {
ExtensionStoreTable.selectAll().toList()
}
return stores.mapNotNull { storeRow ->
var needsPrefUpdate = false
val updateStores = stores.mapNotNull { storeRow ->
val oldIndexUrl = storeRow[ExtensionStoreTable.indexUrl]
val oldName = storeRow[ExtensionStoreTable.name]
try {
val store = fetch(oldIndexUrl)
upsert(store)
if (store.indexUrl != oldIndexUrl) {
transaction {
ExtensionStoreTable.deleteWhere { ExtensionStoreTable.indexUrl eq oldIndexUrl }
}
syncDbToPrefs()
needsPrefUpdate = true
}
upsert(store)
store
} catch (e: Exception) {
logger.warn(e) { "Failed to fetch extension store '$oldName ($oldIndexUrl)'" }
null
}
}
if (needsPrefUpdate) syncDbToPrefs()
return updateStores
}
fun syncDbToPrefs() {
@@ -178,22 +178,33 @@ object ExtensionStoreService {
}
val toAdd = prefUrls - dbStores.keys
val toRemove = (dbStores.keys - prefUrls).toMutableSet()
var needsPrefUpdate = toRemove.isNotEmpty()
toAdd.forEach { url ->
try {
val store = fetch(url)
if (store.indexUrl != url) {
transaction {
ExtensionStoreTable.deleteWhere { ExtensionStoreTable.indexUrl eq url }
}
needsPrefUpdate = true
toRemove -= store.indexUrl
}
upsert(store)
} catch (e: Exception) {
logger.warn(e) { "Failed to sync preference store '$url' to database" }
}
}
val toRemove = dbStores.keys - prefUrls
if (toRemove.isNotEmpty()) {
transaction {
ExtensionStoreTable.deleteWhere { ExtensionStoreTable.indexUrl inList toRemove.toList() }
}
}
if (needsPrefUpdate) {
syncDbToPrefs()
}
}
suspend fun getExtensions(store: ExtensionStore): List<ExtensionInfo> {

View File

@@ -22,6 +22,7 @@ import org.jetbrains.exposed.v1.jdbc.statements.toExecutable
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import org.jetbrains.exposed.v1.jdbc.update
import suwayomi.tachidesk.manga.impl.extension.Extension.proxyExtensionIconUrl
import suwayomi.tachidesk.manga.model.dataclass.ContentRating
import suwayomi.tachidesk.manga.model.dataclass.ExtensionDataClass
import suwayomi.tachidesk.manga.model.dataclass.ExtensionInfo
import suwayomi.tachidesk.manga.model.table.ExtensionTable
@@ -80,7 +81,7 @@ object ExtensionsList {
versionName = it[ExtensionTable.versionName],
versionCode = it[ExtensionTable.versionCode].toInt(),
lang = it[ExtensionTable.lang],
isNsfw = it[ExtensionTable.contentRating] == 3,
isNsfw = it[ExtensionTable.contentRating] == ContentRating.PORNOGRAPHIC.ordinal,
installed = it[ExtensionTable.isInstalled],
hasUpdate = it[ExtensionTable.hasUpdate],
obsolete = it[ExtensionTable.isObsolete],

View File

@@ -121,6 +121,14 @@ fun NetworkExtensionStore.toExtensionInfos(store: ExtensionStore): List<Extensio
name = source.name,
lang = source.language,
homeUrl = source.homeUrl,
message = source.message,
contentRating =
when (source.contentRating) {
NetworkExtensionStore.ContentRating.SAFE -> ContentRating.SAFE
NetworkExtensionStore.ContentRating.SUGGESTIVE -> ContentRating.SUGGESTIVE
NetworkExtensionStore.ContentRating.EROTICA -> ContentRating.EROTICA
NetworkExtensionStore.ContentRating.PORNOGRAPHIC -> ContentRating.PORNOGRAPHIC
},
)
},
)

View File

@@ -58,6 +58,8 @@ fun NetworkLegacyExtension.toExtensionInfo(
name = name,
lang = lang,
homeUrl = "",
message = null,
contentRating = if (nsfw == 1) ContentRating.PORNOGRAPHIC else ContentRating.SAFE,
),
)
} else {
@@ -67,6 +69,8 @@ fun NetworkLegacyExtension.toExtensionInfo(
name = source.name,
lang = source.lang,
homeUrl = source.baseUrl,
message = null,
contentRating = if (nsfw == 1) ContentRating.PORNOGRAPHIC else ContentRating.SAFE,
)
}
},

View File

@@ -31,7 +31,6 @@ import kotlinx.coroutines.sync.withPermit
import suwayomi.tachidesk.global.impl.sync.SyncManager
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.model.dataclass.CategoryDataClass
import suwayomi.tachidesk.manga.model.dataclass.IncludeOrExclude
@@ -311,10 +310,10 @@ class Updater : IUpdater {
tracker[job.manga.id] =
try {
logger.info { "Updating ${job.manga}" }
if (serverConfig.updateMangas.value || !job.manga.initialized) {
Manga.getManga(job.manga.id, true)
}
Chapter.getChapterList(job.manga.id, true)
Manga.updateMangaAndChapters(
job.manga.id,
updateManga = serverConfig.updateMangas.value || !job.manga.initialized,
)
job.copy(status = JobStatus.COMPLETE)
} catch (e: Exception) {
logger.error(e) { "Error while updating ${job.manga}" }

View File

@@ -46,7 +46,7 @@ object ImageResponse {
/**
* Get a cached image response
*
* Note: The caller should also call [clearCachedImage] when appropriate
* Note: The caller should also call [ImageResponse.clearCachedImage] when appropriate
*
* @param cacheSavePath where to save the cached image. Caller should decide to use perma cache or temp cache (OS temp dir)
* @param fileName what the saved cache file should be named

View File

@@ -26,6 +26,8 @@ data class ExtensionSource(
val name: String,
val lang: String,
val homeUrl: String,
val message: String?,
val contentRating: ContentRating,
)
enum class ContentRating {

View File

@@ -3,6 +3,7 @@ package suwayomi.tachidesk.opds.impl
import io.github.oshai.kotlinlogging.KotlinLogging
import org.jetbrains.exposed.v1.core.SortOrder
import suwayomi.tachidesk.i18n.MR
import suwayomi.tachidesk.manga.impl.Manga
import suwayomi.tachidesk.manga.impl.MangaList.proxyThumbnailUrl
import suwayomi.tachidesk.manga.model.table.ChapterTable
import suwayomi.tachidesk.opds.constants.OpdsConstants
@@ -17,7 +18,6 @@ import suwayomi.tachidesk.opds.repository.ChapterRepository
import suwayomi.tachidesk.opds.repository.MangaRepository
import suwayomi.tachidesk.opds.repository.NavigationRepository
import suwayomi.tachidesk.opds.util.OpdsDateUtil
import suwayomi.tachidesk.opds.util.OpdsStringUtil
import suwayomi.tachidesk.opds.util.OpdsXmlUtil
import suwayomi.tachidesk.server.serverConfig
import java.util.Locale
@@ -647,8 +647,7 @@ object OpdsFeedBuilder {
// If no chapters are found in the database, attempt to fetch them from the source.
if (chapterEntries.isEmpty() && totalChapters == 0L) {
try {
suwayomi.tachidesk.manga.impl.Chapter
.fetchChapterList(mangaId)
Manga.updateMangaAndChapters(mangaId, updateManga = false)
// Re-query after fetching.
val (refetchedChapters, refetchedTotal) =

View File

@@ -8,6 +8,7 @@ package suwayomi.tachidesk.server.database.migration
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import de.neonew.exposed.migrations.helpers.SQLMigration
import suwayomi.tachidesk.graphql.types.ContentRating
import suwayomi.tachidesk.graphql.types.DatabaseType
import suwayomi.tachidesk.server.database.migration.helpers.MAYBE_TYPE_PREFIX
import suwayomi.tachidesk.server.database.migration.helpers.UNLIMITED_TEXT
@@ -43,11 +44,11 @@ class M0057_AddNewExtensionApiFields : SQLMigration() {
}
ALTER TABLE EXTENSION ADD COLUMN apk_url VARCHAR(2048);
ALTER TABLE EXTENSION ADD COLUMN content_rating INTEGER DEFAULT 0;
UPDATE EXTENSION SET content_rating = 3 WHERE is_nsfw = TRUE;
UPDATE EXTENSION SET content_rating = ${ContentRating.PORNOGRAPHIC.ordinal} WHERE is_nsfw = TRUE;
ALTER TABLE EXTENSION DROP COLUMN is_nsfw;
ALTER TABLE SOURCE ADD COLUMN message $UNLIMITED_TEXT;
ALTER TABLE SOURCE ADD COLUMN content_rating INTEGER DEFAULT 0;
UPDATE SOURCE SET content_rating = 3 WHERE is_nsfw = TRUE;
UPDATE SOURCE SET content_rating = ${ContentRating.PORNOGRAPHIC.ordinal} WHERE is_nsfw = TRUE;
ALTER TABLE SOURCE DROP COLUMN is_nsfw;

View File

@@ -18,7 +18,6 @@ import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import rx.Observable
import suwayomi.tachidesk.manga.impl.Search.FilterChange
import suwayomi.tachidesk.manga.impl.Search.FilterObject
import suwayomi.tachidesk.manga.impl.Search.SerializableGroup
@@ -41,12 +40,11 @@ class SearchTest : ApplicationTest() {
) : StubSource(id) {
var mangas: List<SManga> = emptyList()
@Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getSearchManga"))
override fun fetchSearchManga(
override suspend fun getSearchManga(
page: Int,
query: String,
filters: FilterList,
): Observable<MangasPage> = Observable.just(MangasPage(mangas, false))
): MangasPage = MangasPage(mangas, false)
}
private val sourceId = 1L

View File

@@ -66,6 +66,7 @@ fun createChapters(
this[ChapterTable.sourceOrder] = it
this[ChapterTable.isRead] = read
this[ChapterTable.manga] = mangaId
this[ChapterTable.memo] = "{}"
}
}
}