Extension API 1.6 (#2120)

* Non-Extension Index changes for 1.6

* Changelog

* Minor fixes

* Implement extension store

* Test build fix

* Docs

* Simplify fetching manga and chapters

* Use EMPTY JsonObject

* Update docs/Configuring-Suwayomi‐Server.md

Co-authored-by: Constantin Piber <59023762+cpiber@users.noreply.github.com>

* Improve Fetch Extension Store

* Fixes

* Simplify deprecated isNsfw in SourceQuery

* Simplify ContentRating in Source.kt

* Simplify isNsfw in SourceType

* No magic numbers for ContentRating, improves safety for future versions of extension api

* Fix SearchTest

* Lint

* Lint

* Optimize imports and fix unchecked cast warning

* Proper extension store queries

* Optimize import fixes

* Add ContentRatingFilter

* Improve extension store sync

* fix: re-sync (#2121)

* Lint

* Add ExtenionStores to the fetchExtensions result since its possible for the stores to change.

* Use a single version of ContentRating

* Exclude ServerConfig.extensionStores from GraphQL

* Use syncDbToPrefs in ExtensionStoreMutation

* Optimize Imports

* Update server/server-config/src/main/kotlin/suwayomi/tachidesk/server/ServerConfig.kt

Co-authored-by: Constantin Piber <59023762+cpiber@users.noreply.github.com>

* Remove replaceWith and add specific description for GQL APIs

* Include OkHttp ZSTD

* Update to latest Mihon extension lib

* Fix latest Mihon Extension Lib

* Lint

* Optimize imports

* Lint

* Review fixes

* Add a index to extesnion table store url

* Lint

---------

Co-authored-by: Constantin Piber <59023762+cpiber@users.noreply.github.com>
This commit is contained in:
Mitchell Syer
2026-06-27 13:39:28 -04:00
committed by GitHub
parent c8f5d83e9c
commit 2d535b44d8
84 changed files with 2576 additions and 1007 deletions

View File

@@ -0,0 +1,57 @@
package suwayomi.tachidesk.graphql.dataLoaders
import com.expediagroup.graphql.dataloader.KotlinDataLoader
import graphql.GraphQLContext
import org.dataloader.DataLoader
import org.dataloader.DataLoaderFactory
import org.jetbrains.exposed.v1.core.Slf4jSqlDebugLogger
import org.jetbrains.exposed.v1.core.inList
import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import suwayomi.tachidesk.graphql.types.ExtensionNodeList
import suwayomi.tachidesk.graphql.types.ExtensionNodeList.Companion.toNodeList
import suwayomi.tachidesk.graphql.types.ExtensionStoreType
import suwayomi.tachidesk.graphql.types.ExtensionType
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 extensionStoreByIndexUrl =
ExtensionStoreTable
.selectAll()
.where { ExtensionStoreTable.indexUrl inList ids }
.map { ExtensionStoreType(it) }
.associateBy { it.indexUrl }
ids.map { extensionStoreByIndexUrl[it] }
}
}
}
}
class ExtensionsForExtensionStore : KotlinDataLoader<String, ExtensionNodeList> {
override val dataLoaderName = "ExtensionsForExtensionStore"
override fun getDataLoader(graphQLContext: GraphQLContext): DataLoader<String, ExtensionNodeList> =
DataLoaderFactory.newDataLoader<String, ExtensionNodeList> { ids ->
future {
transaction {
addLogger(Slf4jSqlDebugLogger)
val extensionByIndexUrl =
ExtensionTable
.selectAll()
.where { ExtensionTable.storeIndexUrl inList ids }
.map { ExtensionType(it) }
.groupBy { it.storeIndexUrl }
ids.map { (extensionByIndexUrl[it] ?: emptyList()).toNodeList() }
}
}
}
}

View File

@@ -2,6 +2,7 @@
package suwayomi.tachidesk.graphql.mutations
import com.expediagroup.graphql.generator.annotations.GraphQLDeprecated
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import org.jetbrains.exposed.v1.core.LikePattern
@@ -25,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
@@ -167,11 +169,12 @@ class ChapterMutation {
)
@RequireAuth
@GraphQLDeprecated("Deprecated in Tachiyomix 1.6", ReplaceWith("fetchMangaAndChapters"))
fun fetchChapters(input: FetchChaptersInput): CompletableFuture<FetchChaptersPayload?> {
val (clientMutationId, mangaId) = input
return future {
Chapter.fetchChapterList(mangaId)
Manga.updateMangaAndChapters(mangaId, updateManga = false)
val chapters =
transaction {

View File

@@ -10,9 +10,11 @@ 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.types.ExtensionStoreType
import suwayomi.tachidesk.graphql.types.ExtensionType
import suwayomi.tachidesk.manga.impl.extension.Extension
import suwayomi.tachidesk.manga.impl.extension.ExtensionsList
import suwayomi.tachidesk.manga.model.table.ExtensionStoreTable
import suwayomi.tachidesk.manga.model.table.ExtensionTable
import suwayomi.tachidesk.server.JavalinSetup.future
import java.util.concurrent.CompletableFuture
@@ -129,6 +131,7 @@ class ExtensionMutation {
data class FetchExtensionsPayload(
val clientMutationId: String?,
val extensions: List<ExtensionType>,
val extensionStores: List<ExtensionStoreType>,
)
@RequireAuth
@@ -146,9 +149,17 @@ class ExtensionMutation {
.map { ExtensionType(it) }
}
val extensionStores =
transaction {
ExtensionStoreTable
.selectAll()
.map { ExtensionStoreType(it) }
}
FetchExtensionsPayload(
clientMutationId = clientMutationId,
extensions = extensions,
extensionStores = extensionStores,
)
}
}

View File

@@ -0,0 +1,104 @@
package suwayomi.tachidesk.graphql.mutations
/*
* 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 org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.jdbc.deleteWhere
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.types.ExtensionStoreType
import suwayomi.tachidesk.manga.impl.extension.ExtensionStoreService
import suwayomi.tachidesk.manga.model.table.ExtensionStoreTable
import suwayomi.tachidesk.server.JavalinSetup.future
import java.util.concurrent.CompletableFuture
class ExtensionStoreMutation {
data class AddExtensionStoreInput(
val clientMutationId: String? = null,
val indexUrl: String,
)
data class AddExtensionStorePayload(
val clientMutationId: String?,
val extensionStore: ExtensionStoreType,
)
@RequireAuth
fun addExtensionStore(input: AddExtensionStoreInput): CompletableFuture<AddExtensionStorePayload?> {
val (clientMutationId, indexUrl) = input
return future {
val store = ExtensionStoreService.fetch(indexUrl)
ExtensionStoreService.upsert(store)
ExtensionStoreService.syncDbToPrefs()
val row =
transaction {
ExtensionStoreTable
.selectAll()
.where { ExtensionStoreTable.indexUrl eq store.indexUrl }
.first()
}
AddExtensionStorePayload(
clientMutationId = clientMutationId,
extensionStore = ExtensionStoreType(row),
)
}
}
data class RemoveExtensionStoreInput(
val clientMutationId: String? = null,
val indexUrl: String,
)
data class RemoveExtensionStorePayload(
val clientMutationId: String?,
val extensionStore: ExtensionStoreType?,
)
@RequireAuth
fun removeExtensionStore(input: RemoveExtensionStoreInput): CompletableFuture<RemoveExtensionStorePayload?> {
val (clientMutationId, indexUrl) = input
return future {
val store =
transaction {
ExtensionStoreTable
.selectAll()
.where { ExtensionStoreTable.indexUrl eq indexUrl }
.firstOrNull()
?.let { ExtensionStoreType(it) }
}
store?.let {
transaction {
ExtensionStoreTable.deleteWhere { ExtensionStoreTable.indexUrl eq indexUrl }
}
}
ExtensionStoreService.syncDbToPrefs()
RemoveExtensionStorePayload(
clientMutationId = clientMutationId,
extensionStore =
store?.let {
ExtensionStoreType(
name = it.name,
badgeLabel = it.badgeLabel,
signingKey = it.signingKey,
contactWebsite = it.contactWebsite,
contactDiscord = it.contactDiscord,
indexUrl = it.indexUrl,
isLegacy = it.isLegacy,
extensionListUrl = it.extensionListUrl,
)
},
)
}
}
}

View File

@@ -2,6 +2,7 @@
package suwayomi.tachidesk.graphql.mutations
import com.expediagroup.graphql.generator.annotations.GraphQLDeprecated
import org.jetbrains.exposed.v1.core.LikePattern
import org.jetbrains.exposed.v1.core.Op
import org.jetbrains.exposed.v1.core.and
@@ -14,12 +15,14 @@ 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.directives.RequireAuth
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.Library
import suwayomi.tachidesk.manga.impl.Manga
import suwayomi.tachidesk.manga.impl.update.IUpdater
import suwayomi.tachidesk.manga.model.table.ChapterTable
import suwayomi.tachidesk.manga.model.table.MangaMetaTable
import suwayomi.tachidesk.manga.model.table.MangaTable
import suwayomi.tachidesk.manga.model.table.toDataClass
@@ -146,11 +149,12 @@ class MangaMutation {
)
@RequireAuth
@GraphQLDeprecated("Deprecated in Tachiyomix 1.6", ReplaceWith("fetchMangaAndChapters"))
fun fetchManga(input: FetchMangaInput): CompletableFuture<FetchMangaPayload?> {
val (clientMutationId, id) = input
return future {
Manga.fetchManga(id)
Manga.updateMangaAndChapters(id, updateChapters = false)
val manga =
transaction {
@@ -163,6 +167,49 @@ class MangaMutation {
}
}
data class FetchMangaAndChaptersInput(
val clientMutationId: String? = null,
val id: Int,
val fetchManga: Boolean,
val fetchChapters: Boolean,
)
data class FetchMangaAndChaptersPayload(
val clientMutationId: String?,
val manga: MangaType,
val chapters: List<ChapterType>,
)
@RequireAuth
fun fetchMangaAndChapters(input: FetchMangaAndChaptersInput): CompletableFuture<FetchMangaAndChaptersPayload?> {
val (clientMutationId, id, fetchManga, fetchChapters) = input
return future {
Manga.updateMangaAndChapters(
mangaId = id,
updateManga = fetchManga,
updateChapters = fetchChapters,
)
val (manga, chapters) =
transaction {
Pair(
MangaTable.selectAll().where { MangaTable.id eq id }.first(),
ChapterTable
.selectAll()
.where { ChapterTable.manga eq id }
.orderBy(ChapterTable.sourceOrder)
.map { ChapterType(it) },
)
}
FetchMangaAndChaptersPayload(
clientMutationId = clientMutationId,
manga = MangaType(manga),
chapters = chapters,
)
}
}
data class SetMangaMetaInput(
val clientMutationId: String? = null,
val meta: MangaMetaType,

View File

@@ -21,12 +21,15 @@ 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.ContentWarningFilter
import suwayomi.tachidesk.graphql.queries.filter.Filter
import suwayomi.tachidesk.graphql.queries.filter.HasGetOp
import suwayomi.tachidesk.graphql.queries.filter.IntFilter
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
@@ -40,6 +43,7 @@ import suwayomi.tachidesk.graphql.server.primitives.lessNotUnique
import suwayomi.tachidesk.graphql.server.primitives.maybeSwap
import suwayomi.tachidesk.graphql.types.ExtensionNodeList
import suwayomi.tachidesk.graphql.types.ExtensionType
import suwayomi.tachidesk.manga.model.dataclass.ContentWarning
import suwayomi.tachidesk.manga.model.table.ExtensionTable
import java.util.concurrent.CompletableFuture
@@ -55,21 +59,23 @@ class ExtensionQuery {
) : OrderBy<ExtensionType> {
PKG_NAME(ExtensionTable.pkgName),
NAME(ExtensionTable.name),
APK_NAME(ExtensionTable.apkName),
@GraphQLDeprecated("")
APK_NAME(ExtensionTable.pkgName),
;
override fun greater(cursor: Cursor): Op<Boolean> =
when (this) {
PKG_NAME -> ExtensionTable.pkgName greater cursor.value
NAME -> greaterNotUnique(ExtensionTable.name, ExtensionTable.pkgName, cursor, String::toString)
APK_NAME -> greaterNotUnique(ExtensionTable.apkName, ExtensionTable.pkgName, cursor, String::toString)
APK_NAME -> ExtensionTable.pkgName greater cursor.value
}
override fun less(cursor: Cursor): Op<Boolean> =
when (this) {
PKG_NAME -> ExtensionTable.pkgName less cursor.value
NAME -> lessNotUnique(ExtensionTable.name, ExtensionTable.pkgName, cursor, String::toString)
APK_NAME -> lessNotUnique(ExtensionTable.apkName, ExtensionTable.pkgName, cursor, String::toString)
APK_NAME -> ExtensionTable.pkgName less cursor.value
}
override fun asCursor(type: ExtensionType): Cursor {
@@ -89,29 +95,44 @@ class ExtensionQuery {
) : Order<ExtensionOrderBy>
data class ExtensionCondition(
val storeIndexUrl: String? = null,
@GraphQLDeprecated("", ReplaceWith("storeIndexUrl"))
val repo: String? = null,
val apkName: String? = null,
val iconUrl: String? = null,
val name: String? = null,
val pkgName: String? = null,
val apkUrl: String? = null,
val extensionLib: String? = null,
val versionName: String? = null,
val versionCode: Int? = null,
val versionCodeLong: Long? = null,
val lang: String? = null,
@GraphQLDeprecated("", ReplaceWith("contentWarning"))
val isNsfw: Boolean? = null,
val contentWarning: ContentWarning? = null,
val isInstalled: Boolean? = null,
val hasUpdate: Boolean? = null,
val isObsolete: Boolean? = null,
) : HasGetOp {
override fun getOp(): Op<Boolean>? {
val opAnd = OpAnd()
opAnd.eq(repo, ExtensionTable.repo)
opAnd.eq(storeIndexUrl, ExtensionTable.storeIndexUrl)
opAnd.eq(repo, ExtensionTable.storeIndexUrl)
opAnd.eq(apkName, ExtensionTable.apkName)
opAnd.eq(iconUrl, ExtensionTable.iconUrl)
opAnd.eq(apkUrl, ExtensionTable.apkUrl)
opAnd.eq(name, ExtensionTable.name)
opAnd.eq(extensionLib, ExtensionTable.extensionLib)
opAnd.eq(versionName, ExtensionTable.versionName)
opAnd.eq(versionCode, ExtensionTable.versionCode)
opAnd.eq(versionCode?.toLong(), ExtensionTable.versionCode)
opAnd.eq(versionCodeLong, ExtensionTable.versionCode)
opAnd.eq(lang, ExtensionTable.lang)
opAnd.eq(isNsfw, ExtensionTable.isNsfw)
opAnd.eq(
isNsfw?.let { if (it) ContentWarning.MIXED.ordinal else ContentWarning.SAFE.ordinal },
ExtensionTable.contentWarning,
)
opAnd.eq(contentWarning?.ordinal, ExtensionTable.contentWarning)
opAnd.eq(isInstalled, ExtensionTable.isInstalled)
opAnd.eq(hasUpdate, ExtensionTable.hasUpdate)
opAnd.eq(isObsolete, ExtensionTable.isObsolete)
@@ -121,15 +142,23 @@ class ExtensionQuery {
}
data class ExtensionFilter(
val storeIndexUrl: StringFilter? = null,
@GraphQLDeprecated("", ReplaceWith("storeIndexUrl"))
val repo: StringFilter? = null,
val apkName: StringFilter? = null,
val iconUrl: StringFilter? = null,
val name: StringFilter? = null,
val pkgName: StringFilter? = null,
val apkUrl: StringFilter? = null,
val versionName: StringFilter? = null,
val extensionLib: StringFilter? = null,
@GraphQLDeprecated("", ReplaceWith("versionCodeLong"))
val versionCode: IntFilter? = null,
val versionCodeLong: LongFilter? = null,
val lang: StringFilter? = null,
@GraphQLDeprecated("", ReplaceWith("contentWarning"))
val isNsfw: BooleanFilter? = null,
val contentWarning: ContentWarningFilter? = null,
val isInstalled: BooleanFilter? = null,
val hasUpdate: BooleanFilter? = null,
val isObsolete: BooleanFilter? = null,
@@ -139,15 +168,18 @@ class ExtensionQuery {
) : Filter<ExtensionFilter> {
override fun getOpList(): List<Op<Boolean>> =
listOfNotNull(
andFilterWithCompareString(ExtensionTable.repo, repo),
andFilterWithCompareString(ExtensionTable.storeIndexUrl, storeIndexUrl),
andFilterWithCompareString(ExtensionTable.storeIndexUrl, repo),
andFilterWithCompareString(ExtensionTable.apkName, apkName),
andFilterWithCompareString(ExtensionTable.iconUrl, iconUrl),
andFilterWithCompareString(ExtensionTable.name, name),
andFilterWithCompareString(ExtensionTable.pkgName, pkgName),
andFilterWithCompareString(ExtensionTable.apkUrl, apkUrl),
andFilterWithCompareString(ExtensionTable.extensionLib, extensionLib),
andFilterWithCompareString(ExtensionTable.versionName, versionName),
andFilterWithCompare(ExtensionTable.versionCode, versionCode),
andFilterWithCompare(ExtensionTable.versionCode, versionCodeLong),
andFilterWithCompareString(ExtensionTable.lang, lang),
andFilterWithCompare(ExtensionTable.isNsfw, isNsfw),
andFilterWithCompareEnum(ExtensionTable.contentWarning, contentWarning),
andFilterWithCompare(ExtensionTable.isInstalled, isInstalled),
andFilterWithCompare(ExtensionTable.hasUpdate, hasUpdate),
andFilterWithCompare(ExtensionTable.isObsolete, isObsolete),

View File

@@ -0,0 +1,190 @@
package suwayomi.tachidesk.graphql.queries
/*
* 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 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(
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(
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

@@ -13,19 +13,22 @@ 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.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.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.ContentWarningFilter
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.andFilterWithCompare
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
@@ -39,6 +42,7 @@ import suwayomi.tachidesk.graphql.server.primitives.lessNotUnique
import suwayomi.tachidesk.graphql.server.primitives.maybeSwap
import suwayomi.tachidesk.graphql.types.SourceNodeList
import suwayomi.tachidesk.graphql.types.SourceType
import suwayomi.tachidesk.manga.model.dataclass.ContentWarning
import suwayomi.tachidesk.manga.model.table.SourceTable
import java.util.concurrent.CompletableFuture
@@ -91,14 +95,23 @@ class SourceQuery {
val id: Long? = null,
val name: String? = null,
val lang: String? = null,
@GraphQLDeprecated("replace with contentWarning == ContentRating.MIXED", ReplaceWith("contentWarning"))
val isNsfw: Boolean? = null,
val contentWarning: ContentWarning? = null,
) : HasGetOp {
override fun getOp(): Op<Boolean>? {
val opAnd = OpAnd()
opAnd.eq(id, SourceTable.id)
opAnd.eq(name, SourceTable.name)
opAnd.eq(lang, SourceTable.lang)
opAnd.eq(isNsfw, SourceTable.isNsfw)
opAnd.andWhere(isNsfw) {
if (it) {
SourceTable.contentWarning greaterEq ContentWarning.MIXED.ordinal
} else {
SourceTable.contentWarning less ContentWarning.MIXED.ordinal
}
}
opAnd.andWhere(contentWarning) { SourceTable.contentWarning eq it.ordinal }
return opAnd.op
}
@@ -108,7 +121,9 @@ class SourceQuery {
val id: LongFilter? = null,
val name: StringFilter? = null,
val lang: StringFilter? = null,
@GraphQLDeprecated("replace with contentWarning", ReplaceWith("contentWarning"))
val isNsfw: BooleanFilter? = null,
val contentWarning: ContentWarningFilter? = null,
override val and: List<SourceFilter>? = null,
override val or: List<SourceFilter>? = null,
override val not: SourceFilter? = null,
@@ -118,7 +133,7 @@ class SourceQuery {
andFilterWithCompareEntity(SourceTable.id, id),
andFilterWithCompareString(SourceTable.name, name),
andFilterWithCompareString(SourceTable.lang, lang),
andFilterWithCompare(SourceTable.isNsfw, isNsfw),
andFilterWithCompareEnum(SourceTable.contentWarning, contentWarning),
)
}

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.manga.model.dataclass.ContentWarning
class ILikeEscapeOp(
expr1: Expression<*>,
@@ -329,6 +330,24 @@ data class DoubleFilter(
)
}
data class ContentWarningFilter(
override val isNull: Boolean? = null,
override val equalTo: ContentWarning? = null,
override val notEqualTo: ContentWarning? = null,
override val notEqualToAll: List<ContentWarning>? = null,
override val notEqualToAny: List<ContentWarning>? = null,
override val distinctFrom: ContentWarning? = null,
override val distinctFromAll: List<ContentWarning>? = null,
override val distinctFromAny: List<ContentWarning>? = null,
override val notDistinctFrom: ContentWarning? = null,
override val `in`: List<ContentWarning>? = null,
override val notIn: List<ContentWarning>? = null,
override val lessThan: ContentWarning? = null,
override val lessThanOrEqualTo: ContentWarning? = null,
override val greaterThan: ContentWarning? = null,
override val greaterThanOrEqualTo: ContentWarning? = null,
) : ComparableScalarFilter<ContentWarning>
data class StringFilter(
override val isNull: Boolean? = null,
override val equalTo: String? = null,
@@ -618,6 +637,35 @@ fun <T : Comparable<T>, S : T?> andFilterWithCompare(
return opAnd.op
}
@Suppress("UNCHECKED_CAST")
fun <T : Enum<T>> andFilterWithCompareEnum(
column: Column<Int>,
filter: ComparableScalarFilter<T>?,
): Op<Boolean>? {
filter ?: return null
val opAnd = OpAnd()
opAnd.andWhere(filter.lessThan) { column less it.ordinal }
opAnd.andWhere(filter.lessThanOrEqualTo) { column lessEq it.ordinal }
opAnd.andWhere(filter.greaterThan) { column greater it.ordinal }
opAnd.andWhere(filter.greaterThanOrEqualTo) { column greaterEq it.ordinal }
opAnd.andWhere(filter.isNull) { if (it) column.isNull() else column.isNotNull() }
opAnd.andWhere(filter.equalTo) { column eq it.ordinal }
opAnd.andNotWhere(filter.notEqualTo, filter.notEqualToAll, filter.notEqualToAny) { column neq it.ordinal }
opAnd.andWhere(filter.distinctFrom, filter.distinctFromAll, filter.distinctFromAny) { DistinctFromOp.distinctFrom(column, it.ordinal) }
opAnd.andWhere(filter.notDistinctFrom) { DistinctFromOp.notDistinctFrom(column, it.ordinal) }
if (!filter.`in`.isNullOrEmpty()) {
opAnd.andWhere(filter.`in`) { column inList it.map { it.ordinal } }
}
if (!filter.notIn.isNullOrEmpty()) {
opAnd.andWhere(filter.notIn) { column notInList it.map { it.ordinal } }
}
return opAnd.op
}
fun <T : Comparable<T>> andFilterWithCompareEntity(
column: Column<EntityID<T>>,
filter: ComparableScalarFilter<T>?,

View File

@@ -21,6 +21,8 @@ import suwayomi.tachidesk.graphql.dataLoaders.DisplayScoreForTrackSearchDataLoad
import suwayomi.tachidesk.graphql.dataLoaders.DownloadedChapterCountForMangaDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.ExtensionDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.ExtensionForSourceDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.ExtensionStoreDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.ExtensionsForExtensionStore
import suwayomi.tachidesk.graphql.dataLoaders.FirstUnreadChapterForMangaDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.GlobalMetaDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.HasDuplicateChaptersForMangaDataLoader
@@ -78,6 +80,8 @@ class TachideskDataLoaderRegistryFactory {
SourceMetaDataLoader(),
ExtensionDataLoader(),
ExtensionForSourceDataLoader(),
ExtensionsForExtensionStore(),
ExtensionStoreDataLoader(),
TrackerDataLoader(),
TrackerStatusesDataLoader(),
TrackerScoresDataLoader(),

View File

@@ -20,6 +20,7 @@ import suwayomi.tachidesk.graphql.mutations.CategoryMutation
import suwayomi.tachidesk.graphql.mutations.ChapterMutation
import suwayomi.tachidesk.graphql.mutations.DownloadMutation
import suwayomi.tachidesk.graphql.mutations.ExtensionMutation
import suwayomi.tachidesk.graphql.mutations.ExtensionStoreMutation
import suwayomi.tachidesk.graphql.mutations.ImageMutation
import suwayomi.tachidesk.graphql.mutations.InfoMutation
import suwayomi.tachidesk.graphql.mutations.KoreaderSyncMutation
@@ -36,6 +37,7 @@ import suwayomi.tachidesk.graphql.queries.CategoryQuery
import suwayomi.tachidesk.graphql.queries.ChapterQuery
import suwayomi.tachidesk.graphql.queries.DownloadQuery
import suwayomi.tachidesk.graphql.queries.ExtensionQuery
import suwayomi.tachidesk.graphql.queries.ExtensionStoreQuery
import suwayomi.tachidesk.graphql.queries.InfoQuery
import suwayomi.tachidesk.graphql.queries.KoreaderSyncQuery
import suwayomi.tachidesk.graphql.queries.MangaQuery
@@ -95,6 +97,7 @@ val schema =
TopLevelObject(ChapterQuery()),
TopLevelObject(DownloadQuery()),
TopLevelObject(ExtensionQuery()),
TopLevelObject(ExtensionStoreQuery()),
TopLevelObject(InfoQuery()),
TopLevelObject(KoreaderSyncQuery()),
TopLevelObject(MangaQuery()),
@@ -112,6 +115,7 @@ val schema =
TopLevelObject(ChapterMutation()),
TopLevelObject(DownloadMutation()),
TopLevelObject(ExtensionMutation()),
TopLevelObject(ExtensionStoreMutation()),
TopLevelObject(ImageMutation()),
TopLevelObject(InfoMutation()),
TopLevelObject(KoreaderSyncMutation()),

View File

@@ -0,0 +1,86 @@
package suwayomi.tachidesk.graphql.types
/*
* 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 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
class ExtensionStoreType(
val name: String,
val badgeLabel: String,
val signingKey: String,
val contactWebsite: String,
val contactDiscord: String?,
val indexUrl: String,
val isLegacy: Boolean,
val extensionListUrl: String?,
) : Node {
constructor(row: ResultRow) : this(
name = row[ExtensionStoreTable.name],
badgeLabel = row[ExtensionStoreTable.badgeLabel],
signingKey = row[ExtensionStoreTable.signingKey],
contactWebsite = row[ExtensionStoreTable.contactWebsite],
contactDiscord = row[ExtensionStoreTable.contactDiscord],
indexUrl = row[ExtensionStoreTable.indexUrl],
isLegacy = row[ExtensionStoreTable.isLegacy],
extensionListUrl = row[ExtensionStoreTable.extensionListUrl],
)
fun extensions(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture<ExtensionNodeList> =
dataFetchingEnvironment.getValueFromDataLoader<String, ExtensionNodeList>("ExtensionsForExtensionStore", 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

@@ -7,6 +7,8 @@
package suwayomi.tachidesk.graphql.types
import com.expediagroup.graphql.generator.annotations.GraphQLDeprecated
import com.expediagroup.graphql.generator.annotations.GraphQLDescription
import com.expediagroup.graphql.server.extensions.getValueFromDataLoader
import graphql.schema.DataFetchingEnvironment
import org.jetbrains.exposed.v1.core.ResultRow
@@ -16,33 +18,51 @@ 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.impl.extension.Extension
import suwayomi.tachidesk.manga.model.dataclass.ContentWarning
import suwayomi.tachidesk.manga.model.table.ExtensionTable
import java.util.concurrent.CompletableFuture
class ExtensionType(
val storeIndexUrl: String?,
@GraphQLDeprecated("Removed in extension api v1.6", ReplaceWith("storeIndexUrl"))
val repo: String?,
val apkName: String,
@GraphQLDescription("This will be nullable in the future")
val apkName: String?,
val iconUrl: String,
val name: String,
val pkgName: String,
val apkUrl: String?,
val extensionLib: String?,
val versionName: String,
@GraphQLDeprecated(
"Type was changed to Long, will be switched back to this variable name in the future.",
ReplaceWith("versionCodeLong"),
)
val versionCode: Int,
val versionCodeLong: Long,
val lang: String,
@GraphQLDeprecated("Removed in extension api v1.6", ReplaceWith("contentWarning"))
val isNsfw: Boolean,
val contentWarning: ContentWarning,
val isInstalled: Boolean,
val hasUpdate: Boolean,
val isObsolete: Boolean,
) : Node {
constructor(row: ResultRow) : this(
repo = row[ExtensionTable.repo],
apkName = row[ExtensionTable.apkName],
iconUrl = Extension.getExtensionIconUrl(row[ExtensionTable.apkName]),
storeIndexUrl = row[ExtensionTable.storeIndexUrl],
repo = row[ExtensionTable.storeIndexUrl],
apkName = row[ExtensionTable.apkName].orEmpty(),
iconUrl = Extension.proxyExtensionIconUrl(row[ExtensionTable.pkgName]),
name = row[ExtensionTable.name],
pkgName = row[ExtensionTable.pkgName],
apkUrl = row[ExtensionTable.apkUrl],
extensionLib = row[ExtensionTable.extensionLib],
versionName = row[ExtensionTable.versionName],
versionCode = row[ExtensionTable.versionCode],
versionCode = row[ExtensionTable.versionCode].toInt(),
versionCodeLong = row[ExtensionTable.versionCode],
lang = row[ExtensionTable.lang],
isNsfw = row[ExtensionTable.isNsfw],
isNsfw = row[ExtensionTable.contentWarning] >= ContentWarning.MIXED.ordinal,
contentWarning = ContentWarning.valueOf(row[ExtensionTable.contentWarning]),
isInstalled = row[ExtensionTable.isInstalled],
hasUpdate = row[ExtensionTable.hasUpdate],
isObsolete = row[ExtensionTable.isObsolete],
@@ -50,6 +70,9 @@ class ExtensionType(
fun source(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture<SourceNodeList> =
dataFetchingEnvironment.getValueFromDataLoader<String, SourceNodeList>("SourcesForExtensionDataLoader", pkgName)
fun extensionStore(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture<ExtensionStoreType> =
dataFetchingEnvironment.getValueFromDataLoader<String, ExtensionStoreType>("ExtensionStoreDataLoader", storeIndexUrl.orEmpty())
}
data class ExtensionNodeList(

View File

@@ -7,6 +7,7 @@
package suwayomi.tachidesk.graphql.types
import com.expediagroup.graphql.generator.annotations.GraphQLDeprecated
import com.expediagroup.graphql.server.extensions.getValueFromDataLoader
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.ConfigurableSource
@@ -25,7 +26,7 @@ import suwayomi.tachidesk.manga.impl.Source.getSourcePreferencesRaw
import suwayomi.tachidesk.manga.impl.extension.Extension
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogueSourceOrStub
import suwayomi.tachidesk.manga.model.dataclass.SourceDataClass
import suwayomi.tachidesk.manga.model.dataclass.ContentWarning
import suwayomi.tachidesk.manga.model.table.ExtensionTable
import suwayomi.tachidesk.manga.model.table.SourceTable
import java.util.concurrent.CompletableFuture
@@ -41,35 +42,29 @@ class SourceType(
val id: Long,
val name: String,
val lang: String,
val contentWarning: ContentWarning,
val iconUrl: String,
val supportsLatest: Boolean,
val isConfigurable: Boolean,
@GraphQLDeprecated("", ReplaceWith("contentWarning"))
val isNsfw: Boolean,
val displayName: String,
val homeUrl: String?,
@GraphQLDeprecated("", ReplaceWith("homeUrl"))
val baseUrl: String?,
) : Node {
constructor(source: SourceDataClass) : this(
id = source.id.toLong(),
name = source.name,
lang = source.lang,
iconUrl = source.iconUrl,
supportsLatest = source.supportsLatest,
isConfigurable = source.isConfigurable,
isNsfw = source.isNsfw,
displayName = source.displayName,
baseUrl = source.baseUrl,
)
constructor(row: ResultRow, sourceExtension: ResultRow, catalogueSource: CatalogueSource) : this(
id = row[SourceTable.id].value,
name = row[SourceTable.name],
lang = row[SourceTable.lang],
iconUrl = Extension.getExtensionIconUrl(sourceExtension[ExtensionTable.apkName]),
contentWarning = ContentWarning.valueOf(row[SourceTable.contentWarning]),
iconUrl = Extension.proxyExtensionIconUrl(sourceExtension[ExtensionTable.pkgName]),
supportsLatest = catalogueSource.supportsLatest,
isConfigurable = catalogueSource is ConfigurableSource,
isNsfw = row[SourceTable.isNsfw],
isNsfw = row[SourceTable.contentWarning] >= ContentWarning.MIXED.ordinal,
displayName = catalogueSource.toString(),
baseUrl = catalogueSource.runCatching { (catalogueSource as? HttpSource)?.baseUrl }.getOrNull(),
homeUrl = runCatching { (catalogueSource as? HttpSource)?.getHomeUrl() }.getOrNull(),
baseUrl = runCatching { (catalogueSource as? HttpSource)?.baseUrl }.getOrNull(),
)
fun manga(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture<MangaNodeList> =

View File

@@ -34,7 +34,7 @@ object MangaAPI {
get("update/{pkgName}", ExtensionController.update)
get("uninstall/{pkgName}", ExtensionController.uninstall)
get("icon/{apkName}", ExtensionController.icon)
get("icon/{pkgName}", ExtensionController.icon)
}
path("source") {

View File

@@ -165,17 +165,17 @@ object ExtensionController {
/** icon for extension named `apkName` */
val icon =
handler(
pathParam<String>("apkName"),
pathParam<String>("pkgName"),
documentWith = {
withOperation {
summary("Extension icon")
description("Icon for extension named `apkName`")
}
},
behaviorOf = { ctx, apkName ->
behaviorOf = { ctx, pkgName ->
ctx.getAttribute(Attribute.TachideskUser).requireUser()
ctx.future {
future { Extension.getExtensionIcon(apkName) }
future { Extension.getExtensionIcon(pkgName) }
.thenApply {
ctx.header("content-type", it.second)
val httpCacheSeconds = 365.days.inWholeSeconds

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

@@ -7,16 +7,19 @@ 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 eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.model.UpdateStrategy
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.util.chapter.ChapterRecognition
import eu.kanade.tachiyomi.util.chapter.ChapterSanitizer.sanitize
import io.github.oshai.kotlinlogging.KotlinLogging
import io.github.reactivecircus.cache4k.Cache
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import org.jetbrains.exposed.v1.core.ResultRow
import org.jetbrains.exposed.v1.core.SortOrder
import org.jetbrains.exposed.v1.core.and
import org.jetbrains.exposed.v1.core.dao.id.EntityID
@@ -32,7 +35,6 @@ import org.jetbrains.exposed.v1.jdbc.selectAll
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.Manga.getManga
import suwayomi.tachidesk.manga.impl.download.DownloadManager
import suwayomi.tachidesk.manga.impl.download.DownloadManager.EnqueueInput
import suwayomi.tachidesk.manga.impl.track.Track
@@ -50,7 +52,6 @@ import suwayomi.tachidesk.server.serverConfig
import java.time.Instant
import java.util.TreeSet
import kotlin.math.max
import kotlin.time.Duration.Companion.minutes
private fun List<ChapterDataClass>.removeDuplicates(currentChapter: ChapterDataClass): List<ChapterDataClass> =
groupBy { it.chapterNumber }
@@ -104,267 +105,277 @@ object Chapter {
.associateBy({ it[ChapterTable.url] }, { it })
}
return chapterList.mapIndexed { index, it ->
return chapterList.map {
val dbChapter = dbChapterMap.getValue(it.url)
ChapterDataClass(
id = dbChapter[ChapterTable.id].value,
url = it.url,
name = it.name,
uploadDate = it.date_upload,
chapterNumber = it.chapter_number,
scanlator = it.scanlator,
mangaId = mangaId,
read = dbChapter[ChapterTable.isRead],
bookmarked = dbChapter[ChapterTable.isBookmarked],
lastPageRead = dbChapter[ChapterTable.lastPageRead],
lastReadAt = dbChapter[ChapterTable.lastReadAt],
index = chapterList.size - index,
fetchedAt = dbChapter[ChapterTable.fetchedAt],
realUrl = dbChapter[ChapterTable.realUrl],
downloaded = dbChapter[ChapterTable.isDownloaded],
pageCount = dbChapter[ChapterTable.pageCount],
lastModifiedAt = dbChapter[ChapterTable.lastModifiedAt],
version = dbChapter[ChapterTable.version],
)
ChapterTable.toDataClass(dbChapter)
}
}
val map: Cache<Int, Mutex> =
Cache
.Builder<Int, Mutex>()
.expireAfterAccess(10.minutes)
.build()
suspend fun fetchChapterList(mangaId: Int): List<SChapter> {
val mutex = map.get(mangaId) { Mutex() }
val mutex = Manga.mangaInfoMutex.get(mangaId) { Mutex() }
val chapterList =
mutex.withLock {
val manga = getManga(mangaId)
val source = getCatalogueSourceOrStub(manga.sourceId.toLong())
val sManga =
SManga.create().apply {
title = manga.title
url = manga.url
description = manga.description
}
val currentLatestChapterNumber = Manga.getLatestChapter(mangaId)?.chapterNumber ?: 0f
val numberOfCurrentChapters = getCountOfMangaChapters(mangaId)
val chapters = source.getChapterList(sManga)
// it's possible that the source returns a list containing chapters with the same url
// once such duplicated chapters have been added, they aren't being removed anymore as long as there is
// a chapter with the same url in the fetched chapter list, even if the duplicated chapter itself
// does not exist anymore on the source
val uniqueChapters = chapters.distinctBy { it.url }
if (uniqueChapters.isEmpty()) {
throw Exception("No chapters found")
}
// Recognize number for new chapters.
uniqueChapters.forEach { chapter ->
(source as? HttpSource)?.prepareNewChapter(chapter, sManga)
val chapterNumber = ChapterRecognition.parseChapterNumber(manga.title, chapter.name, chapter.chapter_number.toDouble())
chapter.chapter_number = chapterNumber.toFloat()
chapter.name = chapter.name.sanitize(manga.title)
chapter.scanlator = chapter.scanlator?.ifBlank { null }?.trim()
}
val now = Instant.now().epochSecond
// Used to not set upload date of older chapters
// to a higher value than newer chapters
var maxSeenUploadDate = 0L
val chaptersInDb =
val mangaEntry =
transaction {
ChapterTable
.selectAll()
.where { ChapterTable.manga eq mangaId }
.map { ChapterTable.toDataClass(it) }
.toList()
MangaTable.selectAll().where { MangaTable.id eq mangaId }.first()
}
val source = getCatalogueSourceOrStub(mangaEntry[MangaTable.sourceReference])
// new chapters after they have been added to the database for auto downloads
val insertedChapterIds = mutableListOf<Int>()
val chapters =
Manga
.fetchMangaAndChapters(
mangaEntry = mangaEntry,
source = source,
fetchDetails = false,
fetchChapters = true,
).chapters
val chaptersToInsert = mutableListOf<ChapterDataClass>() // do not yet have an ID from the database
val chaptersToUpdate = mutableListOf<ChapterDataClass>()
uniqueChapters.reversed().forEachIndexed { index, fetchedChapter ->
val chapterEntry = chaptersInDb.find { it.url == fetchedChapter.url }
val chapterData =
ChapterDataClass.fromSChapter(
fetchedChapter,
chapterEntry?.id ?: 0,
index + 1,
now,
mangaId,
runCatching {
(source as? HttpSource)?.getChapterUrl(fetchedChapter)
}.getOrNull(),
)
if (chapterEntry == null) {
val newChapterData =
if (chapterData.uploadDate == 0L) {
val altDateUpload = if (maxSeenUploadDate == 0L) now else maxSeenUploadDate
chapterData.copy(uploadDate = altDateUpload)
} else {
maxSeenUploadDate = max(maxSeenUploadDate, chapterData.uploadDate)
chapterData
}
chaptersToInsert.add(newChapterData)
} else {
val newChapterData =
if (chapterData.uploadDate == 0L) {
chapterData.copy(uploadDate = chapterEntry.uploadDate)
} else {
chapterData
}
chaptersToUpdate.add(newChapterData)
}
}
val deletedChapterNumbers = TreeSet<Float>()
val deletedReadChapterNumbers = TreeSet<Float>()
val deletedBookmarkedChapterNumbers = TreeSet<Float>()
val deletedDownloadedChapterNumberToChapter = mutableMapOf<Float, ChapterDataClass>()
val deletedChapterNumberDateFetchMap = mutableMapOf<Float, Long>()
// clear any orphaned/duplicate chapters that are in the db but not in `chapterList`
val chapterUrls = uniqueChapters.map { it.url }.toSet()
val chaptersIdsToDelete =
chaptersInDb.mapNotNull { dbChapter ->
if (!chapterUrls.contains(dbChapter.url)) {
if (dbChapter.read) deletedReadChapterNumbers.add(dbChapter.chapterNumber)
if (dbChapter.bookmarked) deletedBookmarkedChapterNumbers.add(dbChapter.chapterNumber)
if (dbChapter.downloaded) deletedDownloadedChapterNumberToChapter[dbChapter.chapterNumber] = dbChapter
deletedChapterNumbers.add(dbChapter.chapterNumber)
deletedChapterNumberDateFetchMap[dbChapter.chapterNumber] = dbChapter.fetchedAt
dbChapter.id
} else {
null
}
}
transaction {
// we got some clean up due
if (chaptersIdsToDelete.isNotEmpty()) {
DownloadManager.dequeue(chaptersIdsToDelete)
PageTable.deleteWhere { chapter inList chaptersIdsToDelete }
ChapterTable.deleteWhere { id inList chaptersIdsToDelete }
}
if (chaptersToInsert.isNotEmpty()) {
ChapterTable
.batchInsert(chaptersToInsert) { chapter ->
this[ChapterTable.url] = chapter.url
this[ChapterTable.name] = chapter.name
this[ChapterTable.date_upload] = chapter.uploadDate
this[ChapterTable.chapter_number] = chapter.chapterNumber
this[ChapterTable.scanlator] = chapter.scanlator
this[ChapterTable.sourceOrder] = chapter.index
this[ChapterTable.fetchedAt] = chapter.fetchedAt
this[ChapterTable.manga] = chapter.mangaId
this[ChapterTable.realUrl] = chapter.realUrl
this[ChapterTable.isRead] = false
this[ChapterTable.isBookmarked] = false
this[ChapterTable.isDownloaded] = false
this[ChapterTable.lastModifiedAt] = chapter.lastModifiedAt
this[ChapterTable.version] = chapter.version
this[ChapterTable.pageCount] = -1
// is recognized chapter number
if (chapter.chapterNumber >= 0f && chapter.chapterNumber in deletedChapterNumbers) {
this[ChapterTable.isRead] = chapter.chapterNumber in deletedReadChapterNumbers
this[ChapterTable.isBookmarked] = chapter.chapterNumber in deletedBookmarkedChapterNumbers
// Try to use the fetch date of the original entry to not pollute 'Updates' tab
deletedChapterNumberDateFetchMap[chapter.chapterNumber]?.let {
this[ChapterTable.fetchedAt] = it
}
deletedDownloadedChapterNumberToChapter[chapter.chapterNumber]?.let {
val hasDownloadedPages = it.pageCount > 0
val isSameName = it.name == chapter.name
val isSameScanlator = it.scanlator == chapter.scanlator
// Only preserve download status for chapters with the same name and of the same scanlator; otherwise,
// the downloaded files won't be found anyway
val isDownloadPreservable = hasDownloadedPages && isSameName && isSameScanlator
if (isDownloadPreservable) {
this[ChapterTable.isDownloaded] = true
this[ChapterTable.pageCount] = it.pageCount
}
}
}
}.forEach { insertedChapterIds.add(it[ChapterTable.id].value) }
}
if (chaptersToUpdate.isNotEmpty()) {
BatchUpdateStatement(ChapterTable)
.apply {
chaptersToUpdate.forEach {
addBatch(EntityID(it.id, ChapterTable))
val currentChapter = chaptersInDb.find { dbChapter -> dbChapter.id == it.id }!!
this[ChapterTable.name] = it.name
this[ChapterTable.date_upload] = it.uploadDate
this[ChapterTable.chapter_number] = it.chapterNumber
this[ChapterTable.scanlator] = it.scanlator
this[ChapterTable.sourceOrder] = it.index
this[ChapterTable.realUrl] = it.realUrl
this[ChapterTable.lastModifiedAt] = it.lastModifiedAt
this[ChapterTable.version] = it.version
this[ChapterTable.isDownloaded] = currentChapter.downloaded
this[ChapterTable.pageCount] = currentChapter.pageCount
if (!currentChapter.downloaded) {
return@forEach
}
val isSameScanlator = currentChapter.scanlator == it.scanlator
val isSameName = currentChapter.name == it.name
val isDownloadPreservable = isSameName && isSameScanlator
if (!isDownloadPreservable) {
this[ChapterTable.isDownloaded] = false
this[ChapterTable.pageCount] = -1
}
}
}.toExecutable()
.execute(this@transaction)
}
MangaTable.update({ MangaTable.id eq mangaId }) {
it[chaptersLastFetchedAt] = Instant.now().epochSecond
}
}
if (manga.inLibrary) {
// We have to query the inserted chapters to get the up-to-date data. I.e. "last_modified_at" is not returned by the insert statement, due to being set by a DB trigger
val insertedChapters =
transaction {
ChapterTable.selectAll().where { ChapterTable.id inList insertedChapterIds }.map(
ChapterTable::toDataClass,
)
}
downloadNewChapters(mangaId, currentLatestChapterNumber, numberOfCurrentChapters, insertedChapters)
}
uniqueChapters
updateChapterListDatabase(mangaEntry, chapters, source)
}
return chapterList
}
fun updateChapterListDatabase(
mangaEntry: ResultRow,
chapters: List<SChapter>,
source: CatalogueSource,
): List<SChapter> {
val currentLatestChapterNumber = Manga.getLatestChapter(mangaEntry[MangaTable.id].value)?.chapterNumber ?: 0f
val numberOfCurrentChapters = getCountOfMangaChapters(mangaEntry[MangaTable.id].value)
// it's possible that the source returns a list containing chapters with the same url
// once such duplicated chapters have been added, they aren't being removed anymore as long as there is
// a chapter with the same url in the fetched chapter list, even if the duplicated chapter itself
// does not exist anymore on the source
val uniqueChapters = chapters.distinctBy { it.url }
if (uniqueChapters.isEmpty()) {
throw Exception("No chapters found")
}
// Recognize number for new chapters.
val sManga =
SManga.create().apply {
url = mangaEntry[MangaTable.url]
title = mangaEntry[MangaTable.title]
thumbnail_url = mangaEntry[MangaTable.thumbnail_url]
artist = mangaEntry[MangaTable.artist]
author = mangaEntry[MangaTable.author]
description = mangaEntry[MangaTable.description]
genre = mangaEntry[MangaTable.genre]
status = mangaEntry[MangaTable.status]
update_strategy = UpdateStrategy.valueOf(mangaEntry[MangaTable.updateStrategy])
memo = Json.decodeFromString(mangaEntry[MangaTable.memo])
initialized = mangaEntry[MangaTable.initialized]
}
uniqueChapters.forEach { chapter ->
(source as? HttpSource)?.prepareNewChapter(chapter, sManga)
val chapterNumber =
ChapterRecognition.parseChapterNumber(
mangaEntry[MangaTable.title],
chapter.name,
chapter.chapter_number.toDouble(),
)
chapter.chapter_number = chapterNumber.toFloat()
chapter.name = chapter.name.sanitize(mangaEntry[MangaTable.title])
chapter.scanlator = chapter.scanlator?.ifBlank { null }?.trim()
}
val now = Instant.now().epochSecond
// Used to not set upload date of older chapters
// to a higher value than newer chapters
var maxSeenUploadDate = 0L
val chaptersInDb =
transaction {
ChapterTable
.selectAll()
.where { ChapterTable.manga eq mangaEntry[MangaTable.id].value }
.map { ChapterTable.toDataClass(it) }
.toList()
}
// new chapters after they have been added to the database for auto downloads
val insertedChapterIds = mutableListOf<Int>()
val chaptersToInsert = mutableListOf<ChapterDataClass>() // do not yet have an ID from the database
val chaptersToUpdate = mutableListOf<ChapterDataClass>()
uniqueChapters.reversed().forEachIndexed { index, fetchedChapter ->
val chapterEntry = chaptersInDb.find { it.url == fetchedChapter.url }
val chapterData =
ChapterDataClass.fromSChapter(
fetchedChapter,
chapterEntry?.id ?: 0,
index + 1,
now,
mangaEntry[MangaTable.id].value,
runCatching {
(source as? HttpSource)?.getChapterUrl(fetchedChapter)
}.getOrNull(),
)
if (chapterEntry == null) {
val newChapterData =
if (chapterData.uploadDate == 0L) {
val altDateUpload = if (maxSeenUploadDate == 0L) now else maxSeenUploadDate
chapterData.copy(uploadDate = altDateUpload)
} else {
maxSeenUploadDate = max(maxSeenUploadDate, chapterData.uploadDate)
chapterData
}
chaptersToInsert.add(newChapterData)
} else {
val newChapterData =
if (chapterData.uploadDate == 0L) {
chapterData.copy(uploadDate = chapterEntry.uploadDate)
} else {
chapterData
}
chaptersToUpdate.add(newChapterData)
}
}
val deletedChapterNumbers = TreeSet<Float>()
val deletedReadChapterNumbers = TreeSet<Float>()
val deletedBookmarkedChapterNumbers = TreeSet<Float>()
val deletedDownloadedChapterNumberToChapter = mutableMapOf<Float, ChapterDataClass>()
val deletedChapterNumberDateFetchMap = mutableMapOf<Float, Long>()
// clear any orphaned/duplicate chapters that are in the db but not in `chapterList`
val chapterUrls = uniqueChapters.map { it.url }.toSet()
val chaptersIdsToDelete =
chaptersInDb.mapNotNull { dbChapter ->
if (!chapterUrls.contains(dbChapter.url)) {
if (dbChapter.read) deletedReadChapterNumbers.add(dbChapter.chapterNumber)
if (dbChapter.bookmarked) deletedBookmarkedChapterNumbers.add(dbChapter.chapterNumber)
if (dbChapter.downloaded) deletedDownloadedChapterNumberToChapter[dbChapter.chapterNumber] = dbChapter
deletedChapterNumbers.add(dbChapter.chapterNumber)
deletedChapterNumberDateFetchMap[dbChapter.chapterNumber] = dbChapter.fetchedAt
dbChapter.id
} else {
null
}
}
transaction {
// we got some clean up due
if (chaptersIdsToDelete.isNotEmpty()) {
DownloadManager.dequeue(chaptersIdsToDelete)
PageTable.deleteWhere { chapter inList chaptersIdsToDelete }
ChapterTable.deleteWhere { id inList chaptersIdsToDelete }
}
if (chaptersToInsert.isNotEmpty()) {
ChapterTable
.batchInsert(chaptersToInsert) { chapter ->
this[ChapterTable.url] = chapter.url
this[ChapterTable.name] = chapter.name
this[ChapterTable.date_upload] = chapter.uploadDate
this[ChapterTable.chapter_number] = chapter.chapterNumber
this[ChapterTable.scanlator] = chapter.scanlator
this[ChapterTable.sourceOrder] = chapter.index
this[ChapterTable.fetchedAt] = chapter.fetchedAt
this[ChapterTable.manga] = chapter.mangaId
this[ChapterTable.realUrl] = chapter.realUrl
this[ChapterTable.memo] = Json.encodeToString(chapter.memo)
this[ChapterTable.isRead] = false
this[ChapterTable.isBookmarked] = false
this[ChapterTable.isDownloaded] = false
this[ChapterTable.lastModifiedAt] = chapter.lastModifiedAt
this[ChapterTable.version] = chapter.version
this[ChapterTable.pageCount] = -1
// is recognized chapter number
if (chapter.chapterNumber >= 0f && chapter.chapterNumber in deletedChapterNumbers) {
this[ChapterTable.isRead] = chapter.chapterNumber in deletedReadChapterNumbers
this[ChapterTable.isBookmarked] = chapter.chapterNumber in deletedBookmarkedChapterNumbers
// Try to use the fetch date of the original entry to not pollute 'Updates' tab
deletedChapterNumberDateFetchMap[chapter.chapterNumber]?.let {
this[ChapterTable.fetchedAt] = it
}
deletedDownloadedChapterNumberToChapter[chapter.chapterNumber]?.let {
val hasDownloadedPages = it.pageCount > 0
val isSameName = it.name == chapter.name
val isSameScanlator = it.scanlator == chapter.scanlator
// Only preserve download status for chapters with the same name and of the same scanlator; otherwise,
// the downloaded files won't be found anyway
val isDownloadPreservable = hasDownloadedPages && isSameName && isSameScanlator
if (isDownloadPreservable) {
this[ChapterTable.isDownloaded] = true
this[ChapterTable.pageCount] = it.pageCount
}
}
}
}.forEach { insertedChapterIds.add(it[ChapterTable.id].value) }
}
if (chaptersToUpdate.isNotEmpty()) {
BatchUpdateStatement(ChapterTable)
.apply {
chaptersToUpdate.forEach {
addBatch(EntityID(it.id, ChapterTable))
val currentChapter = chaptersInDb.find { dbChapter -> dbChapter.id == it.id }!!
this[ChapterTable.name] = it.name
this[ChapterTable.date_upload] = it.uploadDate
this[ChapterTable.chapter_number] = it.chapterNumber
this[ChapterTable.scanlator] = it.scanlator
this[ChapterTable.sourceOrder] = it.index
this[ChapterTable.realUrl] = it.realUrl
this[ChapterTable.lastModifiedAt] = it.lastModifiedAt
this[ChapterTable.version] = it.version
this[ChapterTable.memo] = Json.encodeToString(it.memo)
this[ChapterTable.isDownloaded] = currentChapter.downloaded
this[ChapterTable.pageCount] = currentChapter.pageCount
if (!currentChapter.downloaded) {
return@forEach
}
val isSameScanlator = currentChapter.scanlator == it.scanlator
val isSameName = currentChapter.name == it.name
val isDownloadPreservable = isSameName && isSameScanlator
if (!isDownloadPreservable) {
this[ChapterTable.isDownloaded] = false
this[ChapterTable.pageCount] = -1
}
}
}.toExecutable()
.execute(this@transaction)
}
MangaTable.update({ MangaTable.id eq mangaEntry[MangaTable.id].value }) {
it[chaptersLastFetchedAt] = Instant.now().epochSecond
}
}
if (mangaEntry[MangaTable.inLibrary]) {
// We have to query the inserted chapters to get the up-to-date data. I.e. "last_modified_at" is not returned by the insert statement, due to being set by a DB trigger
val insertedChapters =
transaction {
ChapterTable.selectAll().where { ChapterTable.id inList insertedChapterIds }.map(
ChapterTable::toDataClass,
)
}
downloadNewChapters(
mangaEntry[MangaTable.id].value,
currentLatestChapterNumber,
numberOfCurrentChapters,
insertedChapters,
)
}
return uniqueChapters
}
private fun downloadNewChapters(
mangaId: Int,
prevLatestChapterNumber: Float,

View File

@@ -11,13 +11,20 @@ import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.HttpException
import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.network.awaitSuccess
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.local.LocalSource
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.model.SMangaUpdate
import eu.kanade.tachiyomi.source.model.UpdateStrategy
import eu.kanade.tachiyomi.source.online.HttpSource
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import io.github.reactivecircus.cache4k.Cache
import io.javalin.http.HttpStatus
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.json.Json
import okhttp3.CacheControl
import okhttp3.Response
import org.jetbrains.exposed.v1.core.ResultRow
@@ -32,10 +39,7 @@ import org.jetbrains.exposed.v1.jdbc.selectAll
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.MangaList.proxyThumbnailUrl
import suwayomi.tachidesk.manga.impl.Source.getSource
import suwayomi.tachidesk.manga.impl.download.fileProvider.impl.MissingThumbnailException
import suwayomi.tachidesk.manga.impl.track.Track
import suwayomi.tachidesk.manga.impl.util.network.await
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogueSourceOrNull
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogueSourceOrStub
@@ -47,10 +51,8 @@ import suwayomi.tachidesk.manga.impl.util.updateMangaDownloadDir
import suwayomi.tachidesk.manga.model.dataclass.ChapterDataClass
import suwayomi.tachidesk.manga.model.dataclass.IncludeOrExclude
import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass
import suwayomi.tachidesk.manga.model.dataclass.toGenreList
import suwayomi.tachidesk.manga.model.table.ChapterTable
import suwayomi.tachidesk.manga.model.table.MangaMetaTable
import suwayomi.tachidesk.manga.model.table.MangaStatus
import suwayomi.tachidesk.manga.model.table.MangaTable
import suwayomi.tachidesk.manga.model.table.toDataClass
import suwayomi.tachidesk.server.ApplicationDirs
@@ -59,10 +61,17 @@ import java.io.File
import java.io.IOException
import java.io.InputStream
import java.time.Instant
import kotlin.time.Duration.Companion.minutes
private val logger = KotlinLogging.logger { }
object Manga {
val mangaInfoMutex: Cache<Int, Mutex> =
Cache
.Builder<Int, Mutex>()
.expireAfterAccess(10.minutes)
.build()
suspend fun getManga(
mangaId: Int,
onlineFetch: Boolean = false,
@@ -70,63 +79,118 @@ object Manga {
var mangaEntry = transaction { MangaTable.selectAll().where { MangaTable.id eq mangaId }.first() }
return if (!onlineFetch && mangaEntry[MangaTable.initialized]) {
getMangaDataClass(mangaId, mangaEntry)
MangaTable.toDataClass(mangaEntry)
} else { // initialize manga
val sManga = fetchManga(mangaId) ?: return getMangaDataClass(mangaId, mangaEntry)
updateMangaAndChapters(mangaId, updateChapters = false)
mangaEntry = transaction { MangaTable.selectAll().where { MangaTable.id eq mangaId }.first() }
MangaDataClass(
id = mangaId,
sourceId = mangaEntry[MangaTable.sourceReference].toString(),
url = mangaEntry[MangaTable.url],
title = mangaEntry[MangaTable.title],
thumbnailUrl = proxyThumbnailUrl(mangaId),
thumbnailUrlLastFetched = mangaEntry[MangaTable.thumbnailUrlLastFetched],
initialized = true,
artist = sManga.artist,
author = sManga.author,
description = sManga.description,
genre = sManga.genre.toGenreList(),
status = MangaStatus.valueOf(sManga.status).name,
inLibrary = mangaEntry[MangaTable.inLibrary],
inLibraryAt = mangaEntry[MangaTable.inLibraryAt],
source = getSource(mangaEntry[MangaTable.sourceReference]),
realUrl = mangaEntry[MangaTable.realUrl],
lastFetchedAt = mangaEntry[MangaTable.lastFetchedAt],
chaptersLastFetchedAt = mangaEntry[MangaTable.chaptersLastFetchedAt],
updateStrategy = UpdateStrategy.valueOf(mangaEntry[MangaTable.updateStrategy]),
freshData = true,
trackers = Track.getTrackRecordsByMangaId(mangaId),
lastModifiedAt = mangaEntry[MangaTable.lastModifiedAt],
version = mangaEntry[MangaTable.version],
)
MangaTable.toDataClass(mangaEntry).copy(freshData = true)
}
}
suspend fun fetchManga(mangaId: Int): SManga? {
val mangaEntry = transaction { MangaTable.selectAll().where { MangaTable.id eq mangaId }.first() }
val source =
getCatalogueSourceOrNull(mangaEntry[MangaTable.sourceReference])
?: return null
suspend fun fetchMangaAndChapters(
mangaEntry: ResultRow,
source: CatalogueSource,
fetchDetails: Boolean,
fetchChapters: Boolean,
): SMangaUpdate {
val sManga =
source.getMangaDetails(
SManga.create().apply {
url = mangaEntry[MangaTable.url]
title = mangaEntry[MangaTable.title]
thumbnail_url = mangaEntry[MangaTable.thumbnail_url]
artist = mangaEntry[MangaTable.artist]
author = mangaEntry[MangaTable.author]
description = mangaEntry[MangaTable.description]
genre = mangaEntry[MangaTable.genre]
status = mangaEntry[MangaTable.status]
update_strategy = UpdateStrategy.valueOf(mangaEntry[MangaTable.updateStrategy])
},
)
SManga.create().apply {
url = mangaEntry[MangaTable.url]
title = mangaEntry[MangaTable.title]
thumbnail_url = mangaEntry[MangaTable.thumbnail_url]
artist = mangaEntry[MangaTable.artist]
author = mangaEntry[MangaTable.author]
description = mangaEntry[MangaTable.description]
genre = mangaEntry[MangaTable.genre]
status = mangaEntry[MangaTable.status]
update_strategy = UpdateStrategy.valueOf(mangaEntry[MangaTable.updateStrategy])
memo = Json.decodeFromString(mangaEntry[MangaTable.memo])
initialized = mangaEntry[MangaTable.initialized]
}
val sChapters =
transaction {
ChapterTable
.selectAll()
.where { ChapterTable.manga eq mangaEntry[MangaTable.id] }
.orderBy(ChapterTable.sourceOrder to SortOrder.DESC)
.map {
SChapter.create().apply {
url = it[ChapterTable.url]
name = it[ChapterTable.name]
chapter_number = it[ChapterTable.chapter_number]
scanlator = it[ChapterTable.scanlator]
date_upload = it[ChapterTable.date_upload]
memo = Json.decodeFromString(it[ChapterTable.memo])
}
}
}
return source.getMangaUpdate(
sManga,
sChapters,
fetchDetails = fetchDetails,
fetchChapters = fetchChapters,
)
}
suspend fun fetchManga(mangaId: Int): SManga? {
return mangaInfoMutex.get(mangaId) { Mutex() }.withLock {
val mangaEntry =
transaction { MangaTable.selectAll().where { MangaTable.id eq mangaId }.first() }
val source = getCatalogueSourceOrNull(mangaEntry[MangaTable.sourceReference]) ?: return null
val sManga =
fetchMangaAndChapters(
mangaEntry,
source,
fetchDetails = true,
fetchChapters = false,
).manga
updateMangaDatabase(mangaEntry, source, sManga)
}
}
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,
sManga: SManga,
): SManga {
transaction {
MangaTable.update({ MangaTable.id eq mangaId }) {
MangaTable.update({ MangaTable.id eq mangaEntry[MangaTable.id] }) {
val remoteTitle =
try {
sManga.title
@@ -151,7 +215,7 @@ object Manga {
if (!sManga.thumbnail_url.isNullOrEmpty()) {
it[MangaTable.thumbnail_url] = sManga.thumbnail_url
it[MangaTable.thumbnailUrlLastFetched] = Instant.now().epochSecond
clearThumbnail(mangaId)
clearThumbnail(mangaEntry[MangaTable.id].value)
}
it[MangaTable.realUrl] =
@@ -221,35 +285,6 @@ object Manga {
}
}
private fun getMangaDataClass(
mangaId: Int,
mangaEntry: ResultRow,
) = MangaDataClass(
id = mangaId,
sourceId = mangaEntry[MangaTable.sourceReference].toString(),
url = mangaEntry[MangaTable.url],
title = mangaEntry[MangaTable.title],
thumbnailUrl = proxyThumbnailUrl(mangaId),
thumbnailUrlLastFetched = mangaEntry[MangaTable.thumbnailUrlLastFetched],
initialized = true,
artist = mangaEntry[MangaTable.artist],
author = mangaEntry[MangaTable.author],
description = mangaEntry[MangaTable.description],
genre = mangaEntry[MangaTable.genre].toGenreList(),
status = MangaStatus.valueOf(mangaEntry[MangaTable.status]).name,
inLibrary = mangaEntry[MangaTable.inLibrary],
inLibraryAt = mangaEntry[MangaTable.inLibraryAt],
source = getSource(mangaEntry[MangaTable.sourceReference]),
realUrl = mangaEntry[MangaTable.realUrl],
lastFetchedAt = mangaEntry[MangaTable.lastFetchedAt],
chaptersLastFetchedAt = mangaEntry[MangaTable.chaptersLastFetchedAt],
updateStrategy = UpdateStrategy.valueOf(mangaEntry[MangaTable.updateStrategy]),
freshData = false,
trackers = Track.getTrackRecordsByMangaId(mangaId),
lastModifiedAt = mangaEntry[MangaTable.lastModifiedAt],
version = mangaEntry[MangaTable.version],
)
fun getMangaMetaMap(mangaId: Int): Map<String, String> =
transaction {
MangaMetaTable

View File

@@ -9,6 +9,7 @@ package suwayomi.tachidesk.manga.impl
import eu.kanade.tachiyomi.source.local.LocalSource
import eu.kanade.tachiyomi.source.model.MangasPage
import kotlinx.serialization.json.Json
import org.jetbrains.exposed.v1.core.and
import org.jetbrains.exposed.v1.core.dao.id.EntityID
import org.jetbrains.exposed.v1.core.eq
@@ -75,6 +76,7 @@ object MangaList {
this[MangaTable.status] = it.status
this[MangaTable.thumbnail_url] = it.thumbnail_url
this[MangaTable.updateStrategy] = it.update_strategy.name
this[MangaTable.memo] = Json.encodeToString(it.memo)
this[MangaTable.sourceReference] = sourceId
}.associate { Pair(it[MangaTable.url], it[MangaTable.id].value) }
@@ -103,6 +105,7 @@ object MangaList {
this[MangaTable.status] = sManga.status
this[MangaTable.thumbnail_url] = sManga.thumbnail_url ?: manga[MangaTable.thumbnail_url]
this[MangaTable.updateStrategy] = sManga.update_strategy.name
this[MangaTable.memo] = Json.encodeToString(sManga.memo)
if (!sManga.thumbnail_url.isNullOrEmpty() && manga[MangaTable.thumbnail_url] != sManga.thumbnail_url) {
this[MangaTable.thumbnailUrlLastFetched] = Instant.now().epochSecond
Manga.clearThumbnail(manga[MangaTable.id].value)

View File

@@ -25,10 +25,11 @@ import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.statements.toExecutable
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import suwayomi.tachidesk.manga.impl.Source.preferenceScreenMap
import suwayomi.tachidesk.manga.impl.extension.Extension.getExtensionIconUrl
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.ContentWarning
import suwayomi.tachidesk.manga.model.dataclass.SourceDataClass
import suwayomi.tachidesk.manga.model.table.ExtensionTable
import suwayomi.tachidesk.manga.model.table.SourceMetaTable
@@ -49,10 +50,10 @@ object Source {
id = it[SourceTable.id].value.toString(),
name = it[SourceTable.name],
lang = it[SourceTable.lang],
iconUrl = getExtensionIconUrl(sourceExtension[ExtensionTable.apkName]),
iconUrl = proxyExtensionIconUrl(sourceExtension[ExtensionTable.pkgName]),
supportsLatest = catalogueSource.supportsLatest,
isConfigurable = catalogueSource is ConfigurableSource,
isNsfw = it[SourceTable.isNsfw],
isNsfw = it[SourceTable.contentWarning] >= ContentWarning.MIXED.ordinal,
displayName = catalogueSource.toString(),
baseUrl = runCatching { (catalogueSource as? HttpSource)?.baseUrl }.getOrNull(),
)
@@ -70,13 +71,10 @@ object Source {
id = sourceId.toString(),
name = source[SourceTable.name],
lang = source[SourceTable.lang],
iconUrl =
getExtensionIconUrl(
extension[ExtensionTable.apkName],
),
iconUrl = proxyExtensionIconUrl(extension[ExtensionTable.pkgName]),
supportsLatest = catalogueSource.supportsLatest,
isConfigurable = catalogueSource is ConfigurableSource,
isNsfw = source[SourceTable.isNsfw],
isNsfw = source[SourceTable.contentWarning] >= ContentWarning.MIXED.ordinal,
displayName = catalogueSource.toString(),
baseUrl = runCatching { (catalogueSource as? HttpSource)?.baseUrl }.getOrNull(),
)

View File

@@ -8,6 +8,7 @@ package suwayomi.tachidesk.manga.impl.backup.proto.handlers
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import eu.kanade.tachiyomi.source.model.UpdateStrategy
import kotlinx.serialization.json.Json
import org.jetbrains.exposed.v1.core.ResultRow
import org.jetbrains.exposed.v1.core.SortOrder
import org.jetbrains.exposed.v1.core.and
@@ -76,6 +77,8 @@ object BackupMangaHandler {
updateStrategy = UpdateStrategy.valueOf(mangaRow[MangaTable.updateStrategy]),
lastModifiedAt = mangaRow[MangaTable.lastModifiedAt],
version = mangaRow[MangaTable.version],
initialized = mangaRow[MangaTable.initialized],
memo = Json.encodeToString(mangaRow[MangaTable.memo]).encodeToByteArray(),
)
val mangaId = mangaRow[MangaTable.id].value
@@ -238,6 +241,7 @@ object BackupMangaHandler {
it[lastModifiedAt] = manga.lastModifiedAt
it[version] = manga.version
it[memo] = manga.memo.decodeToString()
}.value
} else {
val dbMangaId = dbManga[MangaTable.id].value
@@ -260,6 +264,7 @@ object BackupMangaHandler {
it[lastModifiedAt] = manga.lastModifiedAt
it[version] = manga.version
it[memo] = manga.memo.decodeToString()
}
dbMangaId
@@ -351,6 +356,7 @@ object BackupMangaHandler {
this[ChapterTable.lastModifiedAt] = chapter.lastModifiedAt
this[ChapterTable.version] = chapter.version
this[ChapterTable.memo] = chapter.memo.decodeToString()
}.map { it[ChapterTable.id].value }
} else {
emptyList()

View File

@@ -2,6 +2,7 @@ package suwayomi.tachidesk.manga.impl.backup.proto.models
import kotlinx.serialization.Serializable
import kotlinx.serialization.protobuf.ProtoNumber
import suwayomi.tachidesk.manga.impl.util.lang.JsonObjectEmptyBytes
@Serializable
data class BackupChapter(
@@ -22,6 +23,7 @@ data class BackupChapter(
// syncyomi
@ProtoNumber(11) var lastModifiedAt: Long = 0,
@ProtoNumber(12) var version: Long = 0,
@ProtoNumber(13) var memo: ByteArray = JsonObjectEmptyBytes,
// suwayomi
@ProtoNumber(9000) var meta: Map<String, String> = emptyMap(),
)

View File

@@ -3,6 +3,7 @@ package suwayomi.tachidesk.manga.impl.backup.proto.models
import eu.kanade.tachiyomi.source.model.UpdateStrategy
import kotlinx.serialization.Serializable
import kotlinx.serialization.protobuf.ProtoNumber
import suwayomi.tachidesk.manga.impl.util.lang.JsonObjectEmptyBytes
@Serializable
data class BackupManga(
@@ -37,6 +38,8 @@ data class BackupManga(
// syncyomi
@ProtoNumber(106) var lastModifiedAt: Long = 0,
@ProtoNumber(109) var version: Long = 0,
@ProtoNumber(111) var initialized: Boolean = false,
@ProtoNumber(13) var memo: ByteArray = JsonObjectEmptyBytes,
// suwayomi
@ProtoNumber(9000) var meta: Map<String, String> = emptyMap(),
)

View File

@@ -13,6 +13,7 @@ import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceFactory
import eu.kanade.tachiyomi.source.local.LocalSource
import io.github.oshai.kotlinlogging.KotlinLogging
import net.dongliu.apk.parser.ApkFile
import net.dongliu.apk.parser.bean.Icon
@@ -23,15 +24,17 @@ import okio.source
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.jdbc.deleteWhere
import org.jetbrains.exposed.v1.jdbc.insert
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.manga.impl.extension.ExtensionsList.extensionTableAsDataClass
import suwayomi.tachidesk.manga.impl.extension.github.ExtensionGithubApi
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
import suwayomi.tachidesk.manga.impl.util.PackageTools.LIB_VERSION_MIN
import suwayomi.tachidesk.manga.impl.util.PackageTools.METADATA_CONTENT_WARNING
import suwayomi.tachidesk.manga.impl.util.PackageTools.METADATA_EXTENSION_LIB
import suwayomi.tachidesk.manga.impl.util.PackageTools.METADATA_NAME
import suwayomi.tachidesk.manga.impl.util.PackageTools.METADATA_NSFW
import suwayomi.tachidesk.manga.impl.util.PackageTools.METADATA_SOURCE_CLASS
import suwayomi.tachidesk.manga.impl.util.PackageTools.dex2jar
@@ -62,18 +65,20 @@ object Extension {
suspend fun installExtension(pkgName: String): Int {
logger.debug { "Installing $pkgName" }
val extensionRecord = extensionTableAsDataClass().first { it.pkgName == pkgName }
val apkUrl =
transaction {
ExtensionTable
.select(ExtensionTable.apkUrl)
.where { ExtensionTable.pkgName eq pkgName }
.firstOrNull()
?.get(ExtensionTable.apkUrl)
} ?: throw NullPointerException("Could not find extension $pkgName")
return installAPK {
val apkURL =
ExtensionGithubApi.getApkUrl(
extensionRecord.repo ?: throw NullPointerException("Could not find extension repo"),
extensionRecord.apkName,
)
val apkName = Uri.parse(apkURL).lastPathSegment!!
val apkName = Uri.parse(apkUrl).lastPathSegment!!
val apkSavePath = "${applicationDirs.extensionsRoot}/$apkName"
// download apk file
downloadAPKFile(apkURL, apkSavePath)
downloadAPKFile(apkUrl, apkSavePath)
apkSavePath
}
@@ -148,7 +153,10 @@ object Extension {
// throw Exception("This apk is not a signed with the official tachiyomi signature")
// }
val isNsfw = packageInfo.applicationInfo.metaData.getString(METADATA_NSFW) == "1"
var contentWarning = packageInfo.applicationInfo.metaData.getInt(METADATA_CONTENT_WARNING)
if (contentWarning == 0) {
contentWarning = packageInfo.applicationInfo.metaData.getInt(METADATA_NSFW)
}
val className =
packageInfo.packageName + packageInfo.applicationInfo.metaData.getString(METADATA_SOURCE_CLASS)
@@ -181,9 +189,16 @@ object Extension {
}
val extensionName =
packageInfo.applicationInfo.nonLocalizedLabel
.toString()
.substringAfter("Tachiyomi: ")
packageInfo.applicationInfo.metaData.getString(METADATA_NAME)
?: packageInfo.applicationInfo.nonLocalizedLabel
.toString()
.substringAfter("Tachiyomi: ")
val extensionLibVersion =
packageInfo.applicationInfo.metaData
.getString(METADATA_EXTENSION_LIB)
.takeUnless { it == "0" }
?: packageInfo.versionName.substringBeforeLast('.')
// update extension info
transaction {
@@ -193,9 +208,10 @@ object Extension {
it[name] = extensionName
it[this.pkgName] = packageInfo.packageName
it[versionName] = packageInfo.versionName
it[versionCode] = packageInfo.versionCode
it[versionCode] = packageInfo.versionCode.toLong()
it[extensionLib] = extensionLibVersion
it[lang] = extensionLang
it[this.isNsfw] = isNsfw
it[this.contentWarning] = contentWarning
}
}
@@ -204,7 +220,7 @@ object Extension {
it[this.isInstalled] = true
it[this.classFQName] = className
it[versionName] = packageInfo.versionName
it[versionCode] = packageInfo.versionCode
it[versionCode] = packageInfo.versionCode.toLong()
}
val extensionId =
@@ -220,7 +236,7 @@ object Extension {
it[name] = httpSource.name
it[lang] = httpSource.lang
it[extension] = extensionId
it[SourceTable.isNsfw] = isNsfw
it[this.contentWarning] = contentWarning
}
logger.debug { "Installed source ${httpSource.name} (${httpSource.lang}) with id:${httpSource.id}" }
}
@@ -343,7 +359,9 @@ object Extension {
logger.debug { "Uninstalling $pkgName" }
val extensionRecord = transaction { ExtensionTable.selectAll().where { ExtensionTable.pkgName eq pkgName }.first() }
val fileNameWithoutType = extensionRecord[ExtensionTable.apkName].substringBefore(".apk")
val fileNameWithoutType =
extensionRecord[ExtensionTable.apkName]?.substringBefore(".apk")
?: throw NullPointerException("Missing $pkgName apkName")
val jarPath = "${applicationDirs.extensionsRoot}/$fileNameWithoutType.jar"
val sources =
transaction {
@@ -359,6 +377,7 @@ object Extension {
ExtensionTable.update({ ExtensionTable.pkgName eq pkgName }) {
it[isInstalled] = false
it[hasUpdate] = false
it[apkName] = null
}
}
@@ -385,8 +404,7 @@ object Extension {
it[versionName] = targetExtension.versionName
it[versionCode] = targetExtension.versionCode
it[lang] = targetExtension.lang
it[isNsfw] = targetExtension.isNsfw
it[apkName] = targetExtension.apkName
it[contentWarning] = targetExtension.contentWarning.ordinal
it[iconUrl] = targetExtension.iconUrl
it[hasUpdate] = false
}
@@ -394,17 +412,21 @@ object Extension {
return installExtension(pkgName)
}
suspend fun getExtensionIcon(apkName: String): Pair<InputStream, String> {
val iconUrl =
if (apkName == "localSource") {
""
} else {
transaction { ExtensionTable.selectAll().where { ExtensionTable.apkName eq apkName }.first() }[ExtensionTable.iconUrl]
}
suspend fun getExtensionIcon(pkgName: String): Pair<InputStream, String> {
val cacheSaveDir = "${applicationDirs.extensionsRoot}/icon"
return getImageResponse(cacheSaveDir, apkName) {
if (pkgName == LocalSource::class.java.`package`.name) {
return getImageResponse(cacheSaveDir, "localSource") {
network.client
.newCall(GET("", cache = CacheControl.FORCE_NETWORK))
.await()
}
}
val iconUrl =
transaction { ExtensionTable.selectAll().where { ExtensionTable.pkgName eq pkgName }.first() }[ExtensionTable.iconUrl]
return getImageResponse(cacheSaveDir, pkgName) {
network.client
.newCall(
GET(iconUrl, cache = CacheControl.FORCE_NETWORK),
@@ -412,5 +434,5 @@ object Extension {
}
}
fun getExtensionIconUrl(apkName: String): String = "/api/v1/extension/icon/$apkName"
fun proxyExtensionIconUrl(pkgName: String): String = "/api/v1/extension/icon/$pkgName"
}

View File

@@ -0,0 +1,269 @@
package suwayomi.tachidesk.manga.impl.extension
/*
* 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 eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.network.awaitSuccess
import io.github.oshai.kotlinlogging.KotlinLogging
import kotlinx.serialization.decodeFromByteArray
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.okio.decodeFromBufferedSource
import kotlinx.serialization.protobuf.ProtoBuf
import okio.BufferedSource
import okio.buffer
import okio.gzip
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.core.inList
import org.jetbrains.exposed.v1.jdbc.deleteWhere
import org.jetbrains.exposed.v1.jdbc.insert
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.manga.impl.extension.github.NetworkExtensionStore
import suwayomi.tachidesk.manga.impl.extension.github.NetworkLegacyExtension
import suwayomi.tachidesk.manga.impl.extension.github.NetworkLegacyExtensionRepo
import suwayomi.tachidesk.manga.impl.extension.github.toExtensionInfo
import suwayomi.tachidesk.manga.impl.extension.github.toExtensionInfos
import suwayomi.tachidesk.manga.model.dataclass.ExtensionInfo
import suwayomi.tachidesk.manga.model.dataclass.ExtensionStore
import suwayomi.tachidesk.manga.model.table.ExtensionStoreTable
import suwayomi.tachidesk.server.serverConfig
import uy.kohesive.injekt.injectLazy
import kotlin.coroutines.cancellation.CancellationException
object ExtensionStoreService {
private val logger = KotlinLogging.logger {}
val network: NetworkHelper by injectLazy()
val protoBuf: ProtoBuf by injectLazy()
val json: Json by injectLazy()
suspend fun fetch(indexUrl: String): ExtensionStore {
var updatedIndexUrl: String = indexUrl
return try {
val response = network.client.newCall(GET(updatedIndexUrl)).awaitSuccess()
response.body.source().decompressIfGzipped().use { source ->
val networkStore =
when (source.peek().readByte()) {
// "[..."
0x5B.toByte() -> {
run {
if (!indexUrl.endsWith("/index.min.json")) {
throw IllegalArgumentException("Provided legacy store url is not valid")
}
updatedIndexUrl = indexUrl.replace("/index.min.json", "/repo.json")
network.client.newCall(GET(updatedIndexUrl)).awaitSuccess().body.source().use {
json.decodeFromBufferedSource<NetworkLegacyExtensionRepo>(it)
}
}
}
// "{..."
0x7B.toByte() -> {
try {
json.decodeFromBufferedSource<NetworkLegacyExtensionRepo>(source.peek())
} catch (_: IllegalArgumentException) {
json.decodeFromBufferedSource<NetworkExtensionStore>(source)
}
}
else -> {
protoBuf.decodeFromByteArray<NetworkExtensionStore>(source.readByteArray())
}
}
if (networkStore is NetworkLegacyExtensionRepo && networkStore.indexV2 != null) {
return fetch(networkStore.indexV2)
}
networkStore.toExtensionStore(updatedIndexUrl)
}
} catch (e: Exception) {
if (e is CancellationException) throw e
logger.error(e) { "Failed to fetch extension store '$indexUrl'" }
throw e
}
}
fun upsert(store: ExtensionStore) {
transaction {
val existing =
ExtensionStoreTable
.selectAll()
.where { ExtensionStoreTable.indexUrl eq store.indexUrl }
.firstOrNull()
if (existing == null) {
ExtensionStoreTable.insert {
it[name] = store.name
it[badgeLabel] = store.badgeLabel
it[signingKey] = store.signingKey
it[contactWebsite] = store.contact.website
it[contactDiscord] = store.contact.discord
it[indexUrl] = store.indexUrl
it[isLegacy] = store.isLegacy
it[extensionListUrl] = store.extensionListUrl
}
} else {
ExtensionStoreTable.update({ ExtensionStoreTable.indexUrl eq store.indexUrl }) {
it[name] = store.name
it[badgeLabel] = store.badgeLabel
it[signingKey] = store.signingKey
it[contactWebsite] = store.contact.website
it[contactDiscord] = store.contact.discord
it[isLegacy] = store.isLegacy
it[extensionListUrl] = store.extensionListUrl
}
}
}
}
suspend fun getAndRefresh(): List<ExtensionStore> {
val stores =
transaction {
ExtensionStoreTable.selectAll().toList()
}
var needsPrefUpdate = false
val updateStores =
stores.mapNotNull { storeRow ->
val oldIndexUrl = storeRow[ExtensionStoreTable.indexUrl]
val oldName = storeRow[ExtensionStoreTable.name]
try {
val store = fetch(oldIndexUrl)
if (store.indexUrl != oldIndexUrl) {
transaction {
ExtensionStoreTable.deleteWhere { ExtensionStoreTable.indexUrl eq oldIndexUrl }
}
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() {
val dbStores =
transaction {
ExtensionStoreTable
.selectAll()
.map { it[ExtensionStoreTable.indexUrl] }
.toSet()
}
val currentPrefs = serverConfig.extensionStores.value.toSet()
val toAdd = dbStores - currentPrefs
val toRemove = currentPrefs - dbStores
if (toAdd.isNotEmpty()) {
serverConfig.extensionStores.value = (serverConfig.extensionStores.value + toAdd).distinct()
}
if (toRemove.isNotEmpty()) {
serverConfig.extensionStores.value = serverConfig.extensionStores.value.filterNot { it in toRemove }
}
}
suspend fun syncPrefsToDb() {
val prefUrls = serverConfig.extensionStores.value.toSet()
val dbStores =
transaction {
ExtensionStoreTable.selectAll().associateBy { it[ExtensionStoreTable.indexUrl] }
}
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" }
}
}
if (toRemove.isNotEmpty()) {
transaction {
ExtensionStoreTable.deleteWhere { ExtensionStoreTable.indexUrl inList toRemove.toList() }
}
}
if (needsPrefUpdate) {
syncDbToPrefs()
}
}
suspend fun getExtensions(store: ExtensionStore): List<ExtensionInfo> {
val extensions =
if (store.extensionListUrl != null) {
val response = network.client.newCall(GET(store.extensionListUrl)).awaitSuccess()
response.body.source().decompressIfGzipped().use { source ->
when (source.peek().readByte()) {
// "{..."
0x7B.toByte() -> {
json.decodeFromBufferedSource<NetworkExtensionStore.ExtensionList>(source)
}
else -> {
protoBuf.decodeFromByteArray<NetworkExtensionStore.ExtensionList>(
source.readByteArray(),
)
}
}.toExtensionInfos(store)
}
} else if (!store.isLegacy) {
val response = network.client.newCall(GET(store.indexUrl)).awaitSuccess()
response.body.source().decompressIfGzipped().use { source ->
when (source.peek().readByte()) {
// "{..."
0x7B.toByte() -> json.decodeFromBufferedSource<NetworkExtensionStore>(source)
else -> protoBuf.decodeFromByteArray<NetworkExtensionStore>(source.readByteArray())
}.extensionList!!
.toExtensionInfos(store)
}
} else {
val storeBaseUrl = store.indexUrl.removeSuffix("/repo.json")
val response = network.client.newCall(GET("$storeBaseUrl/index.min.json")).awaitSuccess()
response.body.source().use { source ->
json
.decodeFromBufferedSource<List<NetworkLegacyExtension>>(source)
.map { it.toExtensionInfo(store, storeBaseUrl) }
}
}
return extensions
}
private fun BufferedSource.decompressIfGzipped(): BufferedSource {
val isGzip =
peek().use { peeked ->
try {
peeked.readShort().toInt() == 0x1f8b
} catch (_: Exception) {
false
}
}
return if (isGzip) gzip().buffer() else this
}
}

View File

@@ -21,12 +21,11 @@ import org.jetbrains.exposed.v1.jdbc.selectAll
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.getExtensionIconUrl
import suwayomi.tachidesk.manga.impl.extension.github.ExtensionGithubApi
import suwayomi.tachidesk.manga.impl.extension.github.OnlineExtension
import suwayomi.tachidesk.manga.impl.extension.Extension.proxyExtensionIconUrl
import suwayomi.tachidesk.manga.model.dataclass.ContentWarning
import suwayomi.tachidesk.manga.model.dataclass.ExtensionDataClass
import suwayomi.tachidesk.manga.model.dataclass.ExtensionInfo
import suwayomi.tachidesk.manga.model.table.ExtensionTable
import suwayomi.tachidesk.server.serverConfig
import java.util.concurrent.ConcurrentHashMap
import kotlin.time.Duration.Companion.seconds
@@ -34,23 +33,23 @@ object ExtensionsList {
private val logger = KotlinLogging.logger {}
var lastUpdateCheck: Long = 0
var updateMap = ConcurrentHashMap<String, OnlineExtension>()
var updateMap = ConcurrentHashMap<String, ExtensionInfo>()
suspend fun fetchExtensions() {
// update if 60 seconds has passed or requested offline and database is empty
val extensions =
serverConfig.extensionRepos.value.map { repo ->
kotlin
.runCatching {
ExtensionGithubApi.findExtensions(repo.repoUrlReplace())
}.onFailure {
logger.warn(it) {
"Failed to fetch extensions for repo: $repo"
}
}
val allExtensions = mutableListOf<ExtensionInfo>()
ExtensionStoreService.getAndRefresh().forEach { store ->
try {
val extensions = ExtensionStoreService.getExtensions(store)
allExtensions.addAll(extensions)
} catch (e: Exception) {
logger.warn(e) {
"Failed to fetch extensions for store: ${store.indexUrl}"
}
}
val foundExtensions = extensions.mapNotNull { it.getOrNull() }.flatten()
updateExtensionDatabase(foundExtensions)
}
updateExtensionDatabase(allExtensions)
}
suspend fun fetchExtensionsCached() {
@@ -74,25 +73,25 @@ object ExtensionsList {
transaction {
ExtensionTable.selectAll().filter { it[ExtensionTable.name] != LocalSource.EXTENSION_NAME }.map {
ExtensionDataClass(
it[ExtensionTable.repo],
it[ExtensionTable.apkName],
getExtensionIconUrl(it[ExtensionTable.apkName]),
it[ExtensionTable.name],
it[ExtensionTable.pkgName],
it[ExtensionTable.versionName],
it[ExtensionTable.versionCode],
it[ExtensionTable.lang],
it[ExtensionTable.isNsfw],
it[ExtensionTable.isInstalled],
it[ExtensionTable.hasUpdate],
it[ExtensionTable.isObsolete],
repo = it[ExtensionTable.storeIndexUrl],
apkName = it[ExtensionTable.apkName].orEmpty(),
iconUrl = proxyExtensionIconUrl(it[ExtensionTable.pkgName]),
name = it[ExtensionTable.name],
pkgName = it[ExtensionTable.pkgName],
versionName = it[ExtensionTable.versionName],
versionCode = it[ExtensionTable.versionCode].toInt(),
lang = it[ExtensionTable.lang],
isNsfw = it[ExtensionTable.contentWarning] >= ContentWarning.MIXED.ordinal,
installed = it[ExtensionTable.isInstalled],
hasUpdate = it[ExtensionTable.hasUpdate],
obsolete = it[ExtensionTable.isObsolete],
)
}
}
private val updateExtensionDatabaseMutex = Mutex()
private suspend fun updateExtensionDatabase(foundExtensions: List<OnlineExtension>) {
private suspend fun updateExtensionDatabase(foundExtensions: List<ExtensionInfo>) {
updateExtensionDatabaseMutex.withLock {
transaction {
val uniqueExtensions =
@@ -106,10 +105,10 @@ object ExtensionsList {
.selectAll()
.toList()
.associateBy { it[ExtensionTable.pkgName] }
val extensionsToUpdate = mutableListOf<Pair<OnlineExtension, ResultRow>>()
val extensionsToInsert = mutableListOf<OnlineExtension>()
val extensionsToUpdate = mutableListOf<Pair<ExtensionInfo, ResultRow>>()
val extensionsToInsert = mutableListOf<ExtensionInfo>()
val extensionsToDelete =
installedExtensions.filter { it.value[ExtensionTable.repo] != null }.mapNotNull { (pkgName, extension) ->
installedExtensions.filter { it.value[ExtensionTable.storeIndexUrl] != null }.mapNotNull { (pkgName, extension) ->
extension.takeUnless { uniqueExtensions.any { it.pkgName == pkgName } }
}
uniqueExtensions.forEach {
@@ -132,7 +131,7 @@ object ExtensionsList {
addBatch(EntityID(extensionRecord[ExtensionTable.id].value, ExtensionTable))
// Always update icon url and repo
this[ExtensionTable.iconUrl] = foundExtension.iconUrl
this[ExtensionTable.repo] = foundExtension.repo
this[ExtensionTable.storeIndexUrl] = foundExtension.storeIndexUrl
// add these because batch updates need matching columns
this[ExtensionTable.hasUpdate] = extensionRecord[ExtensionTable.hasUpdate]
@@ -168,13 +167,14 @@ object ExtensionsList {
extensionsToFullyUpdate.forEach { (foundExtension, extensionRecord) ->
addBatch(EntityID(extensionRecord[ExtensionTable.id].value, ExtensionTable))
// extension is not installed, so we can overwrite the data without a care
this[ExtensionTable.repo] = foundExtension.repo
this[ExtensionTable.storeIndexUrl] = foundExtension.storeIndexUrl
this[ExtensionTable.name] = foundExtension.name
this[ExtensionTable.extensionLib] = foundExtension.extensionLib
this[ExtensionTable.versionName] = foundExtension.versionName
this[ExtensionTable.versionCode] = foundExtension.versionCode
this[ExtensionTable.lang] = foundExtension.lang
this[ExtensionTable.isNsfw] = foundExtension.isNsfw
this[ExtensionTable.apkName] = foundExtension.apkName
this[ExtensionTable.contentWarning] = foundExtension.contentWarning.ordinal
this[ExtensionTable.apkUrl] = foundExtension.apkUrl
this[ExtensionTable.iconUrl] = foundExtension.iconUrl
}
}.toExecutable()
@@ -183,14 +183,15 @@ object ExtensionsList {
}
if (extensionsToInsert.isNotEmpty()) {
ExtensionTable.batchInsert(extensionsToInsert) { foundExtension ->
this[ExtensionTable.repo] = foundExtension.repo
this[ExtensionTable.storeIndexUrl] = foundExtension.storeIndexUrl
this[ExtensionTable.name] = foundExtension.name
this[ExtensionTable.pkgName] = foundExtension.pkgName
this[ExtensionTable.extensionLib] = foundExtension.extensionLib
this[ExtensionTable.versionName] = foundExtension.versionName
this[ExtensionTable.versionCode] = foundExtension.versionCode
this[ExtensionTable.lang] = foundExtension.lang
this[ExtensionTable.isNsfw] = foundExtension.isNsfw
this[ExtensionTable.apkName] = foundExtension.apkName
this[ExtensionTable.contentWarning] = foundExtension.contentWarning.ordinal
this[ExtensionTable.apkUrl] = foundExtension.apkUrl
this[ExtensionTable.iconUrl] = foundExtension.iconUrl
}
}
@@ -215,16 +216,4 @@ object ExtensionsList {
}
}
}
private fun String.repoUrlReplace(): String =
if (contains("github")) {
replace(repoMatchRegex) {
"https://raw.githubusercontent.com/${it.groupValues[2]}/${it.groupValues[3]}/" +
(it.groupValues.getOrNull(4)?.ifBlank { null } ?: "repo") +
"/" +
(it.groupValues.getOrNull(5)?.ifBlank { null } ?: "index.min.json")
}
} else {
this
}
}

View File

@@ -0,0 +1,14 @@
package suwayomi.tachidesk.manga.impl.extension.github
/*
* 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 suwayomi.tachidesk.manga.model.dataclass.ExtensionStore
interface BaseNetworkExtensionStore {
fun toExtensionStore(indexUrl: String): ExtensionStore
}

View File

@@ -1,107 +0,0 @@
package suwayomi.tachidesk.manga.impl.extension.github
/*
* 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 eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.network.awaitSuccess
import eu.kanade.tachiyomi.network.parseAs
import io.github.oshai.kotlinlogging.KotlinLogging
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import suwayomi.tachidesk.manga.impl.util.PackageTools.LIB_VERSION_MAX
import suwayomi.tachidesk.manga.impl.util.PackageTools.LIB_VERSION_MIN
import uy.kohesive.injekt.injectLazy
object ExtensionGithubApi {
private val logger = KotlinLogging.logger {}
private val json: Json by injectLazy()
@Serializable
private data class ExtensionJsonObject(
val name: String,
val pkg: String,
val apk: String,
val lang: String,
val code: Int,
val version: String,
val nsfw: Int,
val hasReadme: Int = 0,
val hasChangelog: Int = 0,
val sources: List<ExtensionSourceJsonObject>?,
)
@Serializable
private data class ExtensionSourceJsonObject(
val name: String,
val lang: String,
val id: Long,
val baseUrl: String,
)
suspend fun findExtensions(repo: String): List<OnlineExtension> {
val response =
client.newCall(GET(repo)).awaitSuccess()
return with(json) {
response
.parseAs<List<ExtensionJsonObject>>()
.toExtensions(repo.substringBeforeLast('/') + '/')
}
}
fun getApkUrl(
repo: String,
apkName: String,
): String = "${repo}apk/$apkName"
private val client by lazy {
val network: NetworkHelper by injectLazy()
network.client
.newBuilder()
.addNetworkInterceptor { chain ->
val originalResponse = chain.proceed(chain.request())
originalResponse
.newBuilder()
.header("Content-Type", "application/json")
.build()
}.build()
}
private fun List<ExtensionJsonObject>.toExtensions(repo: String): List<OnlineExtension> =
this
.filter {
val libVersion = it.version.substringBeforeLast('.').toDouble()
libVersion in LIB_VERSION_MIN..LIB_VERSION_MAX
}.map {
OnlineExtension(
repo = repo,
name = it.name.substringAfter("Tachiyomi: "),
pkgName = it.pkg,
versionName = it.version,
versionCode = it.code,
lang = it.lang,
isNsfw = it.nsfw == 1,
hasReadme = it.hasReadme == 1,
hasChangelog = it.hasChangelog == 1,
sources = it.sources?.toExtensionSources() ?: emptyList(),
apkName = it.apk,
iconUrl = "${repo}icon/${it.pkg}.png",
)
}
private fun List<ExtensionSourceJsonObject>.toExtensionSources(): List<OnlineExtensionSource> =
this.map {
OnlineExtensionSource(
name = it.name,
lang = it.lang,
id = it.id,
baseUrl = it.baseUrl,
)
}
}

View File

@@ -0,0 +1,148 @@
package suwayomi.tachidesk.manga.impl.extension.github
/*
* 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 kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonNames
import kotlinx.serialization.protobuf.ProtoNumber
import suwayomi.tachidesk.manga.model.dataclass.ContentWarning
import suwayomi.tachidesk.manga.model.dataclass.ExtensionInfo
import suwayomi.tachidesk.manga.model.dataclass.ExtensionSource
import suwayomi.tachidesk.manga.model.dataclass.ExtensionStore
@OptIn(ExperimentalSerializationApi::class)
@Serializable
data class NetworkExtensionStore(
@ProtoNumber(1) val name: String,
@ProtoNumber(2) val badgeLabel: String,
@ProtoNumber(3) val signingKey: String,
@ProtoNumber(4) val contact: Contact,
@ProtoNumber(101) val extensionList: ExtensionList?,
@ProtoNumber(102) val extensionListUrl: String?,
) : BaseNetworkExtensionStore {
@Serializable
data class Contact(
@ProtoNumber(1) val website: String,
@ProtoNumber(2) val discord: String?,
)
@Serializable
data class ExtensionList(
@ProtoNumber(1) val extensions: List<Extension>,
)
@Serializable
data class Extension(
@ProtoNumber(1) val name: String,
@ProtoNumber(2) val packageName: String,
@ProtoNumber(3) val resources: Resources,
@ProtoNumber(4) val extensionLib: String,
@ProtoNumber(5) val versionCode: Long,
@ProtoNumber(6) val versionName: String,
@ProtoNumber(7) val contentWarning: ContentWarning,
@ProtoNumber(8) val sources: List<Source>,
)
@Serializable
data class Resources(
@ProtoNumber(1) val apkUrl: String,
@ProtoNumber(2) val iconUrl: String,
)
@Serializable
data class Source(
@ProtoNumber(1) val id: Long,
@ProtoNumber(2) val name: String,
@ProtoNumber(3) val language: String,
@ProtoNumber(4) val homeUrl: String = "",
@ProtoNumber(5) val mirrorUrls: List<String> = emptyList(),
// @ProtoNumber(6) val contentWarning: ContentWarning = ContentWarning.SAFE,
@ProtoNumber(7) val message: String? = null,
)
@Serializable
enum class ContentWarning {
@ProtoNumber(0)
@JsonNames("CONTENT_WARNING_UNSPECIFIED")
UNSPECIFIED,
@ProtoNumber(1)
@JsonNames("CONTENT_WARNING_SAFE")
SAFE,
@ProtoNumber(2)
@JsonNames("CONTENT_WARNING_MIXED")
MIXED,
@ProtoNumber(3)
@JsonNames("CONTENT_WARNING_NSFW")
NSFW,
}
override fun toExtensionStore(indexUrl: String): ExtensionStore =
ExtensionStore(
indexUrl = indexUrl,
name = name,
badgeLabel = badgeLabel,
signingKey = signingKey,
contact =
ExtensionStore.Contact(
website = contact.website,
discord = contact.discord,
),
isLegacy = false,
extensionListUrl = extensionListUrl,
)
}
fun NetworkExtensionStore.ExtensionList.toExtensionInfos(store: ExtensionStore): List<ExtensionInfo> =
extensions.map { extension ->
val lang = extension.sources.map { it.language }.toSet()
ExtensionInfo(
storeIndexUrl = store.indexUrl,
name = extension.name,
pkgName = extension.packageName,
apkUrl = extension.resources.apkUrl,
iconUrl = extension.resources.iconUrl,
extensionLib = extension.extensionLib,
versionCode = extension.versionCode,
versionName = extension.versionName,
lang = if (lang.size == 1) lang.first() else "all",
contentWarning =
when (extension.contentWarning) {
NetworkExtensionStore.ContentWarning.SAFE,
NetworkExtensionStore.ContentWarning.UNSPECIFIED,
-> ContentWarning.SAFE
NetworkExtensionStore.ContentWarning.MIXED -> ContentWarning.MIXED
NetworkExtensionStore.ContentWarning.NSFW -> ContentWarning.NSFW
},
sources =
extension.sources.map { source ->
ExtensionSource(
id = source.id,
name = source.name,
lang = source.language,
homeUrl = source.homeUrl,
message = source.message,
contentWarning =
when (extension.contentWarning) { // todo source.contentWarning
NetworkExtensionStore.ContentWarning.SAFE,
NetworkExtensionStore.ContentWarning.UNSPECIFIED,
-> ContentWarning.SAFE
NetworkExtensionStore.ContentWarning.MIXED -> ContentWarning.MIXED
NetworkExtensionStore.ContentWarning.NSFW -> ContentWarning.NSFW
},
)
},
)
}

View File

@@ -0,0 +1,77 @@
package suwayomi.tachidesk.manga.impl.extension.github
/*
* 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 kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import suwayomi.tachidesk.manga.model.dataclass.ContentWarning
import suwayomi.tachidesk.manga.model.dataclass.ExtensionInfo
import suwayomi.tachidesk.manga.model.dataclass.ExtensionSource
import suwayomi.tachidesk.manga.model.dataclass.ExtensionStore
@OptIn(ExperimentalSerializationApi::class)
@Serializable
data class NetworkLegacyExtension(
val name: String,
val pkg: String,
val apk: String,
val lang: String,
val version: String,
val code: Long,
val nsfw: Int,
val sources: List<Source>? = null,
) {
@Serializable
data class Source(
val id: Long,
val lang: String,
val name: String,
val baseUrl: String,
)
}
fun NetworkLegacyExtension.toExtensionInfo(
store: ExtensionStore,
storeBaseUrl: String,
): ExtensionInfo =
ExtensionInfo(
storeIndexUrl = store.indexUrl,
name = name.substringAfter("Tachiyomi: "),
pkgName = pkg,
apkUrl = "$storeBaseUrl/apk/$apk",
iconUrl = "$storeBaseUrl/icon/$pkg.png",
extensionLib = version.substringBeforeLast('.'),
versionCode = code,
versionName = version,
lang = lang,
contentWarning = if (nsfw == 1) ContentWarning.MIXED else ContentWarning.SAFE,
sources =
if (sources.isNullOrEmpty()) {
listOf(
ExtensionSource(
id = 0,
name = name,
lang = lang,
homeUrl = "",
message = null,
contentWarning = if (nsfw == 1) ContentWarning.MIXED else ContentWarning.SAFE,
),
)
} else {
sources.map { source ->
ExtensionSource(
id = source.id,
name = source.name,
lang = source.lang,
homeUrl = source.baseUrl,
message = null,
contentWarning = if (nsfw == 1) ContentWarning.MIXED else ContentWarning.SAFE,
)
}
},
)

View File

@@ -0,0 +1,41 @@
package suwayomi.tachidesk.manga.impl.extension.github
/*
* 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 kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import suwayomi.tachidesk.manga.model.dataclass.ExtensionStore
@Serializable
data class NetworkLegacyExtensionRepo(
@SerialName("index_v2") val indexV2: String?,
val meta: Meta,
) : BaseNetworkExtensionStore {
@Serializable
data class Meta(
val name: String,
val shortName: String?,
val website: String,
val signingKeyFingerprint: String,
)
override fun toExtensionStore(indexUrl: String): ExtensionStore =
ExtensionStore(
indexUrl = indexUrl,
name = meta.name,
badgeLabel = meta.shortName ?: meta.name,
signingKey = meta.signingKeyFingerprint,
contact =
ExtensionStore.Contact(
website = meta.website,
discord = null,
),
isLegacy = true,
extensionListUrl = null,
)
}

View File

@@ -1,30 +0,0 @@
package suwayomi.tachidesk.manga.impl.extension.github
/*
* 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/. */
data class OnlineExtensionSource(
val name: String,
val lang: String,
val id: Long,
val baseUrl: String,
)
data class OnlineExtension(
val repo: String,
val name: String,
val pkgName: String,
val apkName: String,
val lang: String,
val versionCode: Int,
val versionName: String,
val isNsfw: Boolean,
val hasReadme: Boolean,
val hasChangelog: Boolean,
val sources: List<OnlineExtensionSource>,
val iconUrl: String,
)

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

@@ -40,8 +40,13 @@ object PackageTools {
const val METADATA_SOURCE_CLASS = "tachiyomi.extension.class"
const val METADATA_SOURCE_FACTORY = "tachiyomi.extension.factory"
const val METADATA_NSFW = "tachiyomi.extension.nsfw"
const val METADATA_NAME = "tachiyomix.name"
const val METADATA_EXTENSION_LIB = "tachiyomix.extensionLib"
const val METADATA_CONTENT_WARNING = "tachiyomix.contentWarning"
const val LIB_VERSION_MIN = 1.3
const val LIB_VERSION_MAX = 1.5
const val LIB_VERSION_MAX = 1.6
/**
* Convert dex to jar, a wrapper for the dex2jar library

View File

@@ -0,0 +1,10 @@
package suwayomi.tachidesk.manga.impl.util.lang
import kotlinx.serialization.json.JsonObject
val JsonObjectEmpty = JsonObject(emptyMap())
val JsonObjectEmptyBytes = byteArrayOf(0x7B, 0x7D)
val JsonObject.Companion.EMPTY: JsonObject
inline get() = JsonObjectEmpty

View File

@@ -45,7 +45,9 @@ object GetCatalogueSource {
ExtensionTable.selectAll().where { ExtensionTable.id eq extensionId }.first()
}
val apkName = extensionRecord[ExtensionTable.apkName]
val apkName =
extensionRecord[ExtensionTable.apkName]
?: throw NullPointerException("Missing apkName")
val className = extensionRecord[ExtensionTable.classFQName]
val jarName = apkName.substringBefore(".apk") + ".jar"
val jarPath = "${applicationDirs.extensionsRoot}/$jarName"

View File

@@ -13,6 +13,7 @@ import eu.kanade.tachiyomi.source.model.MangasPage
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.model.SMangaUpdate
import rx.Observable
open class StubSource(
@@ -23,9 +24,17 @@ open class StubSource(
override val name: String
get() = id.toString()
override suspend fun getPopularManga(page: Int): MangasPage = throw getSourceNotInstalledException()
@Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getPopularManga"))
override fun fetchPopularManga(page: Int): Observable<MangasPage> = Observable.error(getSourceNotInstalledException())
override suspend fun getSearchManga(
page: Int,
query: String,
filters: FilterList,
): MangasPage = throw getSourceNotInstalledException()
@Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getSearchManga"))
override fun fetchSearchManga(
page: Int,
@@ -33,17 +42,28 @@ open class StubSource(
filters: FilterList,
): Observable<MangasPage> = Observable.error(getSourceNotInstalledException())
override suspend fun getLatestUpdates(page: Int): MangasPage = throw getSourceNotInstalledException()
@Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getLatestUpdates"))
override fun fetchLatestUpdates(page: Int): Observable<MangasPage> = Observable.error(getSourceNotInstalledException())
override fun getFilterList(): FilterList = FilterList()
override suspend fun getMangaUpdate(
manga: SManga,
chapters: List<SChapter>,
fetchDetails: Boolean,
fetchChapters: Boolean,
): SMangaUpdate = throw getSourceNotInstalledException()
@Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getMangaDetails"))
override fun fetchMangaDetails(manga: SManga): Observable<SManga> = Observable.error(getSourceNotInstalledException())
@Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getChapterList"))
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> = Observable.error(getSourceNotInstalledException())
override suspend fun getPageList(chapter: SChapter): List<Page> = throw getSourceNotInstalledException()
@Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getPageList"))
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> = Observable.error(getSourceNotInstalledException())

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

@@ -1,10 +1,13 @@
package suwayomi.tachidesk.manga.model.dataclass
import com.fasterxml.jackson.annotation.JsonIgnore
import eu.kanade.tachiyomi.source.model.SChapter
import kotlinx.serialization.json.JsonObject
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import suwayomi.tachidesk.manga.impl.Chapter.getChapterMetaMap
import suwayomi.tachidesk.manga.impl.util.lang.EMPTY
import suwayomi.tachidesk.manga.model.table.ChapterTable
/*
@@ -43,6 +46,8 @@ data class ChapterDataClass(
val pageCount: Int = -1,
val lastModifiedAt: Long = 0,
val version: Long = 0,
@JsonIgnore
val memo: JsonObject = JsonObject.EMPTY,
) {
companion object {
fun fromSChapter(
@@ -60,6 +65,7 @@ data class ChapterDataClass(
uploadDate = sChapter.date_upload,
chapterNumber = sChapter.chapter_number,
scanlator = sChapter.scanlator,
memo = sChapter.memo,
index = index,
fetchedAt = fetchedAt,
realUrl = realUrl,

View File

@@ -0,0 +1,42 @@
package suwayomi.tachidesk.manga.model.dataclass
/*
* 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/. */
data class ExtensionInfo(
val storeIndexUrl: String,
val name: String,
val pkgName: String,
val apkUrl: String,
val iconUrl: String,
val extensionLib: String,
val versionCode: Long,
val versionName: String,
val lang: String,
val contentWarning: ContentWarning,
val sources: List<ExtensionSource>,
)
data class ExtensionSource(
val id: Long,
val name: String,
val lang: String,
val homeUrl: String,
val message: String?,
val contentWarning: ContentWarning,
)
enum class ContentWarning {
SAFE,
MIXED,
NSFW,
;
companion object {
fun valueOf(contentWarning: Int) = entries.find { it.ordinal == contentWarning } ?: SAFE
}
}

View File

@@ -0,0 +1,23 @@
package suwayomi.tachidesk.manga.model.dataclass
/*
* 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/. */
data class ExtensionStore(
val indexUrl: String,
val name: String,
val badgeLabel: String,
val signingKey: String,
val contact: Contact,
val isLegacy: Boolean,
val extensionListUrl: String?,
) {
data class Contact(
val website: String,
val discord: String?,
)
}

View File

@@ -7,8 +7,11 @@ package suwayomi.tachidesk.manga.model.dataclass
* 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 com.fasterxml.jackson.annotation.JsonIgnore
import eu.kanade.tachiyomi.source.model.UpdateStrategy
import kotlinx.serialization.json.JsonObject
import suwayomi.tachidesk.manga.impl.Manga.getMangaMetaMap
import suwayomi.tachidesk.manga.impl.util.lang.EMPTY
import suwayomi.tachidesk.manga.impl.util.lang.trimAll
import suwayomi.tachidesk.manga.model.table.MangaStatus
import java.time.Instant
@@ -44,6 +47,8 @@ data class MangaDataClass(
val trackers: List<MangaTrackerDataClass>? = null,
val lastModifiedAt: Long = 0,
val version: Long = 0,
@JsonIgnore
val memo: JsonObject = JsonObject.EMPTY,
) {
override fun toString(): String = "\"$title\" (id= $id) (sourceId= $sourceId)"

View File

@@ -7,11 +7,13 @@ package suwayomi.tachidesk.manga.model.table
* 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 kotlinx.serialization.json.Json
import org.jetbrains.exposed.v1.core.ReferenceOption
import org.jetbrains.exposed.v1.core.ResultRow
import org.jetbrains.exposed.v1.core.dao.id.IntIdTable
import suwayomi.tachidesk.manga.model.dataclass.ChapterDataClass
import suwayomi.tachidesk.manga.model.table.columns.truncatingVarchar
import suwayomi.tachidesk.manga.model.table.columns.unlimitedVarchar
object ChapterTable : IntIdTable() {
val url = varchar("url", 2048)
@@ -42,6 +44,8 @@ object ChapterTable : IntIdTable() {
val lastModifiedAt = long("last_modified_at").default(0)
val version = long("version").default(0)
val isSyncing = bool("is_syncing").default(false)
val memo = unlimitedVarchar("memo")
}
fun ChapterTable.toDataClass(chapterEntry: ResultRow) =
@@ -64,4 +68,5 @@ fun ChapterTable.toDataClass(chapterEntry: ResultRow) =
pageCount = chapterEntry[pageCount],
lastModifiedAt = chapterEntry[lastModifiedAt],
version = chapterEntry[version],
memo = Json.decodeFromString(chapterEntry[memo]),
)

View File

@@ -0,0 +1,21 @@
package suwayomi.tachidesk.manga.model.table
/*
* 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 org.jetbrains.exposed.v1.core.dao.id.IntIdTable
object ExtensionStoreTable : IntIdTable() {
val indexUrl = varchar("index_url", 2048).uniqueIndex()
val name = varchar("name", 256)
val badgeLabel = varchar("badge_label", 32)
val signingKey = varchar("signing_key", 512)
val contactWebsite = varchar("contact_website", 2048)
val contactDiscord = varchar("contact_discord", 2048).nullable()
val isLegacy = bool("is_legacy").default(false)
val extensionListUrl = varchar("extension_list_url", 2048).nullable()
}

View File

@@ -10,8 +10,8 @@ package suwayomi.tachidesk.manga.model.table
import org.jetbrains.exposed.v1.core.dao.id.IntIdTable
object ExtensionTable : IntIdTable() {
val apkName = varchar("apk_name", 1024)
val repo = varchar("repo", 1024).nullable()
val apkName = varchar("apk_name", 1024).nullable()
val storeIndexUrl = varchar("store_index_url", 2048).nullable().index()
// default is the local source icon from tachiyomi
@Suppress("ktlint:standard:max-line-length")
@@ -23,10 +23,12 @@ object ExtensionTable : IntIdTable() {
val name = varchar("name", 128)
val pkgName = varchar("pkg_name", 128)
val apkUrl = varchar("apk_url", 2048)
val extensionLib = varchar("extension_lib", 16).nullable()
val versionName = varchar("version_name", 16)
val versionCode = integer("version_code")
val versionCode = long("version_code")
val lang = varchar("lang", 32)
val isNsfw = bool("is_nsfw")
val contentWarning = integer("content_warning")
val isInstalled = bool("is_installed").default(false)
val hasUpdate = bool("has_update").default(false)

View File

@@ -9,6 +9,7 @@ package suwayomi.tachidesk.manga.model.table
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.model.UpdateStrategy
import kotlinx.serialization.json.Json
import org.jetbrains.exposed.v1.core.ResultRow
import org.jetbrains.exposed.v1.core.dao.id.IntIdTable
import suwayomi.tachidesk.manga.impl.MangaList.proxyThumbnailUrl
@@ -48,6 +49,7 @@ object MangaTable : IntIdTable() {
val lastModifiedAt = long("last_modified_at").default(0)
val version = long("version").default(0)
val isSyncing = bool("is_syncing").default(false)
val memo = unlimitedVarchar("memo")
}
fun MangaTable.toDataClass(mangaEntry: ResultRow) =
@@ -72,6 +74,7 @@ fun MangaTable.toDataClass(mangaEntry: ResultRow) =
updateStrategy = UpdateStrategy.valueOf(mangaEntry[updateStrategy]),
lastModifiedAt = mangaEntry[lastModifiedAt],
version = mangaEntry[version],
memo = Json.decodeFromString(mangaEntry[memo]),
)
enum class MangaStatus(

View File

@@ -14,5 +14,5 @@ object SourceTable : IdTable<Long>() {
val name = varchar("name", 128)
val lang = varchar("lang", 32)
val extension = reference("extension", ExtensionTable)
val isNsfw = bool("is_nsfw").default(false)
val contentWarning = integer("content_warning").default(0)
}

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
@@ -657,8 +657,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

@@ -9,7 +9,6 @@ import org.jetbrains.exposed.v1.core.SortOrder
import org.jetbrains.exposed.v1.core.alias
import org.jetbrains.exposed.v1.core.and
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.core.greater
import org.jetbrains.exposed.v1.core.inList
import org.jetbrains.exposed.v1.core.inSubQuery
import org.jetbrains.exposed.v1.core.intLiteral

View File

@@ -4,7 +4,6 @@ import dev.icerock.moko.resources.StringResource
import org.jetbrains.exposed.v1.core.JoinType
import org.jetbrains.exposed.v1.core.SortOrder
import org.jetbrains.exposed.v1.core.alias
import org.jetbrains.exposed.v1.core.count
import org.jetbrains.exposed.v1.core.countDistinct
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.jdbc.select
@@ -138,9 +137,9 @@ object NavigationRepository {
val query =
SourceTable
.join(ExtensionTable, JoinType.LEFT, onColumn = SourceTable.extension, otherColumn = ExtensionTable.id)
.select(SourceTable.id, SourceTable.name, SourceTable.lang, ExtensionTable.apkName)
.select(SourceTable.id, SourceTable.name, SourceTable.lang, ExtensionTable.pkgName)
.where { ExtensionTable.isInstalled eq true }
.groupBy(SourceTable.id, SourceTable.name, SourceTable.lang, ExtensionTable.apkName)
.groupBy(SourceTable.id, SourceTable.name, SourceTable.lang, ExtensionTable.pkgName)
.orderBy(SourceTable.name to SortOrder.ASC)
val totalCount = query.count()
@@ -152,7 +151,7 @@ object NavigationRepository {
OpdsSourceNavEntry(
id = it[SourceTable.id].value,
name = formatSourceName(it[SourceTable.name], it[SourceTable.lang]),
iconUrl = it[ExtensionTable.apkName].let { apkName -> Extension.getExtensionIconUrl(apkName) },
iconUrl = it[ExtensionTable.pkgName].let { pkgName -> Extension.proxyExtensionIconUrl(pkgName) },
mangaCount = null,
)
}
@@ -177,13 +176,13 @@ object NavigationRepository {
val query =
baseJoin
.select(SourceTable.id, SourceTable.name, SourceTable.lang, ExtensionTable.apkName, mangaCount)
.select(SourceTable.id, SourceTable.name, SourceTable.lang, ExtensionTable.pkgName, mangaCount)
.where { MangaTable.inLibrary eq true }
query.applyOpdsMangaFilter(activeFilters, excludeField = "source_id")
query
.groupBy(SourceTable.id, SourceTable.name, SourceTable.lang, ExtensionTable.apkName)
.groupBy(SourceTable.id, SourceTable.name, SourceTable.lang, ExtensionTable.pkgName)
.orderBy(SourceTable.name to SortOrder.ASC)
val totalCount = query.count()
@@ -199,7 +198,7 @@ object NavigationRepository {
OpdsSourceNavEntry(
id = it[SourceTable.id].value,
name = formatSourceName(it[SourceTable.name], it[SourceTable.lang]),
iconUrl = it[ExtensionTable.apkName].let { apkName -> Extension.getExtensionIconUrl(apkName) },
iconUrl = it[ExtensionTable.pkgName].let { pkgName -> Extension.proxyExtensionIconUrl(pkgName) },
mangaCount = it[mangaCount],
)
}
@@ -210,12 +209,12 @@ object NavigationRepository {
transaction {
SourceTable
.join(ExtensionTable, JoinType.LEFT, onColumn = SourceTable.extension, otherColumn = ExtensionTable.id)
.select(SourceTable.name, SourceTable.lang, ExtensionTable.apkName)
.select(SourceTable.name, SourceTable.lang, ExtensionTable.pkgName)
.where { SourceTable.id eq sourceId }
.firstOrNull()
?.let {
val name = formatSourceName(it[SourceTable.name], it[SourceTable.lang])
val icon = Extension.getExtensionIconUrl(it[ExtensionTable.apkName])
val icon = Extension.proxyExtensionIconUrl(it[ExtensionTable.pkgName])
Pair(name, icon)
}
}

View File

@@ -41,6 +41,7 @@ import suwayomi.tachidesk.graphql.types.DatabaseType
import suwayomi.tachidesk.i18n.LocalizationHelper
import suwayomi.tachidesk.manga.impl.backup.proto.ProtoBackupExport
import suwayomi.tachidesk.manga.impl.download.DownloadManager
import suwayomi.tachidesk.manga.impl.extension.ExtensionStoreService
import suwayomi.tachidesk.manga.impl.update.IUpdater
import suwayomi.tachidesk.manga.impl.update.Updater
import suwayomi.tachidesk.manga.impl.util.lang.renameTo
@@ -519,4 +520,12 @@ fun applicationSetup() {
GlobalScope.launch {
CEFManager.init()
}
serverConfig.subscribeTo(
serverConfig.extensionStores,
{ _ ->
ExtensionStoreService.syncPrefsToDb()
},
ignoreInitialValue = false,
)
}

View File

@@ -12,6 +12,7 @@ import com.zaxxer.hikari.HikariDataSource
import de.neonew.exposed.migrations.loadMigrationsFrom
import de.neonew.exposed.migrations.runMigrations
import io.github.oshai.kotlinlogging.KotlinLogging
import kotlinx.serialization.json.Json
import org.jetbrains.exposed.v1.core.DatabaseConfig
import org.jetbrains.exposed.v1.core.ExperimentalKeywordApi
import org.jetbrains.exposed.v1.core.Schema
@@ -140,6 +141,8 @@ object DBManager {
"Idle: ${ds.hikariPoolMXBean.idleConnections}, " +
"Waiting: ${ds.hikariPoolMXBean.threadsAwaitingConnection}"
}
val format = Json { prettyPrint = false }
}
private val logger = KotlinLogging.logger {}

View File

@@ -0,0 +1,89 @@
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.SQLMigration
import suwayomi.tachidesk.graphql.types.DatabaseType
import suwayomi.tachidesk.manga.model.dataclass.ContentWarning
import suwayomi.tachidesk.server.database.migration.helpers.MAYBE_TYPE_PREFIX
import suwayomi.tachidesk.server.database.migration.helpers.UNLIMITED_TEXT
import suwayomi.tachidesk.server.database.migration.helpers.toSqlName
import suwayomi.tachidesk.server.serverConfig
@Suppress("ClassName", "unused")
class M0057_AddNewExtensionApiFields : SQLMigration() {
fun postgresRename(): String =
"ALTER TABLE EXTENSION RENAME COLUMN " + "repo".toSqlName() + " TO " + "store_index_url".toSqlName() + ";"
fun h2Rename(): String =
"ALTER TABLE EXTENSION ALTER COLUMN " + "repo".toSqlName() + " RENAME TO " + "store_index_url".toSqlName() + ";"
override val sql by lazy {
"""
ALTER TABLE manga ADD COLUMN memo $UNLIMITED_TEXT DEFAULT '{}' NOT NULL;
ALTER TABLE chapter ADD COLUMN memo $UNLIMITED_TEXT DEFAULT '{}' NOT NULL;
${
when (serverConfig.databaseType.value) {
DatabaseType.POSTGRESQL -> postgresRename()
DatabaseType.H2 -> h2Rename()
}
}
ALTER TABLE EXTENSION ALTER COLUMN store_index_url ${MAYBE_TYPE_PREFIX}VARCHAR(2048);
CREATE INDEX extension_store_index_url ON EXTENSION (store_index_url);
ALTER TABLE EXTENSION ALTER COLUMN version_code ${MAYBE_TYPE_PREFIX}BIGINT;
ALTER TABLE EXTENSION ALTER COLUMN apk_name DROP NOT NULL;
${
when (serverConfig.databaseType.value) {
DatabaseType.POSTGRESQL -> postgresBackfill()
DatabaseType.H2 -> h2Backfill()
}
}
ALTER TABLE EXTENSION ADD COLUMN apk_url VARCHAR(2048);
ALTER TABLE EXTENSION ADD COLUMN content_warning INTEGER DEFAULT 0;
UPDATE EXTENSION SET content_warning = ${ContentWarning.MIXED.ordinal} WHERE is_nsfw = TRUE;
ALTER TABLE EXTENSION DROP COLUMN is_nsfw;
ALTER TABLE SOURCE ADD COLUMN content_warning INTEGER DEFAULT 0;
UPDATE SOURCE SET content_warning = ${ContentWarning.MIXED.ordinal} WHERE is_nsfw = TRUE;
ALTER TABLE SOURCE DROP COLUMN is_nsfw;
""".trimIndent()
}
fun postgresBackfill() =
"""
-- 1. Add the column as nullable to avoid table locks
ALTER TABLE EXTENSION ADD COLUMN extension_lib VARCHAR(16);
-- 2. Backfill existing rows using the first two parts of the version_name (split by dot)
UPDATE EXTENSION
SET extension_lib = CONCAT(
SPLIT_PART(version_name, '.', 1),
'.',
SPLIT_PART(version_name, '.', 2)
);
-- 3. Enforce the NOT NULL constraint
ALTER TABLE EXTENSION ALTER COLUMN extension_lib SET NOT NULL;
""".trimIndent()
fun h2Backfill() =
"""
-- 1. Add the column as nullable
ALTER TABLE EXTENSION ADD COLUMN extension_lib VARCHAR(16);
-- 2. Backfill rows by extracting text up to the second dot
UPDATE EXTENSION
SET extension_lib = CASE
-- If there's a second dot (e.g. 1.2.3), grab everything before it
WHEN LOCATE('.', version_name, LOCATE('.', version_name) + 1) > 0
THEN SUBSTRING(version_name, 1, LOCATE('.', version_name, LOCATE('.', version_name) + 1) - 1)
-- If there's no second dot (e.g. 1.2), keep the original value
ELSE version_name
END;
-- 3. Enforce the NOT NULL constraint
ALTER TABLE EXTENSION ALTER COLUMN extension_lib SET NOT NULL;
""".trimIndent()
}

View File

@@ -0,0 +1,32 @@
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.AddTableMigration
import org.jetbrains.exposed.v1.core.Table
import org.jetbrains.exposed.v1.core.dao.id.IntIdTable
@Suppress("ClassName", "unused")
class M0058_AddExtensionStore : AddTableMigration() {
private class ExtensionStoreTable : IntIdTable() {
val indexUrl = varchar("index_url", 2048).uniqueIndex()
val name = varchar("name", 256)
val badgeLabel = varchar("badge_label", 32)
val signingKey = varchar("signing_key", 512)
val contactWebsite = varchar("contact_website", 2048)
val contactDiscord = varchar("contact_discord", 2048).nullable()
val isLegacy = bool("is_legacy").default(false)
val extensionListUrl = varchar("extension_list_url", 2048).nullable()
}
override val tables: Array<Table>
get() =
arrayOf(
ExtensionStoreTable(),
)
}