Implement extension store

This commit is contained in:
Syer10
2026-06-16 22:38:03 -04:00
parent 85fe9802e2
commit 41ef220a0b
44 changed files with 1338 additions and 381 deletions

View File

@@ -276,30 +276,38 @@ class ServerConfig(
description = "Ignore re-uploaded chapters from auto-download", description = "Ignore re-uploaded chapters from auto-download",
) )
val extensionRepos: MutableStateFlow<List<String>> by ListSetting<String>( @Deprecated("Will get removed", replaceWith = ReplaceWith("extensionStores"))
val extensionRepos: MutableStateFlow<List<String>> by MigratedConfigValue(
protoNumber = 22, protoNumber = 22,
group = SettingGroup.EXTENSION, group = SettingGroup.EXTENSION,
privacySafe = false, privacySafe = false,
defaultValue = emptyList(), defaultValue = emptyList(),
itemValidator = { url -> deprecated =
if (url.matches(repoMatchRegex)) { SettingsRegistry.SettingDeprecated(
null replaceWith = "extensionStores",
} else { message = "Replaced with extensionStores",
"Invalid repository URL format" migrateConfigValue = {
} (it.unwrapped() as? List<String>)
}, ?.map {
itemToValidValue = { url -> if (it.contains("github")) {
if (url.matches(repoMatchRegex)) { it.replace(repoMatchRegex) {
url "https://raw.githubusercontent.com/${it.groupValues[2]}/${it.groupValues[3]}/" +
} else { (it.groupValues.getOrNull(4)?.ifBlank { null } ?: "repo") +
null "/" +
} (it.groupValues.getOrNull(5)?.ifBlank { null } ?: "index.min.json")
}, }
} else {
it
}
}
},
),
readMigrated = { extensionStores.value },
setMigrated = { extensionStores.value = it },
typeInfo = typeInfo =
SettingsRegistry.PartialTypeInfo( SettingsRegistry.PartialTypeInfo(
specificType = "List<String>", specificType = "List<String>",
), ),
description = "example: [\"https://github.com/MY_ACCOUNT/MY_REPO/tree/repo\"]",
) )
val maxSourcesInParallel: MutableStateFlow<Int> by IntSetting( val maxSourcesInParallel: MutableStateFlow<Int> by IntSetting(
@@ -1104,7 +1112,32 @@ class ServerConfig(
privacySafe = true, privacySafe = true,
defaultValue = false, defaultValue = false,
description = "Skips the metadata feed and provides download/stream links directly in the chapter list. Improves compatibility with KOReader auto-downloader. KoSync strategies are applied, but PROMPT conflicts are ignored (treating local progress as priority)." description = "Skips the metadata feed and provides download/stream links directly in the chapter list. Improves compatibility with KOReader auto-downloader. KoSync strategies are applied, but PROMPT conflicts are ignored (treating local progress as priority)."
)
val extensionStores: MutableStateFlow<List<String>> by ListSetting<String>(
protoNumber = 97,
group = SettingGroup.EXTENSION,
privacySafe = false,
defaultValue = emptyList(),
itemValidator = { url ->
if (url.isNotEmpty()) {
null
} else {
"Invalid store URL format"
}
},
itemToValidValue = { url ->
if (url.isNotEmpty()) {
url
} else {
null
}
},
typeInfo =
SettingsRegistry.PartialTypeInfo(
specificType = "List<String>",
),
description = "List of extension store index URLs",
) )
/** ****************************************************************** **/ /** ****************************************************************** **/

View File

@@ -175,11 +175,12 @@ class LocalSource(
chapters: List<SChapter>, chapters: List<SChapter>,
fetchDetails: Boolean, fetchDetails: Boolean,
fetchChapters: Boolean, fetchChapters: Boolean,
): SMangaUpdate = supervisorScope { ): SMangaUpdate =
val asyncManga = if (fetchDetails) async { getMangaDetails(manga) } else null supervisorScope {
val asyncChapters = if (fetchChapters) async { getChapterList(manga) } else null val asyncManga = if (fetchDetails) async { getMangaDetails(manga) } else null
SMangaUpdate(asyncManga?.await() ?: manga, asyncChapters?.await() ?: chapters) val asyncChapters = if (fetchChapters) async { getChapterList(manga) } else null
} SMangaUpdate(asyncManga?.await() ?: manga, asyncChapters?.await() ?: chapters)
}
// Manga details related // Manga details related
private suspend fun getMangaDetails(manga: SManga): SManga = private suspend fun getMangaDetails(manga: SManga): SManga =
@@ -481,7 +482,8 @@ class LocalSource(
it[versionName] = "1.2" it[versionName] = "1.2"
it[versionCode] = 0 it[versionCode] = 0
it[lang] = LANG it[lang] = LANG
it[isNsfw] = false it[extensionLib] = "1.2"
it[contentRating] = 0
it[isInstalled] = true it[isInstalled] = true
} }
@@ -490,7 +492,6 @@ class LocalSource(
it[name] = NAME it[name] = NAME
it[lang] = LANG it[lang] = LANG
it[extension] = extensionId it[extension] = extensionId
it[isNsfw] = false
} }
} }
} }

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 ExtensionStoreForExtension : KotlinDataLoader<String, ExtensionStoreType> {
override val dataLoaderName = "ExtensionStoreForExtension"
override fun getDataLoader(graphQLContext: GraphQLContext): DataLoader<String, ExtensionStoreType> =
DataLoaderFactory.newDataLoader<String, ExtensionStoreType> { 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 ExtensionForExtensionStore : KotlinDataLoader<String, ExtensionNodeList> {
override val dataLoaderName = "ExtensionForExtensionStore"
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

@@ -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 suwayomi.tachidesk.server.serverConfig
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)
serverConfig.extensionStores.value = (serverConfig.extensionStores.value + indexUrl).distinct()
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 }
}
}
serverConfig.extensionStores.value = serverConfig.extensionStores.value.filterNot { it == indexUrl }
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,
)
},
)
}
}
}

View File

@@ -190,12 +190,13 @@ class MangaMutation {
var mangaEntry = var mangaEntry =
transaction { MangaTable.selectAll().where { MangaTable.id eq id }.first() } transaction { MangaTable.selectAll().where { MangaTable.id eq id }.first() }
val source = getCatalogueSourceOrStub(mangaEntry[MangaTable.sourceReference]) val source = getCatalogueSourceOrStub(mangaEntry[MangaTable.sourceReference])
val sMangaUpdate = Manga.fetchMangaAndChapters( val sMangaUpdate =
mangaEntry = mangaEntry, Manga.fetchMangaAndChapters(
source = source, mangaEntry = mangaEntry,
fetchDetails = fetchManga, source = source,
fetchChapters = fetchChapters fetchDetails = fetchManga,
) fetchChapters = fetchChapters,
)
Manga.updateMangaDatabase(mangaEntry, source, sMangaUpdate.manga) Manga.updateMangaDatabase(mangaEntry, source, sMangaUpdate.manga)
mangaEntry = transaction { MangaTable.selectAll().where { MangaTable.id eq id }.first() } mangaEntry = transaction { MangaTable.selectAll().where { MangaTable.id eq id }.first() }
@@ -209,9 +210,8 @@ class MangaMutation {
.selectAll() .selectAll()
.where { ChapterTable.manga eq id } .where { ChapterTable.manga eq id }
.orderBy(ChapterTable.sourceOrder) .orderBy(ChapterTable.sourceOrder)
.map { ChapterType(it) } .map { ChapterType(it) },
) )
} }
FetchMangaAndChaptersPayload( FetchMangaAndChaptersPayload(
clientMutationId = clientMutationId, clientMutationId = clientMutationId,

View File

@@ -24,6 +24,7 @@ import suwayomi.tachidesk.graphql.queries.filter.BooleanFilter
import suwayomi.tachidesk.graphql.queries.filter.Filter import suwayomi.tachidesk.graphql.queries.filter.Filter
import suwayomi.tachidesk.graphql.queries.filter.HasGetOp import suwayomi.tachidesk.graphql.queries.filter.HasGetOp
import suwayomi.tachidesk.graphql.queries.filter.IntFilter 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.OpAnd
import suwayomi.tachidesk.graphql.queries.filter.StringFilter import suwayomi.tachidesk.graphql.queries.filter.StringFilter
import suwayomi.tachidesk.graphql.queries.filter.andFilterWithCompare import suwayomi.tachidesk.graphql.queries.filter.andFilterWithCompare
@@ -38,6 +39,7 @@ import suwayomi.tachidesk.graphql.server.primitives.applyBeforeAfter
import suwayomi.tachidesk.graphql.server.primitives.greaterNotUnique import suwayomi.tachidesk.graphql.server.primitives.greaterNotUnique
import suwayomi.tachidesk.graphql.server.primitives.lessNotUnique import suwayomi.tachidesk.graphql.server.primitives.lessNotUnique
import suwayomi.tachidesk.graphql.server.primitives.maybeSwap import suwayomi.tachidesk.graphql.server.primitives.maybeSwap
import suwayomi.tachidesk.graphql.types.ContentRating
import suwayomi.tachidesk.graphql.types.ExtensionNodeList import suwayomi.tachidesk.graphql.types.ExtensionNodeList
import suwayomi.tachidesk.graphql.types.ExtensionType import suwayomi.tachidesk.graphql.types.ExtensionType
import suwayomi.tachidesk.manga.model.table.ExtensionTable import suwayomi.tachidesk.manga.model.table.ExtensionTable
@@ -55,21 +57,23 @@ class ExtensionQuery {
) : OrderBy<ExtensionType> { ) : OrderBy<ExtensionType> {
PKG_NAME(ExtensionTable.pkgName), PKG_NAME(ExtensionTable.pkgName),
NAME(ExtensionTable.name), NAME(ExtensionTable.name),
APK_NAME(ExtensionTable.apkName),
@GraphQLDeprecated("")
APK_NAME(ExtensionTable.pkgName),
; ;
override fun greater(cursor: Cursor): Op<Boolean> = override fun greater(cursor: Cursor): Op<Boolean> =
when (this) { when (this) {
PKG_NAME -> ExtensionTable.pkgName greater cursor.value PKG_NAME -> ExtensionTable.pkgName greater cursor.value
NAME -> greaterNotUnique(ExtensionTable.name, ExtensionTable.pkgName, cursor, String::toString) 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> = override fun less(cursor: Cursor): Op<Boolean> =
when (this) { when (this) {
PKG_NAME -> ExtensionTable.pkgName less cursor.value PKG_NAME -> ExtensionTable.pkgName less cursor.value
NAME -> lessNotUnique(ExtensionTable.name, ExtensionTable.pkgName, cursor, String::toString) 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 { override fun asCursor(type: ExtensionType): Cursor {
@@ -89,29 +93,41 @@ class ExtensionQuery {
) : Order<ExtensionOrderBy> ) : Order<ExtensionOrderBy>
data class ExtensionCondition( data class ExtensionCondition(
val storeIndexUrl: String? = null,
@GraphQLDeprecated("", ReplaceWith("storeIndexUrl"))
val repo: String? = null, val repo: String? = null,
val apkName: String? = null, val apkName: String? = null,
val iconUrl: String? = null, val iconUrl: String? = null,
val name: String? = null, val name: String? = null,
val pkgName: String? = null, val pkgName: String? = null,
val apkUrl: String? = null,
val extensionLib: String? = null,
val versionName: String? = null, val versionName: String? = null,
val versionCode: Int? = null, val versionCode: Int? = null,
val versionCodeLong: Long? = null,
val lang: String? = null, val lang: String? = null,
@GraphQLDeprecated("", ReplaceWith("contentRating"))
val isNsfw: Boolean? = null, val isNsfw: Boolean? = null,
val contentRating: ContentRating? = null,
val isInstalled: Boolean? = null, val isInstalled: Boolean? = null,
val hasUpdate: Boolean? = null, val hasUpdate: Boolean? = null,
val isObsolete: Boolean? = null, val isObsolete: Boolean? = null,
) : HasGetOp { ) : HasGetOp {
override fun getOp(): Op<Boolean>? { override fun getOp(): Op<Boolean>? {
val opAnd = OpAnd() 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(apkName, ExtensionTable.apkName)
opAnd.eq(iconUrl, ExtensionTable.iconUrl) opAnd.eq(iconUrl, ExtensionTable.iconUrl)
opAnd.eq(apkUrl, ExtensionTable.apkUrl)
opAnd.eq(name, ExtensionTable.name) opAnd.eq(name, ExtensionTable.name)
opAnd.eq(extensionLib, ExtensionTable.extensionLib)
opAnd.eq(versionName, ExtensionTable.versionName) 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(lang, ExtensionTable.lang)
opAnd.eq(isNsfw, ExtensionTable.isNsfw) opAnd.eq(isNsfw?.let { if (it) 3 else 0 }, ExtensionTable.contentRating)
opAnd.eq(contentRating?.ordinal, ExtensionTable.contentRating)
opAnd.eq(isInstalled, ExtensionTable.isInstalled) opAnd.eq(isInstalled, ExtensionTable.isInstalled)
opAnd.eq(hasUpdate, ExtensionTable.hasUpdate) opAnd.eq(hasUpdate, ExtensionTable.hasUpdate)
opAnd.eq(isObsolete, ExtensionTable.isObsolete) opAnd.eq(isObsolete, ExtensionTable.isObsolete)
@@ -121,15 +137,23 @@ class ExtensionQuery {
} }
data class ExtensionFilter( data class ExtensionFilter(
val storeIndexUrl: StringFilter? = null,
@GraphQLDeprecated("", ReplaceWith("storeIndexUrl"))
val repo: StringFilter? = null, val repo: StringFilter? = null,
val apkName: StringFilter? = null, val apkName: StringFilter? = null,
val iconUrl: StringFilter? = null, val iconUrl: StringFilter? = null,
val name: StringFilter? = null, val name: StringFilter? = null,
val pkgName: StringFilter? = null, val pkgName: StringFilter? = null,
val apkUrl: StringFilter? = null,
val versionName: StringFilter? = null, val versionName: StringFilter? = null,
val extensionLib: StringFilter? = null,
@GraphQLDeprecated("", ReplaceWith("versionCodeLong"))
val versionCode: IntFilter? = null, val versionCode: IntFilter? = null,
val versionCodeLong: LongFilter? = null,
val lang: StringFilter? = null, val lang: StringFilter? = null,
@GraphQLDeprecated("", ReplaceWith("storeIndexUrl"))
val isNsfw: BooleanFilter? = null, val isNsfw: BooleanFilter? = null,
// val contentRating: EnumFilter<ContentRating>? = null,
val isInstalled: BooleanFilter? = null, val isInstalled: BooleanFilter? = null,
val hasUpdate: BooleanFilter? = null, val hasUpdate: BooleanFilter? = null,
val isObsolete: BooleanFilter? = null, val isObsolete: BooleanFilter? = null,
@@ -139,15 +163,18 @@ class ExtensionQuery {
) : Filter<ExtensionFilter> { ) : Filter<ExtensionFilter> {
override fun getOpList(): List<Op<Boolean>> = override fun getOpList(): List<Op<Boolean>> =
listOfNotNull( listOfNotNull(
andFilterWithCompareString(ExtensionTable.repo, repo), andFilterWithCompareString(ExtensionTable.storeIndexUrl, storeIndexUrl),
andFilterWithCompareString(ExtensionTable.storeIndexUrl, repo),
andFilterWithCompareString(ExtensionTable.apkName, apkName), andFilterWithCompareString(ExtensionTable.apkName, apkName),
andFilterWithCompareString(ExtensionTable.iconUrl, iconUrl), andFilterWithCompareString(ExtensionTable.iconUrl, iconUrl),
andFilterWithCompareString(ExtensionTable.name, name), andFilterWithCompareString(ExtensionTable.name, name),
andFilterWithCompareString(ExtensionTable.pkgName, pkgName), andFilterWithCompareString(ExtensionTable.pkgName, pkgName),
andFilterWithCompareString(ExtensionTable.apkUrl, apkUrl),
andFilterWithCompareString(ExtensionTable.extensionLib, extensionLib),
andFilterWithCompareString(ExtensionTable.versionName, versionName), andFilterWithCompareString(ExtensionTable.versionName, versionName),
andFilterWithCompare(ExtensionTable.versionCode, versionCode), andFilterWithCompare(ExtensionTable.versionCode, versionCodeLong),
andFilterWithCompareString(ExtensionTable.lang, lang), andFilterWithCompareString(ExtensionTable.lang, lang),
andFilterWithCompare(ExtensionTable.isNsfw, isNsfw), // andFilterWithCompareEnum(ExtensionTable.contentRating, contentRating),
andFilterWithCompare(ExtensionTable.isInstalled, isInstalled), andFilterWithCompare(ExtensionTable.isInstalled, isInstalled),
andFilterWithCompare(ExtensionTable.hasUpdate, hasUpdate), andFilterWithCompare(ExtensionTable.hasUpdate, hasUpdate),
andFilterWithCompare(ExtensionTable.isObsolete, isObsolete), andFilterWithCompare(ExtensionTable.isObsolete, isObsolete),

View File

@@ -0,0 +1,39 @@
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 org.jetbrains.exposed.v1.core.eq
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.model.table.ExtensionStoreTable
import java.util.concurrent.CompletableFuture
class ExtensionStoreQuery {
@RequireAuth
fun extensionStore(indexUrl: String): CompletableFuture<ExtensionStoreType?> =
CompletableFuture.supplyAsync {
transaction {
ExtensionStoreTable
.selectAll()
.where { ExtensionStoreTable.indexUrl eq indexUrl }
.firstOrNull()
?.let { ExtensionStoreType(it) }
}
}
@RequireAuth
fun extensionStores(): List<ExtensionStoreType> =
transaction {
ExtensionStoreTable
.selectAll()
.toList()
.map { ExtensionStoreType(it) }
}
}

View File

@@ -13,7 +13,9 @@ import graphql.schema.DataFetchingEnvironment
import org.jetbrains.exposed.v1.core.Column import org.jetbrains.exposed.v1.core.Column
import org.jetbrains.exposed.v1.core.Op import org.jetbrains.exposed.v1.core.Op
import org.jetbrains.exposed.v1.core.SortOrder 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.greater
import org.jetbrains.exposed.v1.core.greaterEq
import org.jetbrains.exposed.v1.core.less import org.jetbrains.exposed.v1.core.less
import org.jetbrains.exposed.v1.jdbc.selectAll import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.transactions.transaction import org.jetbrains.exposed.v1.jdbc.transactions.transaction
@@ -24,7 +26,6 @@ import suwayomi.tachidesk.graphql.queries.filter.HasGetOp
import suwayomi.tachidesk.graphql.queries.filter.LongFilter import suwayomi.tachidesk.graphql.queries.filter.LongFilter
import suwayomi.tachidesk.graphql.queries.filter.OpAnd import suwayomi.tachidesk.graphql.queries.filter.OpAnd
import suwayomi.tachidesk.graphql.queries.filter.StringFilter 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.andFilterWithCompareEntity
import suwayomi.tachidesk.graphql.queries.filter.andFilterWithCompareString import suwayomi.tachidesk.graphql.queries.filter.andFilterWithCompareString
import suwayomi.tachidesk.graphql.queries.filter.applyOps import suwayomi.tachidesk.graphql.queries.filter.applyOps
@@ -37,6 +38,7 @@ import suwayomi.tachidesk.graphql.server.primitives.applyBeforeAfter
import suwayomi.tachidesk.graphql.server.primitives.greaterNotUnique import suwayomi.tachidesk.graphql.server.primitives.greaterNotUnique
import suwayomi.tachidesk.graphql.server.primitives.lessNotUnique import suwayomi.tachidesk.graphql.server.primitives.lessNotUnique
import suwayomi.tachidesk.graphql.server.primitives.maybeSwap import suwayomi.tachidesk.graphql.server.primitives.maybeSwap
import suwayomi.tachidesk.graphql.types.ContentRating
import suwayomi.tachidesk.graphql.types.SourceNodeList import suwayomi.tachidesk.graphql.types.SourceNodeList
import suwayomi.tachidesk.graphql.types.SourceType import suwayomi.tachidesk.graphql.types.SourceType
import suwayomi.tachidesk.manga.model.table.SourceTable import suwayomi.tachidesk.manga.model.table.SourceTable
@@ -91,14 +93,17 @@ class SourceQuery {
val id: Long? = null, val id: Long? = null,
val name: String? = null, val name: String? = null,
val lang: String? = null, val lang: String? = null,
@GraphQLDeprecated("replace with contentRating == 3", ReplaceWith("contentRating"))
val isNsfw: Boolean? = null, val isNsfw: Boolean? = null,
val contentRating: ContentRating? = null,
) : HasGetOp { ) : HasGetOp {
override fun getOp(): Op<Boolean>? { override fun getOp(): Op<Boolean>? {
val opAnd = OpAnd() val opAnd = OpAnd()
opAnd.eq(id, SourceTable.id) opAnd.eq(id, SourceTable.id)
opAnd.eq(name, SourceTable.name) opAnd.eq(name, SourceTable.name)
opAnd.eq(lang, SourceTable.lang) opAnd.eq(lang, SourceTable.lang)
opAnd.eq(isNsfw, SourceTable.isNsfw) opAnd.andWhere(isNsfw) { if (it) SourceTable.contentRating greaterEq 3 else SourceTable.contentRating less 3 }
opAnd.andWhere(contentRating) { SourceTable.contentRating eq it.getValue() }
return opAnd.op return opAnd.op
} }
@@ -108,7 +113,9 @@ class SourceQuery {
val id: LongFilter? = null, val id: LongFilter? = null,
val name: StringFilter? = null, val name: StringFilter? = null,
val lang: StringFilter? = null, val lang: StringFilter? = null,
@GraphQLDeprecated("replace with contentRating == 3", ReplaceWith("contentRating"))
val isNsfw: BooleanFilter? = null, val isNsfw: BooleanFilter? = null,
// val contentRating: EnumFilter<ContentRating>? = null,
override val and: List<SourceFilter>? = null, override val and: List<SourceFilter>? = null,
override val or: List<SourceFilter>? = null, override val or: List<SourceFilter>? = null,
override val not: SourceFilter? = null, override val not: SourceFilter? = null,
@@ -118,7 +125,7 @@ class SourceQuery {
andFilterWithCompareEntity(SourceTable.id, id), andFilterWithCompareEntity(SourceTable.id, id),
andFilterWithCompareString(SourceTable.name, name), andFilterWithCompareString(SourceTable.name, name),
andFilterWithCompareString(SourceTable.lang, lang), andFilterWithCompareString(SourceTable.lang, lang),
andFilterWithCompare(SourceTable.isNsfw, isNsfw), // andFilterWithCompareEnum(SourceTable.contentRating, contentRating)
) )
} }

View File

@@ -329,6 +329,24 @@ data class DoubleFilter(
) )
} }
data class EnumFilter<T : Enum<T>>(
override val isNull: Boolean? = null,
override val equalTo: T? = null,
override val notEqualTo: T? = null,
override val notEqualToAll: List<T>? = null,
override val notEqualToAny: List<T>? = null,
override val distinctFrom: T? = null,
override val distinctFromAll: List<T>? = null,
override val distinctFromAny: List<T>? = null,
override val notDistinctFrom: T? = null,
override val `in`: List<T>? = null,
override val notIn: List<T>? = null,
override val lessThan: T? = null,
override val lessThanOrEqualTo: T? = null,
override val greaterThan: T? = null,
override val greaterThanOrEqualTo: T? = null,
) : ComparableScalarFilter<T>
data class StringFilter( data class StringFilter(
override val isNull: Boolean? = null, override val isNull: Boolean? = null,
override val equalTo: String? = null, override val equalTo: String? = null,
@@ -618,6 +636,35 @@ fun <T : Comparable<T>, S : T?> andFilterWithCompare(
return opAnd.op 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( fun <T : Comparable<T>> andFilterWithCompareEntity(
column: Column<EntityID<T>>, column: Column<EntityID<T>>,
filter: ComparableScalarFilter<T>?, filter: ComparableScalarFilter<T>?,

View File

@@ -20,7 +20,9 @@ import suwayomi.tachidesk.graphql.dataLoaders.DisplayScoreForTrackRecordDataLoad
import suwayomi.tachidesk.graphql.dataLoaders.DisplayScoreForTrackSearchDataLoader import suwayomi.tachidesk.graphql.dataLoaders.DisplayScoreForTrackSearchDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.DownloadedChapterCountForMangaDataLoader import suwayomi.tachidesk.graphql.dataLoaders.DownloadedChapterCountForMangaDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.ExtensionDataLoader import suwayomi.tachidesk.graphql.dataLoaders.ExtensionDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.ExtensionForExtensionStore
import suwayomi.tachidesk.graphql.dataLoaders.ExtensionForSourceDataLoader import suwayomi.tachidesk.graphql.dataLoaders.ExtensionForSourceDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.ExtensionStoreForExtension
import suwayomi.tachidesk.graphql.dataLoaders.FirstUnreadChapterForMangaDataLoader import suwayomi.tachidesk.graphql.dataLoaders.FirstUnreadChapterForMangaDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.GlobalMetaDataLoader import suwayomi.tachidesk.graphql.dataLoaders.GlobalMetaDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.HasDuplicateChaptersForMangaDataLoader import suwayomi.tachidesk.graphql.dataLoaders.HasDuplicateChaptersForMangaDataLoader
@@ -78,6 +80,8 @@ class TachideskDataLoaderRegistryFactory {
SourceMetaDataLoader(), SourceMetaDataLoader(),
ExtensionDataLoader(), ExtensionDataLoader(),
ExtensionForSourceDataLoader(), ExtensionForSourceDataLoader(),
ExtensionForExtensionStore(),
ExtensionStoreForExtension(),
TrackerDataLoader(), TrackerDataLoader(),
TrackerStatusesDataLoader(), TrackerStatusesDataLoader(),
TrackerScoresDataLoader(), TrackerScoresDataLoader(),

View File

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

View File

@@ -0,0 +1,38 @@
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.Node
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,
) : 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],
)
fun extension(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture<ExtensionNodeList> =
dataFetchingEnvironment.getValueFromDataLoader<String, ExtensionNodeList>("ExtensionForExtensionStore", indexUrl)
}

View File

@@ -7,6 +7,8 @@
package suwayomi.tachidesk.graphql.types 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 com.expediagroup.graphql.server.extensions.getValueFromDataLoader
import graphql.schema.DataFetchingEnvironment import graphql.schema.DataFetchingEnvironment
import org.jetbrains.exposed.v1.core.ResultRow import org.jetbrains.exposed.v1.core.ResultRow
@@ -20,29 +22,46 @@ import suwayomi.tachidesk.manga.model.table.ExtensionTable
import java.util.concurrent.CompletableFuture import java.util.concurrent.CompletableFuture
class ExtensionType( class ExtensionType(
val storeIndexUrl: String?,
@GraphQLDeprecated("Removed in extension api v1.6", ReplaceWith("storeIndexUrl"))
val repo: String?, val repo: String?,
val apkName: String, @GraphQLDescription("This will be nullable in the future")
val apkName: String?,
val iconUrl: String, val iconUrl: String,
val name: String, val name: String,
val pkgName: String, val pkgName: String,
val apkUrl: String?,
val extensionLib: String?,
val versionName: 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 versionCode: Int,
val versionCodeLong: Long,
val lang: String, val lang: String,
@GraphQLDeprecated("Removed in extension api v1.6", ReplaceWith("contentRating"))
val isNsfw: Boolean, val isNsfw: Boolean,
val contentRating: ContentRating,
val isInstalled: Boolean, val isInstalled: Boolean,
val hasUpdate: Boolean, val hasUpdate: Boolean,
val isObsolete: Boolean, val isObsolete: Boolean,
) : Node { ) : Node {
constructor(row: ResultRow) : this( constructor(row: ResultRow) : this(
repo = row[ExtensionTable.repo], storeIndexUrl = row[ExtensionTable.storeIndexUrl],
apkName = row[ExtensionTable.apkName], repo = row[ExtensionTable.storeIndexUrl],
iconUrl = Extension.getExtensionIconUrl(row[ExtensionTable.apkName]), apkName = row[ExtensionTable.apkName].orEmpty(),
iconUrl = Extension.proxyExtensionIconUrl(row[ExtensionTable.pkgName]),
name = row[ExtensionTable.name], name = row[ExtensionTable.name],
pkgName = row[ExtensionTable.pkgName], pkgName = row[ExtensionTable.pkgName],
apkUrl = row[ExtensionTable.apkUrl],
extensionLib = row[ExtensionTable.extensionLib],
versionName = row[ExtensionTable.versionName], versionName = row[ExtensionTable.versionName],
versionCode = row[ExtensionTable.versionCode], versionCode = row[ExtensionTable.versionCode].toInt(),
versionCodeLong = row[ExtensionTable.versionCode],
lang = row[ExtensionTable.lang], lang = row[ExtensionTable.lang],
isNsfw = row[ExtensionTable.isNsfw], isNsfw = row[ExtensionTable.contentRating] == 3,
contentRating = ContentRating.valueOf(row[ExtensionTable.contentRating]),
isInstalled = row[ExtensionTable.isInstalled], isInstalled = row[ExtensionTable.isInstalled],
hasUpdate = row[ExtensionTable.hasUpdate], hasUpdate = row[ExtensionTable.hasUpdate],
isObsolete = row[ExtensionTable.isObsolete], isObsolete = row[ExtensionTable.isObsolete],
@@ -50,6 +69,9 @@ class ExtensionType(
fun source(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture<SourceNodeList> = fun source(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture<SourceNodeList> =
dataFetchingEnvironment.getValueFromDataLoader<String, SourceNodeList>("SourcesForExtensionDataLoader", pkgName) dataFetchingEnvironment.getValueFromDataLoader<String, SourceNodeList>("SourcesForExtensionDataLoader", pkgName)
fun extensionStore(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture<ExtensionStoreType> =
dataFetchingEnvironment.getValueFromDataLoader<String, ExtensionStoreType>("ExtensionStoreForExtension", storeIndexUrl.orEmpty())
} }
data class ExtensionNodeList( data class ExtensionNodeList(

View File

@@ -7,6 +7,8 @@
package suwayomi.tachidesk.graphql.types package suwayomi.tachidesk.graphql.types
import com.expediagroup.graphql.generator.annotations.GraphQLDeprecated
import com.expediagroup.graphql.generator.annotations.GraphQLIgnore
import com.expediagroup.graphql.server.extensions.getValueFromDataLoader import com.expediagroup.graphql.server.extensions.getValueFromDataLoader
import eu.kanade.tachiyomi.source.CatalogueSource import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.ConfigurableSource import eu.kanade.tachiyomi.source.ConfigurableSource
@@ -25,7 +27,6 @@ import suwayomi.tachidesk.manga.impl.Source.getSourcePreferencesRaw
import suwayomi.tachidesk.manga.impl.extension.Extension import suwayomi.tachidesk.manga.impl.extension.Extension
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogueSourceOrStub import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogueSourceOrStub
import suwayomi.tachidesk.manga.model.dataclass.SourceDataClass
import suwayomi.tachidesk.manga.model.table.ExtensionTable import suwayomi.tachidesk.manga.model.table.ExtensionTable
import suwayomi.tachidesk.manga.model.table.SourceTable import suwayomi.tachidesk.manga.model.table.SourceTable
import java.util.concurrent.CompletableFuture import java.util.concurrent.CompletableFuture
@@ -41,35 +42,31 @@ class SourceType(
val id: Long, val id: Long,
val name: String, val name: String,
val lang: String, val lang: String,
val message: String?,
val contentRating: ContentRating,
val iconUrl: String, val iconUrl: String,
val supportsLatest: Boolean, val supportsLatest: Boolean,
val isConfigurable: Boolean, val isConfigurable: Boolean,
@GraphQLDeprecated("", ReplaceWith("contentRating"))
val isNsfw: Boolean, val isNsfw: Boolean,
val displayName: String, val displayName: String,
val homeUrl: String?,
@GraphQLDeprecated("", ReplaceWith("homeUrl"))
val baseUrl: String?, val baseUrl: String?,
) : Node { ) : 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( constructor(row: ResultRow, sourceExtension: ResultRow, catalogueSource: CatalogueSource) : this(
id = row[SourceTable.id].value, id = row[SourceTable.id].value,
name = row[SourceTable.name], name = row[SourceTable.name],
lang = row[SourceTable.lang], lang = row[SourceTable.lang],
iconUrl = Extension.getExtensionIconUrl(sourceExtension[ExtensionTable.apkName]), message = row[SourceTable.message],
contentRating = ContentRating.valueOf(row[SourceTable.contentRating]),
iconUrl = Extension.proxyExtensionIconUrl(sourceExtension[ExtensionTable.pkgName]),
supportsLatest = catalogueSource.supportsLatest, supportsLatest = catalogueSource.supportsLatest,
isConfigurable = catalogueSource is ConfigurableSource, isConfigurable = catalogueSource is ConfigurableSource,
isNsfw = row[SourceTable.isNsfw], isNsfw = row[SourceTable.contentRating] >= 3,
displayName = catalogueSource.toString(), 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> = fun manga(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture<MangaNodeList> =
@@ -510,3 +507,33 @@ fun preferenceOf(preference: SourcePreference): Preference =
throw RuntimeException("sealed class cannot have more subtypes!") throw RuntimeException("sealed class cannot have more subtypes!")
} }
} }
enum class ContentRating {
SAFE,
SUGGESTIVE,
EROTICA,
PORNOGRAPHIC,
;
@GraphQLIgnore
fun getValue() =
when (this) {
SAFE -> 0
SUGGESTIVE -> 1
EROTICA -> 2
PORNOGRAPHIC -> 3
}
@GraphQLIgnore
companion object {
@GraphQLIgnore
fun valueOf(contentRating: Int) =
when (contentRating) {
0 -> SAFE
1 -> SUGGESTIVE
2 -> EROTICA
3 -> PORNOGRAPHIC
else -> SAFE
}
}
}

View File

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

View File

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

View File

@@ -18,6 +18,7 @@ import io.github.oshai.kotlinlogging.KotlinLogging
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import org.jetbrains.exposed.v1.core.ResultRow import org.jetbrains.exposed.v1.core.ResultRow
import org.jetbrains.exposed.v1.core.SortOrder import org.jetbrains.exposed.v1.core.SortOrder
import org.jetbrains.exposed.v1.core.and import org.jetbrains.exposed.v1.core.and
@@ -114,17 +115,20 @@ object Chapter {
val mutex = Manga.mangaInfoMutex.get(mangaId) { Mutex() } val mutex = Manga.mangaInfoMutex.get(mangaId) { Mutex() }
val chapterList = val chapterList =
mutex.withLock { mutex.withLock {
val mangaEntry = transaction { val mangaEntry =
MangaTable.selectAll().where { MangaTable.id eq mangaId }.first() transaction {
} MangaTable.selectAll().where { MangaTable.id eq mangaId }.first()
}
val source = getCatalogueSourceOrStub(mangaEntry[MangaTable.sourceReference]) val source = getCatalogueSourceOrStub(mangaEntry[MangaTable.sourceReference])
val chapters = Manga.fetchMangaAndChapters( val chapters =
mangaEntry = mangaEntry, Manga
source = source, .fetchMangaAndChapters(
fetchDetails = false, mangaEntry = mangaEntry,
fetchChapters = true, source = source,
).chapters fetchDetails = false,
fetchChapters = true,
).chapters
updateChapterListDatabase(mangaEntry, chapters, source) updateChapterListDatabase(mangaEntry, chapters, source)
} }
@@ -150,22 +154,28 @@ object Chapter {
} }
// Recognize number for new chapters. // Recognize number for new chapters.
val sManga = SManga.create().apply { val sManga =
url = mangaEntry[MangaTable.url] SManga.create().apply {
title = mangaEntry[MangaTable.title] url = mangaEntry[MangaTable.url]
thumbnail_url = mangaEntry[MangaTable.thumbnail_url] title = mangaEntry[MangaTable.title]
artist = mangaEntry[MangaTable.artist] thumbnail_url = mangaEntry[MangaTable.thumbnail_url]
author = mangaEntry[MangaTable.author] artist = mangaEntry[MangaTable.artist]
description = mangaEntry[MangaTable.description] author = mangaEntry[MangaTable.author]
genre = mangaEntry[MangaTable.genre] description = mangaEntry[MangaTable.description]
status = mangaEntry[MangaTable.status] genre = mangaEntry[MangaTable.genre]
update_strategy = UpdateStrategy.valueOf(mangaEntry[MangaTable.updateStrategy]) status = mangaEntry[MangaTable.status]
memo = mangaEntry[MangaTable.memo] update_strategy = UpdateStrategy.valueOf(mangaEntry[MangaTable.updateStrategy])
initialized = mangaEntry[MangaTable.initialized] memo = Json.decodeFromString(mangaEntry[MangaTable.memo])
} initialized = mangaEntry[MangaTable.initialized]
}
uniqueChapters.forEach { chapter -> uniqueChapters.forEach { chapter ->
(source as? HttpSource)?.prepareNewChapter(chapter, sManga) (source as? HttpSource)?.prepareNewChapter(chapter, sManga)
val chapterNumber = ChapterRecognition.parseChapterNumber(mangaEntry[MangaTable.title], chapter.name, chapter.chapter_number.toDouble()) val chapterNumber =
ChapterRecognition.parseChapterNumber(
mangaEntry[MangaTable.title],
chapter.name,
chapter.chapter_number.toDouble(),
)
chapter.chapter_number = chapterNumber.toFloat() chapter.chapter_number = chapterNumber.toFloat()
chapter.name = chapter.name.sanitize(mangaEntry[MangaTable.title]) chapter.name = chapter.name.sanitize(mangaEntry[MangaTable.title])
chapter.scanlator = chapter.scanlator?.ifBlank { null }?.trim() chapter.scanlator = chapter.scanlator?.ifBlank { null }?.trim()
@@ -270,7 +280,7 @@ object Chapter {
this[ChapterTable.fetchedAt] = chapter.fetchedAt this[ChapterTable.fetchedAt] = chapter.fetchedAt
this[ChapterTable.manga] = chapter.mangaId this[ChapterTable.manga] = chapter.mangaId
this[ChapterTable.realUrl] = chapter.realUrl this[ChapterTable.realUrl] = chapter.realUrl
this[ChapterTable.memo] = chapter.memo this[ChapterTable.memo] = Json.encodeToString(chapter.memo)
this[ChapterTable.isRead] = false this[ChapterTable.isRead] = false
this[ChapterTable.isBookmarked] = false this[ChapterTable.isBookmarked] = false
this[ChapterTable.isDownloaded] = false this[ChapterTable.isDownloaded] = false
@@ -321,7 +331,7 @@ object Chapter {
this[ChapterTable.realUrl] = it.realUrl this[ChapterTable.realUrl] = it.realUrl
this[ChapterTable.lastModifiedAt] = it.lastModifiedAt this[ChapterTable.lastModifiedAt] = it.lastModifiedAt
this[ChapterTable.version] = it.version this[ChapterTable.version] = it.version
this[ChapterTable.memo] = it.memo this[ChapterTable.memo] = Json.encodeToString(it.memo)
this[ChapterTable.isDownloaded] = currentChapter.downloaded this[ChapterTable.isDownloaded] = currentChapter.downloaded
this[ChapterTable.pageCount] = currentChapter.pageCount this[ChapterTable.pageCount] = currentChapter.pageCount
@@ -359,7 +369,7 @@ object Chapter {
mangaEntry[MangaTable.id].value, mangaEntry[MangaTable.id].value,
currentLatestChapterNumber, currentLatestChapterNumber,
numberOfCurrentChapters, numberOfCurrentChapters,
insertedChapters insertedChapters,
) )
} }

View File

@@ -24,6 +24,7 @@ import io.github.reactivecircus.cache4k.Cache
import io.javalin.http.HttpStatus import io.javalin.http.HttpStatus
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.json.Json
import okhttp3.CacheControl import okhttp3.CacheControl
import okhttp3.Response import okhttp3.Response
import org.jetbrains.exposed.v1.core.ResultRow import org.jetbrains.exposed.v1.core.ResultRow
@@ -105,24 +106,26 @@ object Manga {
genre = mangaEntry[MangaTable.genre] genre = mangaEntry[MangaTable.genre]
status = mangaEntry[MangaTable.status] status = mangaEntry[MangaTable.status]
update_strategy = UpdateStrategy.valueOf(mangaEntry[MangaTable.updateStrategy]) update_strategy = UpdateStrategy.valueOf(mangaEntry[MangaTable.updateStrategy])
memo = mangaEntry[MangaTable.memo] memo = Json.decodeFromString(mangaEntry[MangaTable.memo])
initialized = mangaEntry[MangaTable.initialized] initialized = mangaEntry[MangaTable.initialized]
} }
val sChapters = transaction { val sChapters =
ChapterTable.selectAll() transaction {
.where { ChapterTable.manga eq mangaEntry[MangaTable.id] } ChapterTable
.orderBy(ChapterTable.sourceOrder to SortOrder.DESC) .selectAll()
.map { .where { ChapterTable.manga eq mangaEntry[MangaTable.id] }
SChapter.create().apply { .orderBy(ChapterTable.sourceOrder to SortOrder.DESC)
url = it[ChapterTable.url] .map {
name = it[ChapterTable.name] SChapter.create().apply {
chapter_number = it[ChapterTable.chapter_number] url = it[ChapterTable.url]
scanlator = it[ChapterTable.scanlator] name = it[ChapterTable.name]
date_upload = it[ChapterTable.date_upload] chapter_number = it[ChapterTable.chapter_number]
memo = it[ChapterTable.memo] scanlator = it[ChapterTable.scanlator]
date_upload = it[ChapterTable.date_upload]
memo = Json.decodeFromString(it[ChapterTable.memo])
}
} }
} }
}
return source.getMangaUpdate( return source.getMangaUpdate(
sManga, sManga,
@@ -137,12 +140,13 @@ object Manga {
val mangaEntry = val mangaEntry =
transaction { MangaTable.selectAll().where { MangaTable.id eq mangaId }.first() } transaction { MangaTable.selectAll().where { MangaTable.id eq mangaId }.first() }
val source = getCatalogueSourceOrNull(mangaEntry[MangaTable.sourceReference]) ?: return null val source = getCatalogueSourceOrNull(mangaEntry[MangaTable.sourceReference]) ?: return null
val sManga = fetchMangaAndChapters( val sManga =
mangaEntry, fetchMangaAndChapters(
source, mangaEntry,
fetchDetails = true, source,
fetchChapters = false fetchDetails = true,
).manga fetchChapters = false,
).manga
updateMangaDatabase(mangaEntry, source, sManga) updateMangaDatabase(mangaEntry, source, sManga)
} }

View File

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

View File

@@ -25,7 +25,7 @@ import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.statements.toExecutable import org.jetbrains.exposed.v1.jdbc.statements.toExecutable
import org.jetbrains.exposed.v1.jdbc.transactions.transaction import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import suwayomi.tachidesk.manga.impl.Source.preferenceScreenMap 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.getCatalogueSourceOrNull
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogueSourceOrStub import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogueSourceOrStub
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.unregisterCatalogueSource import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.unregisterCatalogueSource
@@ -49,10 +49,10 @@ object Source {
id = it[SourceTable.id].value.toString(), id = it[SourceTable.id].value.toString(),
name = it[SourceTable.name], name = it[SourceTable.name],
lang = it[SourceTable.lang], lang = it[SourceTable.lang],
iconUrl = getExtensionIconUrl(sourceExtension[ExtensionTable.apkName]), iconUrl = proxyExtensionIconUrl(sourceExtension[ExtensionTable.pkgName]),
supportsLatest = catalogueSource.supportsLatest, supportsLatest = catalogueSource.supportsLatest,
isConfigurable = catalogueSource is ConfigurableSource, isConfigurable = catalogueSource is ConfigurableSource,
isNsfw = it[SourceTable.isNsfw], isNsfw = it[SourceTable.contentRating] >= 3,
displayName = catalogueSource.toString(), displayName = catalogueSource.toString(),
baseUrl = runCatching { (catalogueSource as? HttpSource)?.baseUrl }.getOrNull(), baseUrl = runCatching { (catalogueSource as? HttpSource)?.baseUrl }.getOrNull(),
) )
@@ -70,13 +70,10 @@ object Source {
id = sourceId.toString(), id = sourceId.toString(),
name = source[SourceTable.name], name = source[SourceTable.name],
lang = source[SourceTable.lang], lang = source[SourceTable.lang],
iconUrl = iconUrl = proxyExtensionIconUrl(extension[ExtensionTable.pkgName]),
getExtensionIconUrl(
extension[ExtensionTable.apkName],
),
supportsLatest = catalogueSource.supportsLatest, supportsLatest = catalogueSource.supportsLatest,
isConfigurable = catalogueSource is ConfigurableSource, isConfigurable = catalogueSource is ConfigurableSource,
isNsfw = source[SourceTable.isNsfw], isNsfw = source[SourceTable.contentRating] >= 3,
displayName = catalogueSource.toString(), displayName = catalogueSource.toString(),
baseUrl = runCatching { (catalogueSource as? HttpSource)?.baseUrl }.getOrNull(), baseUrl = runCatching { (catalogueSource as? HttpSource)?.baseUrl }.getOrNull(),
) )

View File

@@ -9,7 +9,6 @@ package suwayomi.tachidesk.manga.impl.backup.proto.handlers
import eu.kanade.tachiyomi.source.model.UpdateStrategy import eu.kanade.tachiyomi.source.model.UpdateStrategy
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import org.jetbrains.exposed.v1.core.ResultRow import org.jetbrains.exposed.v1.core.ResultRow
import org.jetbrains.exposed.v1.core.SortOrder import org.jetbrains.exposed.v1.core.SortOrder
import org.jetbrains.exposed.v1.core.and import org.jetbrains.exposed.v1.core.and
@@ -79,7 +78,7 @@ object BackupMangaHandler {
lastModifiedAt = mangaRow[MangaTable.lastModifiedAt], lastModifiedAt = mangaRow[MangaTable.lastModifiedAt],
version = mangaRow[MangaTable.version], version = mangaRow[MangaTable.version],
initialized = mangaRow[MangaTable.initialized], initialized = mangaRow[MangaTable.initialized],
memo = Json.encodeToString(mangaRow[MangaTable.memo]).encodeToByteArray() memo = Json.encodeToString(mangaRow[MangaTable.memo]).encodeToByteArray(),
) )
val mangaId = mangaRow[MangaTable.id].value val mangaId = mangaRow[MangaTable.id].value
@@ -242,7 +241,7 @@ object BackupMangaHandler {
it[lastModifiedAt] = manga.lastModifiedAt it[lastModifiedAt] = manga.lastModifiedAt
it[version] = manga.version it[version] = manga.version
it[memo] = Json.decodeFromString<JsonObject>(manga.memo.decodeToString()) it[memo] = manga.memo.decodeToString()
}.value }.value
} else { } else {
val dbMangaId = dbManga[MangaTable.id].value val dbMangaId = dbManga[MangaTable.id].value
@@ -265,7 +264,7 @@ object BackupMangaHandler {
it[lastModifiedAt] = manga.lastModifiedAt it[lastModifiedAt] = manga.lastModifiedAt
it[version] = manga.version it[version] = manga.version
it[memo] = Json.decodeFromString<JsonObject>(manga.memo.decodeToString()) it[memo] = manga.memo.decodeToString()
} }
dbMangaId dbMangaId
@@ -357,7 +356,7 @@ object BackupMangaHandler {
this[ChapterTable.lastModifiedAt] = chapter.lastModifiedAt this[ChapterTable.lastModifiedAt] = chapter.lastModifiedAt
this[ChapterTable.version] = chapter.version this[ChapterTable.version] = chapter.version
this[ChapterTable.memo] = Json.decodeFromString<JsonObject>(chapter.memo.decodeToString()) this[ChapterTable.memo] = chapter.memo.decodeToString()
}.map { it[ChapterTable.id].value } }.map { it[ChapterTable.id].value }
} else { } else {
emptyList() emptyList()

View File

@@ -13,6 +13,7 @@ import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.source.CatalogueSource import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceFactory import eu.kanade.tachiyomi.source.SourceFactory
import eu.kanade.tachiyomi.source.local.LocalSource
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import net.dongliu.apk.parser.ApkFile import net.dongliu.apk.parser.ApkFile
import net.dongliu.apk.parser.bean.Icon import net.dongliu.apk.parser.bean.Icon
@@ -23,11 +24,10 @@ import okio.source
import org.jetbrains.exposed.v1.core.eq import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.jdbc.deleteWhere import org.jetbrains.exposed.v1.jdbc.deleteWhere
import org.jetbrains.exposed.v1.jdbc.insert 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.selectAll
import org.jetbrains.exposed.v1.jdbc.transactions.transaction import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import org.jetbrains.exposed.v1.jdbc.update 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
import suwayomi.tachidesk.manga.impl.util.PackageTools.EXTENSION_FEATURE 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_MAX
@@ -62,18 +62,20 @@ object Extension {
suspend fun installExtension(pkgName: String): Int { suspend fun installExtension(pkgName: String): Int {
logger.debug { "Installing $pkgName" } 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 { return installAPK {
val apkURL = val apkName = Uri.parse(apkUrl).lastPathSegment!!
ExtensionGithubApi.getApkUrl(
extensionRecord.repo ?: throw NullPointerException("Could not find extension repo"),
extensionRecord.apkName,
)
val apkName = Uri.parse(apkURL).lastPathSegment!!
val apkSavePath = "${applicationDirs.extensionsRoot}/$apkName" val apkSavePath = "${applicationDirs.extensionsRoot}/$apkName"
// download apk file // download apk file
downloadAPKFile(apkURL, apkSavePath) downloadAPKFile(apkUrl, apkSavePath)
apkSavePath apkSavePath
} }
@@ -193,9 +195,9 @@ object Extension {
it[name] = extensionName it[name] = extensionName
it[this.pkgName] = packageInfo.packageName it[this.pkgName] = packageInfo.packageName
it[versionName] = packageInfo.versionName it[versionName] = packageInfo.versionName
it[versionCode] = packageInfo.versionCode it[versionCode] = packageInfo.versionCode.toLong()
it[lang] = extensionLang it[lang] = extensionLang
it[this.isNsfw] = isNsfw it[contentRating] = if (isNsfw) 3 else 0 // todo will change
} }
} }
@@ -204,7 +206,7 @@ object Extension {
it[this.isInstalled] = true it[this.isInstalled] = true
it[this.classFQName] = className it[this.classFQName] = className
it[versionName] = packageInfo.versionName it[versionName] = packageInfo.versionName
it[versionCode] = packageInfo.versionCode it[versionCode] = packageInfo.versionCode.toLong()
} }
val extensionId = val extensionId =
@@ -220,7 +222,7 @@ object Extension {
it[name] = httpSource.name it[name] = httpSource.name
it[lang] = httpSource.lang it[lang] = httpSource.lang
it[extension] = extensionId it[extension] = extensionId
it[SourceTable.isNsfw] = isNsfw it[contentRating] = if (isNsfw) 3 else 0
} }
logger.debug { "Installed source ${httpSource.name} (${httpSource.lang}) with id:${httpSource.id}" } logger.debug { "Installed source ${httpSource.name} (${httpSource.lang}) with id:${httpSource.id}" }
} }
@@ -343,7 +345,9 @@ object Extension {
logger.debug { "Uninstalling $pkgName" } logger.debug { "Uninstalling $pkgName" }
val extensionRecord = transaction { ExtensionTable.selectAll().where { ExtensionTable.pkgName eq pkgName }.first() } 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 jarPath = "${applicationDirs.extensionsRoot}/$fileNameWithoutType.jar"
val sources = val sources =
transaction { transaction {
@@ -359,6 +363,7 @@ object Extension {
ExtensionTable.update({ ExtensionTable.pkgName eq pkgName }) { ExtensionTable.update({ ExtensionTable.pkgName eq pkgName }) {
it[isInstalled] = false it[isInstalled] = false
it[hasUpdate] = false it[hasUpdate] = false
it[apkName] = null
} }
} }
@@ -385,8 +390,7 @@ object Extension {
it[versionName] = targetExtension.versionName it[versionName] = targetExtension.versionName
it[versionCode] = targetExtension.versionCode it[versionCode] = targetExtension.versionCode
it[lang] = targetExtension.lang it[lang] = targetExtension.lang
it[isNsfw] = targetExtension.isNsfw it[contentRating] = targetExtension.contentRating.ordinal
it[apkName] = targetExtension.apkName
it[iconUrl] = targetExtension.iconUrl it[iconUrl] = targetExtension.iconUrl
it[hasUpdate] = false it[hasUpdate] = false
} }
@@ -394,17 +398,21 @@ object Extension {
return installExtension(pkgName) return installExtension(pkgName)
} }
suspend fun getExtensionIcon(apkName: String): Pair<InputStream, String> { suspend fun getExtensionIcon(pkgName: String): Pair<InputStream, String> {
val iconUrl =
if (apkName == "localSource") {
""
} else {
transaction { ExtensionTable.selectAll().where { ExtensionTable.apkName eq apkName }.first() }[ExtensionTable.iconUrl]
}
val cacheSaveDir = "${applicationDirs.extensionsRoot}/icon" 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 network.client
.newCall( .newCall(
GET(iconUrl, cache = CacheControl.FORCE_NETWORK), GET(iconUrl, cache = CacheControl.FORCE_NETWORK),
@@ -412,5 +420,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,223 @@
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 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 = fetch(indexUrl, forceV2 = false)
private suspend fun fetch(
indexUrl: String,
forceV2: Boolean,
): ExtensionStore {
var updatedIndexUrl = indexUrl
return try {
val response = network.client.newCall(GET(indexUrl)).awaitSuccess()
response.body
.source()
.use { source ->
try {
protoBuf.decodeFromByteArray<NetworkExtensionStore>(source.peek().readByteArray())
} catch (e: IllegalArgumentException) {
logger.debug { "Failed to decode as protobuf, trying JSON" }
if (forceV2) throw e
try {
json.decodeFromBufferedSource<NetworkExtensionStore>(source.peek())
} catch (_: IllegalArgumentException) {
logger.debug { "Failed to decode as NetworkExtensionStore, trying LegacyExtensionRepo" }
val legacyIndex =
try {
json.decodeFromBufferedSource<NetworkLegacyExtensionRepo>(source.peek())
} catch (e: IllegalArgumentException) {
if (!indexUrl.endsWith("/index.min.json")) {
throw e
}
logger.debug { "Retrying with /index.min.json" }
updatedIndexUrl = indexUrl.replace("/index.min.json", "/repo.json")
network.client.newCall(GET(updatedIndexUrl)).awaitSuccess().body.source().use {
json.decodeFromBufferedSource<NetworkLegacyExtensionRepo>(it)
}
}
if (legacyIndex.indexV2 != null) {
return fetch(legacyIndex.indexV2, forceV2 = true)
} else {
legacyIndex
}
}
}
}.toExtensionStore(updatedIndexUrl)
} catch (e: Exception) {
if (e is CancellationException) throw e
logger.debug(e) { "Failed to fetch extension store '$indexUrl'" }
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
}
} 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
}
}
}
}
suspend fun getAndRefresh(): List<ExtensionStore> {
val stores =
transaction {
ExtensionStoreTable.selectAll().toList()
}
return stores.mapNotNull { storeRow ->
val oldIndexUrl = storeRow[ExtensionStoreTable.indexUrl]
val oldName = storeRow[ExtensionStoreTable.name]
try {
val store = fetch(oldIndexUrl)
upsert(store)
if (store.indexUrl != oldIndexUrl) {
transaction {
ExtensionStoreTable.deleteWhere { ExtensionStoreTable.indexUrl eq oldIndexUrl }
}
syncDbToPrefs()
}
store
} catch (e: Exception) {
logger.warn(e) { "Failed to fetch extension store '$oldName ($oldIndexUrl)'" }
null
}
}
}
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
toAdd.forEach { url ->
try {
val store = fetch(url)
upsert(store)
} catch (e: Exception) {
logger.warn(e) { "Failed to sync preference store '$url' to database" }
}
}
val toRemove = dbStores.keys - prefUrls
if (toRemove.isNotEmpty()) {
transaction {
ExtensionStoreTable.deleteWhere { ExtensionStoreTable.indexUrl inList toRemove.toList() }
}
}
}
suspend fun getExtensions(store: ExtensionStore): List<ExtensionInfo> {
val extensions =
if (!store.isLegacy) {
val response = network.client.newCall(GET(store.indexUrl)).awaitSuccess()
response.body
.source()
.use { source ->
try {
protoBuf.decodeFromByteArray<NetworkExtensionStore>(source.peek().readByteArray())
} catch (_: IllegalArgumentException) {
json.decodeFromBufferedSource<NetworkExtensionStore>(source.peek())
}
}.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
}
}

View File

@@ -21,12 +21,10 @@ import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.statements.toExecutable import org.jetbrains.exposed.v1.jdbc.statements.toExecutable
import org.jetbrains.exposed.v1.jdbc.transactions.transaction import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import org.jetbrains.exposed.v1.jdbc.update import org.jetbrains.exposed.v1.jdbc.update
import suwayomi.tachidesk.manga.impl.extension.Extension.getExtensionIconUrl import suwayomi.tachidesk.manga.impl.extension.Extension.proxyExtensionIconUrl
import suwayomi.tachidesk.manga.impl.extension.github.ExtensionGithubApi
import suwayomi.tachidesk.manga.impl.extension.github.OnlineExtension
import suwayomi.tachidesk.manga.model.dataclass.ExtensionDataClass import suwayomi.tachidesk.manga.model.dataclass.ExtensionDataClass
import suwayomi.tachidesk.manga.model.dataclass.ExtensionInfo
import suwayomi.tachidesk.manga.model.table.ExtensionTable import suwayomi.tachidesk.manga.model.table.ExtensionTable
import suwayomi.tachidesk.server.serverConfig
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@@ -34,23 +32,23 @@ object ExtensionsList {
private val logger = KotlinLogging.logger {} private val logger = KotlinLogging.logger {}
var lastUpdateCheck: Long = 0 var lastUpdateCheck: Long = 0
var updateMap = ConcurrentHashMap<String, OnlineExtension>() var updateMap = ConcurrentHashMap<String, ExtensionInfo>()
suspend fun fetchExtensions() { suspend fun fetchExtensions() {
// update if 60 seconds has passed or requested offline and database is empty val allExtensions = mutableListOf<ExtensionInfo>()
val extensions =
serverConfig.extensionRepos.value.map { repo -> ExtensionStoreService.getAndRefresh().forEach { store ->
kotlin try {
.runCatching { val extensions = ExtensionStoreService.getExtensions(store)
ExtensionGithubApi.findExtensions(repo.repoUrlReplace()) allExtensions.addAll(extensions)
}.onFailure { } catch (e: Exception) {
logger.warn(it) { logger.warn(e) {
"Failed to fetch extensions for repo: $repo" "Failed to fetch extensions for store: ${store.indexUrl}"
} }
}
} }
val foundExtensions = extensions.mapNotNull { it.getOrNull() }.flatten() }
updateExtensionDatabase(foundExtensions)
updateExtensionDatabase(allExtensions)
} }
suspend fun fetchExtensionsCached() { suspend fun fetchExtensionsCached() {
@@ -74,25 +72,25 @@ object ExtensionsList {
transaction { transaction {
ExtensionTable.selectAll().filter { it[ExtensionTable.name] != LocalSource.EXTENSION_NAME }.map { ExtensionTable.selectAll().filter { it[ExtensionTable.name] != LocalSource.EXTENSION_NAME }.map {
ExtensionDataClass( ExtensionDataClass(
it[ExtensionTable.repo], repo = it[ExtensionTable.storeIndexUrl],
it[ExtensionTable.apkName], apkName = it[ExtensionTable.apkName].orEmpty(),
getExtensionIconUrl(it[ExtensionTable.apkName]), iconUrl = proxyExtensionIconUrl(it[ExtensionTable.pkgName]),
it[ExtensionTable.name], name = it[ExtensionTable.name],
it[ExtensionTable.pkgName], pkgName = it[ExtensionTable.pkgName],
it[ExtensionTable.versionName], versionName = it[ExtensionTable.versionName],
it[ExtensionTable.versionCode], versionCode = it[ExtensionTable.versionCode].toInt(),
it[ExtensionTable.lang], lang = it[ExtensionTable.lang],
it[ExtensionTable.isNsfw], isNsfw = it[ExtensionTable.contentRating] == 3,
it[ExtensionTable.isInstalled], installed = it[ExtensionTable.isInstalled],
it[ExtensionTable.hasUpdate], hasUpdate = it[ExtensionTable.hasUpdate],
it[ExtensionTable.isObsolete], obsolete = it[ExtensionTable.isObsolete],
) )
} }
} }
private val updateExtensionDatabaseMutex = Mutex() private val updateExtensionDatabaseMutex = Mutex()
private suspend fun updateExtensionDatabase(foundExtensions: List<OnlineExtension>) { private suspend fun updateExtensionDatabase(foundExtensions: List<ExtensionInfo>) {
updateExtensionDatabaseMutex.withLock { updateExtensionDatabaseMutex.withLock {
transaction { transaction {
val uniqueExtensions = val uniqueExtensions =
@@ -106,10 +104,10 @@ object ExtensionsList {
.selectAll() .selectAll()
.toList() .toList()
.associateBy { it[ExtensionTable.pkgName] } .associateBy { it[ExtensionTable.pkgName] }
val extensionsToUpdate = mutableListOf<Pair<OnlineExtension, ResultRow>>() val extensionsToUpdate = mutableListOf<Pair<ExtensionInfo, ResultRow>>()
val extensionsToInsert = mutableListOf<OnlineExtension>() val extensionsToInsert = mutableListOf<ExtensionInfo>()
val extensionsToDelete = 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 } } extension.takeUnless { uniqueExtensions.any { it.pkgName == pkgName } }
} }
uniqueExtensions.forEach { uniqueExtensions.forEach {
@@ -132,7 +130,7 @@ object ExtensionsList {
addBatch(EntityID(extensionRecord[ExtensionTable.id].value, ExtensionTable)) addBatch(EntityID(extensionRecord[ExtensionTable.id].value, ExtensionTable))
// Always update icon url and repo // Always update icon url and repo
this[ExtensionTable.iconUrl] = foundExtension.iconUrl this[ExtensionTable.iconUrl] = foundExtension.iconUrl
this[ExtensionTable.repo] = foundExtension.repo this[ExtensionTable.storeIndexUrl] = foundExtension.storeIndexUrl
// add these because batch updates need matching columns // add these because batch updates need matching columns
this[ExtensionTable.hasUpdate] = extensionRecord[ExtensionTable.hasUpdate] this[ExtensionTable.hasUpdate] = extensionRecord[ExtensionTable.hasUpdate]
@@ -168,13 +166,14 @@ object ExtensionsList {
extensionsToFullyUpdate.forEach { (foundExtension, extensionRecord) -> extensionsToFullyUpdate.forEach { (foundExtension, extensionRecord) ->
addBatch(EntityID(extensionRecord[ExtensionTable.id].value, ExtensionTable)) addBatch(EntityID(extensionRecord[ExtensionTable.id].value, ExtensionTable))
// extension is not installed, so we can overwrite the data without a care // 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.name] = foundExtension.name
this[ExtensionTable.extensionLib] = foundExtension.extensionLib
this[ExtensionTable.versionName] = foundExtension.versionName this[ExtensionTable.versionName] = foundExtension.versionName
this[ExtensionTable.versionCode] = foundExtension.versionCode this[ExtensionTable.versionCode] = foundExtension.versionCode
this[ExtensionTable.lang] = foundExtension.lang this[ExtensionTable.lang] = foundExtension.lang
this[ExtensionTable.isNsfw] = foundExtension.isNsfw this[ExtensionTable.contentRating] = foundExtension.contentRating.ordinal
this[ExtensionTable.apkName] = foundExtension.apkName this[ExtensionTable.apkUrl] = foundExtension.apkUrl
this[ExtensionTable.iconUrl] = foundExtension.iconUrl this[ExtensionTable.iconUrl] = foundExtension.iconUrl
} }
}.toExecutable() }.toExecutable()
@@ -183,14 +182,15 @@ object ExtensionsList {
} }
if (extensionsToInsert.isNotEmpty()) { if (extensionsToInsert.isNotEmpty()) {
ExtensionTable.batchInsert(extensionsToInsert) { foundExtension -> ExtensionTable.batchInsert(extensionsToInsert) { foundExtension ->
this[ExtensionTable.repo] = foundExtension.repo this[ExtensionTable.storeIndexUrl] = foundExtension.storeIndexUrl
this[ExtensionTable.name] = foundExtension.name this[ExtensionTable.name] = foundExtension.name
this[ExtensionTable.pkgName] = foundExtension.pkgName this[ExtensionTable.pkgName] = foundExtension.pkgName
this[ExtensionTable.extensionLib] = foundExtension.extensionLib
this[ExtensionTable.versionName] = foundExtension.versionName this[ExtensionTable.versionName] = foundExtension.versionName
this[ExtensionTable.versionCode] = foundExtension.versionCode this[ExtensionTable.versionCode] = foundExtension.versionCode
this[ExtensionTable.lang] = foundExtension.lang this[ExtensionTable.lang] = foundExtension.lang
this[ExtensionTable.isNsfw] = foundExtension.isNsfw this[ExtensionTable.contentRating] = foundExtension.contentRating.ordinal
this[ExtensionTable.apkName] = foundExtension.apkName this[ExtensionTable.apkUrl] = foundExtension.apkUrl
this[ExtensionTable.iconUrl] = foundExtension.iconUrl this[ExtensionTable.iconUrl] = foundExtension.iconUrl
} }
} }
@@ -215,16 +215,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,127 @@
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.ContentRating
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(5) val extensions: List<Extension>,
) : BaseNetworkExtensionStore {
@Serializable
data class Contact(
@ProtoNumber(1) val website: String,
@ProtoNumber(2) val discord: String?,
)
@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 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 contentRating: ContentRating = ContentRating.SAFE,
@ProtoNumber(7) val message: String? = null,
)
@Serializable
enum class ContentRating {
@ProtoNumber(0)
@JsonNames("CONTENT_RATING_SAFE")
SAFE,
@ProtoNumber(1)
@JsonNames("CONTENT_RATING_SUGGESTIVE")
SUGGESTIVE,
@ProtoNumber(2)
@JsonNames("CONTENT_RATING_EROTICA")
EROTICA,
@ProtoNumber(3)
@JsonNames("CONTENT_RATING_PORNOGRAPHIC")
PORNOGRAPHIC,
}
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,
)
}
fun NetworkExtensionStore.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",
contentRating =
when (extension.sources.maxOfOrNull { it.contentRating }) {
NetworkExtensionStore.ContentRating.SAFE -> ContentRating.SAFE
NetworkExtensionStore.ContentRating.SUGGESTIVE -> ContentRating.SUGGESTIVE
NetworkExtensionStore.ContentRating.EROTICA -> ContentRating.EROTICA
NetworkExtensionStore.ContentRating.PORNOGRAPHIC -> ContentRating.PORNOGRAPHIC
null -> ContentRating.SAFE
},
sources =
extension.sources.map { source ->
ExtensionSource(
id = source.id,
name = source.name,
lang = source.language,
homeUrl = source.homeUrl,
)
},
)
}

View File

@@ -0,0 +1,73 @@
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.ContentRating
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,
contentRating = if (nsfw == 1) ContentRating.PORNOGRAPHIC else ContentRating.SAFE,
sources =
if (sources.isNullOrEmpty()) {
listOf(
ExtensionSource(
id = 0,
name = name,
lang = lang,
homeUrl = "",
),
)
} else {
sources.map { source ->
ExtensionSource(
id = source.id,
name = source.name,
lang = source.lang,
homeUrl = source.baseUrl,
)
}
},
)

View File

@@ -0,0 +1,40 @@
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,
)
}

View File

@@ -41,7 +41,7 @@ object PackageTools {
const val METADATA_SOURCE_FACTORY = "tachiyomi.extension.factory" const val METADATA_SOURCE_FACTORY = "tachiyomi.extension.factory"
const val METADATA_NSFW = "tachiyomi.extension.nsfw" const val METADATA_NSFW = "tachiyomi.extension.nsfw"
const val LIB_VERSION_MIN = 1.3 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 * Convert dex to jar, a wrapper for the dex2jar library

View File

@@ -45,7 +45,9 @@ object GetCatalogueSource {
ExtensionTable.selectAll().where { ExtensionTable.id eq extensionId }.first() 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 className = extensionRecord[ExtensionTable.classFQName]
val jarName = apkName.substringBefore(".apk") + ".jar" val jarName = apkName.substringBefore(".apk") + ".jar"
val jarPath = "${applicationDirs.extensionsRoot}/$jarName" val jarPath = "${applicationDirs.extensionsRoot}/$jarName"

View File

@@ -29,7 +29,11 @@ open class StubSource(
@Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getPopularManga")) @Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getPopularManga"))
override fun fetchPopularManga(page: Int): Observable<MangasPage> = Observable.error(getSourceNotInstalledException()) override fun fetchPopularManga(page: Int): Observable<MangasPage> = Observable.error(getSourceNotInstalledException())
override suspend fun getSearchManga(page: Int, query: String, filters: FilterList): MangasPage = throw getSourceNotInstalledException() override suspend fun getSearchManga(
page: Int,
query: String,
filters: FilterList,
): MangasPage = throw getSourceNotInstalledException()
@Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getSearchManga")) @Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getSearchManga"))
override fun fetchSearchManga( override fun fetchSearchManga(
@@ -45,7 +49,12 @@ open class StubSource(
override fun getFilterList(): FilterList = FilterList() override fun getFilterList(): FilterList = FilterList()
override suspend fun getMangaUpdate(manga: SManga, chapters: List<SChapter>, fetchDetails: Boolean, fetchChapters: Boolean): SMangaUpdate = throw getSourceNotInstalledException() 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")) @Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getMangaDetails"))
override fun fetchMangaDetails(manga: SManga): Observable<SManga> = Observable.error(getSourceNotInstalledException()) override fun fetchMangaDetails(manga: SManga): Observable<SManga> = Observable.error(getSourceNotInstalledException())

View File

@@ -1,4 +1,4 @@
package suwayomi.tachidesk.manga.impl.extension.github package suwayomi.tachidesk.manga.model.dataclass
/* /*
* Copyright (C) Contributors to the Suwayomi project * Copyright (C) Contributors to the Suwayomi project
@@ -7,24 +7,30 @@ package suwayomi.tachidesk.manga.impl.extension.github
* License, v. 2.0. If a copy of the MPL was not distributed with this * License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
data class OnlineExtensionSource( data class ExtensionInfo(
val name: String, val storeIndexUrl: String,
val lang: String,
val id: Long,
val baseUrl: String,
)
data class OnlineExtension(
val repo: String,
val name: String, val name: String,
val pkgName: String, val pkgName: String,
val apkName: String, val apkUrl: 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, val iconUrl: String,
val extensionLib: String,
val versionCode: Long,
val versionName: String,
val lang: String,
val contentRating: ContentRating,
val sources: List<ExtensionSource>,
) )
data class ExtensionSource(
val id: Long,
val name: String,
val lang: String,
val homeUrl: String,
)
enum class ContentRating {
SAFE,
SUGGESTIVE,
EROTICA,
PORNOGRAPHIC,
}

View File

@@ -0,0 +1,22 @@
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,
) {
data class Contact(
val website: String,
val discord: String?,
)
}

View File

@@ -7,14 +7,13 @@ package suwayomi.tachidesk.manga.model.table
* License, v. 2.0. If a copy of the MPL was not distributed with this * License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.Json
import org.jetbrains.exposed.v1.core.ReferenceOption import org.jetbrains.exposed.v1.core.ReferenceOption
import org.jetbrains.exposed.v1.core.ResultRow import org.jetbrains.exposed.v1.core.ResultRow
import org.jetbrains.exposed.v1.core.dao.id.IntIdTable import org.jetbrains.exposed.v1.core.dao.id.IntIdTable
import org.jetbrains.exposed.v1.json.json
import suwayomi.tachidesk.manga.model.dataclass.ChapterDataClass import suwayomi.tachidesk.manga.model.dataclass.ChapterDataClass
import suwayomi.tachidesk.manga.model.table.columns.truncatingVarchar import suwayomi.tachidesk.manga.model.table.columns.truncatingVarchar
import suwayomi.tachidesk.server.database.DBManager import suwayomi.tachidesk.manga.model.table.columns.unlimitedVarchar
object ChapterTable : IntIdTable() { object ChapterTable : IntIdTable() {
val url = varchar("url", 2048) val url = varchar("url", 2048)
@@ -46,7 +45,7 @@ object ChapterTable : IntIdTable() {
val version = long("version").default(0) val version = long("version").default(0)
val isSyncing = bool("is_syncing").default(false) val isSyncing = bool("is_syncing").default(false)
val memo = json<JsonObject>("memo", DBManager.format) val memo = unlimitedVarchar("memo")
} }
fun ChapterTable.toDataClass(chapterEntry: ResultRow) = fun ChapterTable.toDataClass(chapterEntry: ResultRow) =
@@ -69,5 +68,5 @@ fun ChapterTable.toDataClass(chapterEntry: ResultRow) =
pageCount = chapterEntry[pageCount], pageCount = chapterEntry[pageCount],
lastModifiedAt = chapterEntry[lastModifiedAt], lastModifiedAt = chapterEntry[lastModifiedAt],
version = chapterEntry[version], version = chapterEntry[version],
memo = chapterEntry[memo], memo = Json.decodeFromString(chapterEntry[memo]),
) )

View File

@@ -0,0 +1,20 @@
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)
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)
}

View File

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

View File

@@ -9,16 +9,14 @@ package suwayomi.tachidesk.manga.model.table
import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.model.UpdateStrategy import eu.kanade.tachiyomi.source.model.UpdateStrategy
import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.Json
import org.jetbrains.exposed.v1.core.ResultRow import org.jetbrains.exposed.v1.core.ResultRow
import org.jetbrains.exposed.v1.core.dao.id.IntIdTable import org.jetbrains.exposed.v1.core.dao.id.IntIdTable
import org.jetbrains.exposed.v1.json.json
import suwayomi.tachidesk.manga.impl.MangaList.proxyThumbnailUrl import suwayomi.tachidesk.manga.impl.MangaList.proxyThumbnailUrl
import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass
import suwayomi.tachidesk.manga.model.dataclass.toGenreList import suwayomi.tachidesk.manga.model.dataclass.toGenreList
import suwayomi.tachidesk.manga.model.table.columns.truncatingVarchar import suwayomi.tachidesk.manga.model.table.columns.truncatingVarchar
import suwayomi.tachidesk.manga.model.table.columns.unlimitedVarchar import suwayomi.tachidesk.manga.model.table.columns.unlimitedVarchar
import suwayomi.tachidesk.server.database.DBManager
object MangaTable : IntIdTable() { object MangaTable : IntIdTable() {
val url = varchar("url", 2048) val url = varchar("url", 2048)
@@ -51,7 +49,7 @@ object MangaTable : IntIdTable() {
val lastModifiedAt = long("last_modified_at").default(0) val lastModifiedAt = long("last_modified_at").default(0)
val version = long("version").default(0) val version = long("version").default(0)
val isSyncing = bool("is_syncing").default(false) val isSyncing = bool("is_syncing").default(false)
val memo = json<JsonObject>("memo", DBManager.format) val memo = unlimitedVarchar("memo")
} }
fun MangaTable.toDataClass(mangaEntry: ResultRow) = fun MangaTable.toDataClass(mangaEntry: ResultRow) =
@@ -76,7 +74,7 @@ fun MangaTable.toDataClass(mangaEntry: ResultRow) =
updateStrategy = UpdateStrategy.valueOf(mangaEntry[updateStrategy]), updateStrategy = UpdateStrategy.valueOf(mangaEntry[updateStrategy]),
lastModifiedAt = mangaEntry[lastModifiedAt], lastModifiedAt = mangaEntry[lastModifiedAt],
version = mangaEntry[version], version = mangaEntry[version],
memo = mangaEntry[memo], memo = Json.decodeFromString(mangaEntry[memo]),
) )
enum class MangaStatus( enum class MangaStatus(

View File

@@ -8,11 +8,13 @@ package suwayomi.tachidesk.manga.model.table
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import org.jetbrains.exposed.v1.core.dao.id.IdTable import org.jetbrains.exposed.v1.core.dao.id.IdTable
import suwayomi.tachidesk.manga.model.table.columns.unlimitedVarchar
object SourceTable : IdTable<Long>() { object SourceTable : IdTable<Long>() {
override val id = long("id").entityId() override val id = long("id").entityId()
val name = varchar("name", 128) val name = varchar("name", 128)
val lang = varchar("lang", 32) val lang = varchar("lang", 32)
val extension = reference("extension", ExtensionTable) val extension = reference("extension", ExtensionTable)
val isNsfw = bool("is_nsfw").default(false) val message = unlimitedVarchar("message").nullable()
val contentRating = integer("content_rating").default(0)
} }

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.JoinType
import org.jetbrains.exposed.v1.core.SortOrder import org.jetbrains.exposed.v1.core.SortOrder
import org.jetbrains.exposed.v1.core.alias 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.countDistinct
import org.jetbrains.exposed.v1.core.eq import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.jdbc.select import org.jetbrains.exposed.v1.jdbc.select
@@ -139,9 +138,9 @@ object NavigationRepository {
val query = val query =
SourceTable SourceTable
.join(ExtensionTable, JoinType.LEFT, onColumn = SourceTable.extension, otherColumn = ExtensionTable.id) .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 } .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) .orderBy(SourceTable.name to SortOrder.ASC)
val totalCount = query.count() val totalCount = query.count()
@@ -153,7 +152,7 @@ object NavigationRepository {
OpdsSourceNavEntry( OpdsSourceNavEntry(
id = it[SourceTable.id].value, id = it[SourceTable.id].value,
name = formatSourceName(it[SourceTable.name], it[SourceTable.lang]), 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, mangaCount = null,
) )
} }
@@ -173,13 +172,13 @@ object NavigationRepository {
.join(ExtensionTable, JoinType.LEFT, onColumn = SourceTable.extension, otherColumn = ExtensionTable.id) .join(ExtensionTable, JoinType.LEFT, onColumn = SourceTable.extension, otherColumn = ExtensionTable.id)
.join(CategoryMangaTable, JoinType.LEFT, MangaTable.id, CategoryMangaTable.manga) .join(CategoryMangaTable, JoinType.LEFT, MangaTable.id, CategoryMangaTable.manga)
.join(ChapterTable, JoinType.LEFT, MangaTable.id, ChapterTable.manga) .join(ChapterTable, JoinType.LEFT, MangaTable.id, ChapterTable.manga)
.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 } .where { MangaTable.inLibrary eq true }
query.applyOpdsMangaFilter(activeFilters, excludeField = "source_id") query.applyOpdsMangaFilter(activeFilters, excludeField = "source_id")
query 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) .orderBy(SourceTable.name to SortOrder.ASC)
val totalCount = query.count() val totalCount = query.count()
@@ -195,7 +194,7 @@ object NavigationRepository {
OpdsSourceNavEntry( OpdsSourceNavEntry(
id = it[SourceTable.id].value, id = it[SourceTable.id].value,
name = formatSourceName(it[SourceTable.name], it[SourceTable.lang]), 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], mangaCount = it[mangaCount],
) )
} }
@@ -206,12 +205,12 @@ object NavigationRepository {
transaction { transaction {
SourceTable SourceTable
.join(ExtensionTable, JoinType.LEFT, onColumn = SourceTable.extension, otherColumn = ExtensionTable.id) .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 } .where { SourceTable.id eq sourceId }
.firstOrNull() .firstOrNull()
?.let { ?.let {
val name = formatSourceName(it[SourceTable.name], it[SourceTable.lang]) 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) Pair(name, icon)
} }
} }

View File

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

View File

@@ -1,19 +0,0 @@
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
@Suppress("ClassName", "unused")
class M0057_AddMangaChapterMemoFields : SQLMigration() {
override val sql =
"""
ALTER TABLE manga ADD COLUMN memo JSON DEFAULT '{}';
ALTER TABLE chapter ADD COLUMN memo JSON DEFAULT '{}';
""".trimIndent()
}

View File

@@ -0,0 +1,88 @@
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.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);
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_rating INTEGER DEFAULT 0;
UPDATE EXTENSION SET content_rating = 3 WHERE is_nsfw = TRUE;
ALTER TABLE EXTENSION DROP COLUMN is_nsfw;
ALTER TABLE SOURCE ADD COLUMN message $UNLIMITED_TEXT;
ALTER TABLE SOURCE ADD COLUMN content_rating INTEGER DEFAULT 0;
UPDATE SOURCE SET content_rating = 3 WHERE is_nsfw = TRUE;
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,31 @@
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)
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)
}
override val tables: Array<Table>
get() =
arrayOf(
ExtensionStoreTable(),
)
}