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

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

View File

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

View File

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

View File

@@ -24,6 +24,7 @@ import io.github.reactivecircus.cache4k.Cache
import io.javalin.http.HttpStatus
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.json.Json
import okhttp3.CacheControl
import okhttp3.Response
import org.jetbrains.exposed.v1.core.ResultRow
@@ -105,24 +106,26 @@ object Manga {
genre = mangaEntry[MangaTable.genre]
status = mangaEntry[MangaTable.status]
update_strategy = UpdateStrategy.valueOf(mangaEntry[MangaTable.updateStrategy])
memo = mangaEntry[MangaTable.memo]
memo = Json.decodeFromString(mangaEntry[MangaTable.memo])
initialized = mangaEntry[MangaTable.initialized]
}
val sChapters = transaction {
ChapterTable.selectAll()
.where { ChapterTable.manga eq mangaEntry[MangaTable.id] }
.orderBy(ChapterTable.sourceOrder to SortOrder.DESC)
.map {
SChapter.create().apply {
url = it[ChapterTable.url]
name = it[ChapterTable.name]
chapter_number = it[ChapterTable.chapter_number]
scanlator = it[ChapterTable.scanlator]
date_upload = it[ChapterTable.date_upload]
memo = it[ChapterTable.memo]
val sChapters =
transaction {
ChapterTable
.selectAll()
.where { ChapterTable.manga eq mangaEntry[MangaTable.id] }
.orderBy(ChapterTable.sourceOrder to SortOrder.DESC)
.map {
SChapter.create().apply {
url = it[ChapterTable.url]
name = it[ChapterTable.name]
chapter_number = it[ChapterTable.chapter_number]
scanlator = it[ChapterTable.scanlator]
date_upload = it[ChapterTable.date_upload]
memo = Json.decodeFromString(it[ChapterTable.memo])
}
}
}
}
}
return source.getMangaUpdate(
sManga,
@@ -137,12 +140,13 @@ object Manga {
val mangaEntry =
transaction { MangaTable.selectAll().where { MangaTable.id eq mangaId }.first() }
val source = getCatalogueSourceOrNull(mangaEntry[MangaTable.sourceReference]) ?: return null
val sManga = fetchMangaAndChapters(
mangaEntry,
source,
fetchDetails = true,
fetchChapters = false
).manga
val sManga =
fetchMangaAndChapters(
mangaEntry,
source,
fetchDetails = true,
fetchChapters = false,
).manga
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.model.MangasPage
import kotlinx.serialization.json.Json
import org.jetbrains.exposed.v1.core.and
import org.jetbrains.exposed.v1.core.dao.id.EntityID
import org.jetbrains.exposed.v1.core.eq
@@ -75,6 +76,7 @@ object MangaList {
this[MangaTable.status] = it.status
this[MangaTable.thumbnail_url] = it.thumbnail_url
this[MangaTable.updateStrategy] = it.update_strategy.name
this[MangaTable.memo] = Json.encodeToString(it.memo)
this[MangaTable.sourceReference] = sourceId
}.associate { Pair(it[MangaTable.url], it[MangaTable.id].value) }
@@ -103,6 +105,7 @@ object MangaList {
this[MangaTable.status] = sManga.status
this[MangaTable.thumbnail_url] = sManga.thumbnail_url ?: manga[MangaTable.thumbnail_url]
this[MangaTable.updateStrategy] = sManga.update_strategy.name
this[MangaTable.memo] = Json.encodeToString(sManga.memo)
if (!sManga.thumbnail_url.isNullOrEmpty() && manga[MangaTable.thumbnail_url] != sManga.thumbnail_url) {
this[MangaTable.thumbnailUrlLastFetched] = Instant.now().epochSecond
Manga.clearThumbnail(manga[MangaTable.id].value)

View File

@@ -25,7 +25,7 @@ import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.statements.toExecutable
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import suwayomi.tachidesk.manga.impl.Source.preferenceScreenMap
import suwayomi.tachidesk.manga.impl.extension.Extension.getExtensionIconUrl
import suwayomi.tachidesk.manga.impl.extension.Extension.proxyExtensionIconUrl
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogueSourceOrNull
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogueSourceOrStub
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.unregisterCatalogueSource
@@ -49,10 +49,10 @@ object Source {
id = it[SourceTable.id].value.toString(),
name = it[SourceTable.name],
lang = it[SourceTable.lang],
iconUrl = getExtensionIconUrl(sourceExtension[ExtensionTable.apkName]),
iconUrl = proxyExtensionIconUrl(sourceExtension[ExtensionTable.pkgName]),
supportsLatest = catalogueSource.supportsLatest,
isConfigurable = catalogueSource is ConfigurableSource,
isNsfw = it[SourceTable.isNsfw],
isNsfw = it[SourceTable.contentRating] >= 3,
displayName = catalogueSource.toString(),
baseUrl = runCatching { (catalogueSource as? HttpSource)?.baseUrl }.getOrNull(),
)
@@ -70,13 +70,10 @@ object Source {
id = sourceId.toString(),
name = source[SourceTable.name],
lang = source[SourceTable.lang],
iconUrl =
getExtensionIconUrl(
extension[ExtensionTable.apkName],
),
iconUrl = proxyExtensionIconUrl(extension[ExtensionTable.pkgName]),
supportsLatest = catalogueSource.supportsLatest,
isConfigurable = catalogueSource is ConfigurableSource,
isNsfw = source[SourceTable.isNsfw],
isNsfw = source[SourceTable.contentRating] >= 3,
displayName = catalogueSource.toString(),
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 kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import org.jetbrains.exposed.v1.core.ResultRow
import org.jetbrains.exposed.v1.core.SortOrder
import org.jetbrains.exposed.v1.core.and
@@ -79,7 +78,7 @@ object BackupMangaHandler {
lastModifiedAt = mangaRow[MangaTable.lastModifiedAt],
version = mangaRow[MangaTable.version],
initialized = mangaRow[MangaTable.initialized],
memo = Json.encodeToString(mangaRow[MangaTable.memo]).encodeToByteArray()
memo = Json.encodeToString(mangaRow[MangaTable.memo]).encodeToByteArray(),
)
val mangaId = mangaRow[MangaTable.id].value
@@ -242,7 +241,7 @@ object BackupMangaHandler {
it[lastModifiedAt] = manga.lastModifiedAt
it[version] = manga.version
it[memo] = Json.decodeFromString<JsonObject>(manga.memo.decodeToString())
it[memo] = manga.memo.decodeToString()
}.value
} else {
val dbMangaId = dbManga[MangaTable.id].value
@@ -265,7 +264,7 @@ object BackupMangaHandler {
it[lastModifiedAt] = manga.lastModifiedAt
it[version] = manga.version
it[memo] = Json.decodeFromString<JsonObject>(manga.memo.decodeToString())
it[memo] = manga.memo.decodeToString()
}
dbMangaId
@@ -357,7 +356,7 @@ object BackupMangaHandler {
this[ChapterTable.lastModifiedAt] = chapter.lastModifiedAt
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 }
} else {
emptyList()

View File

@@ -13,6 +13,7 @@ import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceFactory
import eu.kanade.tachiyomi.source.local.LocalSource
import io.github.oshai.kotlinlogging.KotlinLogging
import net.dongliu.apk.parser.ApkFile
import net.dongliu.apk.parser.bean.Icon
@@ -23,11 +24,10 @@ import okio.source
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.jdbc.deleteWhere
import org.jetbrains.exposed.v1.jdbc.insert
import org.jetbrains.exposed.v1.jdbc.select
import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import org.jetbrains.exposed.v1.jdbc.update
import suwayomi.tachidesk.manga.impl.extension.ExtensionsList.extensionTableAsDataClass
import suwayomi.tachidesk.manga.impl.extension.github.ExtensionGithubApi
import suwayomi.tachidesk.manga.impl.util.PackageTools
import suwayomi.tachidesk.manga.impl.util.PackageTools.EXTENSION_FEATURE
import suwayomi.tachidesk.manga.impl.util.PackageTools.LIB_VERSION_MAX
@@ -62,18 +62,20 @@ object Extension {
suspend fun installExtension(pkgName: String): Int {
logger.debug { "Installing $pkgName" }
val extensionRecord = extensionTableAsDataClass().first { it.pkgName == pkgName }
val apkUrl =
transaction {
ExtensionTable
.select(ExtensionTable.apkUrl)
.where { ExtensionTable.pkgName eq pkgName }
.firstOrNull()
?.get(ExtensionTable.apkUrl)
} ?: throw NullPointerException("Could not find extension $pkgName")
return installAPK {
val apkURL =
ExtensionGithubApi.getApkUrl(
extensionRecord.repo ?: throw NullPointerException("Could not find extension repo"),
extensionRecord.apkName,
)
val apkName = Uri.parse(apkURL).lastPathSegment!!
val apkName = Uri.parse(apkUrl).lastPathSegment!!
val apkSavePath = "${applicationDirs.extensionsRoot}/$apkName"
// download apk file
downloadAPKFile(apkURL, apkSavePath)
downloadAPKFile(apkUrl, apkSavePath)
apkSavePath
}
@@ -193,9 +195,9 @@ object Extension {
it[name] = extensionName
it[this.pkgName] = packageInfo.packageName
it[versionName] = packageInfo.versionName
it[versionCode] = packageInfo.versionCode
it[versionCode] = packageInfo.versionCode.toLong()
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.classFQName] = className
it[versionName] = packageInfo.versionName
it[versionCode] = packageInfo.versionCode
it[versionCode] = packageInfo.versionCode.toLong()
}
val extensionId =
@@ -220,7 +222,7 @@ object Extension {
it[name] = httpSource.name
it[lang] = httpSource.lang
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}" }
}
@@ -343,7 +345,9 @@ object Extension {
logger.debug { "Uninstalling $pkgName" }
val extensionRecord = transaction { ExtensionTable.selectAll().where { ExtensionTable.pkgName eq pkgName }.first() }
val fileNameWithoutType = extensionRecord[ExtensionTable.apkName].substringBefore(".apk")
val fileNameWithoutType =
extensionRecord[ExtensionTable.apkName]?.substringBefore(".apk")
?: throw NullPointerException("Missing $pkgName apkName")
val jarPath = "${applicationDirs.extensionsRoot}/$fileNameWithoutType.jar"
val sources =
transaction {
@@ -359,6 +363,7 @@ object Extension {
ExtensionTable.update({ ExtensionTable.pkgName eq pkgName }) {
it[isInstalled] = false
it[hasUpdate] = false
it[apkName] = null
}
}
@@ -385,8 +390,7 @@ object Extension {
it[versionName] = targetExtension.versionName
it[versionCode] = targetExtension.versionCode
it[lang] = targetExtension.lang
it[isNsfw] = targetExtension.isNsfw
it[apkName] = targetExtension.apkName
it[contentRating] = targetExtension.contentRating.ordinal
it[iconUrl] = targetExtension.iconUrl
it[hasUpdate] = false
}
@@ -394,17 +398,21 @@ object Extension {
return installExtension(pkgName)
}
suspend fun getExtensionIcon(apkName: String): Pair<InputStream, String> {
val iconUrl =
if (apkName == "localSource") {
""
} else {
transaction { ExtensionTable.selectAll().where { ExtensionTable.apkName eq apkName }.first() }[ExtensionTable.iconUrl]
}
suspend fun getExtensionIcon(pkgName: String): Pair<InputStream, String> {
val cacheSaveDir = "${applicationDirs.extensionsRoot}/icon"
return getImageResponse(cacheSaveDir, apkName) {
if (pkgName == LocalSource::class.java.`package`.name) {
return getImageResponse(cacheSaveDir, "localSource") {
network.client
.newCall(GET("", cache = CacheControl.FORCE_NETWORK))
.await()
}
}
val iconUrl =
transaction { ExtensionTable.selectAll().where { ExtensionTable.pkgName eq pkgName }.first() }[ExtensionTable.iconUrl]
return getImageResponse(cacheSaveDir, pkgName) {
network.client
.newCall(
GET(iconUrl, cache = CacheControl.FORCE_NETWORK),
@@ -412,5 +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.transactions.transaction
import org.jetbrains.exposed.v1.jdbc.update
import suwayomi.tachidesk.manga.impl.extension.Extension.getExtensionIconUrl
import suwayomi.tachidesk.manga.impl.extension.github.ExtensionGithubApi
import suwayomi.tachidesk.manga.impl.extension.github.OnlineExtension
import suwayomi.tachidesk.manga.impl.extension.Extension.proxyExtensionIconUrl
import suwayomi.tachidesk.manga.model.dataclass.ExtensionDataClass
import suwayomi.tachidesk.manga.model.dataclass.ExtensionInfo
import suwayomi.tachidesk.manga.model.table.ExtensionTable
import suwayomi.tachidesk.server.serverConfig
import java.util.concurrent.ConcurrentHashMap
import kotlin.time.Duration.Companion.seconds
@@ -34,23 +32,23 @@ object ExtensionsList {
private val logger = KotlinLogging.logger {}
var lastUpdateCheck: Long = 0
var updateMap = ConcurrentHashMap<String, OnlineExtension>()
var updateMap = ConcurrentHashMap<String, ExtensionInfo>()
suspend fun fetchExtensions() {
// update if 60 seconds has passed or requested offline and database is empty
val extensions =
serverConfig.extensionRepos.value.map { repo ->
kotlin
.runCatching {
ExtensionGithubApi.findExtensions(repo.repoUrlReplace())
}.onFailure {
logger.warn(it) {
"Failed to fetch extensions for repo: $repo"
}
}
val allExtensions = mutableListOf<ExtensionInfo>()
ExtensionStoreService.getAndRefresh().forEach { store ->
try {
val extensions = ExtensionStoreService.getExtensions(store)
allExtensions.addAll(extensions)
} catch (e: Exception) {
logger.warn(e) {
"Failed to fetch extensions for store: ${store.indexUrl}"
}
}
val foundExtensions = extensions.mapNotNull { it.getOrNull() }.flatten()
updateExtensionDatabase(foundExtensions)
}
updateExtensionDatabase(allExtensions)
}
suspend fun fetchExtensionsCached() {
@@ -74,25 +72,25 @@ object ExtensionsList {
transaction {
ExtensionTable.selectAll().filter { it[ExtensionTable.name] != LocalSource.EXTENSION_NAME }.map {
ExtensionDataClass(
it[ExtensionTable.repo],
it[ExtensionTable.apkName],
getExtensionIconUrl(it[ExtensionTable.apkName]),
it[ExtensionTable.name],
it[ExtensionTable.pkgName],
it[ExtensionTable.versionName],
it[ExtensionTable.versionCode],
it[ExtensionTable.lang],
it[ExtensionTable.isNsfw],
it[ExtensionTable.isInstalled],
it[ExtensionTable.hasUpdate],
it[ExtensionTable.isObsolete],
repo = it[ExtensionTable.storeIndexUrl],
apkName = it[ExtensionTable.apkName].orEmpty(),
iconUrl = proxyExtensionIconUrl(it[ExtensionTable.pkgName]),
name = it[ExtensionTable.name],
pkgName = it[ExtensionTable.pkgName],
versionName = it[ExtensionTable.versionName],
versionCode = it[ExtensionTable.versionCode].toInt(),
lang = it[ExtensionTable.lang],
isNsfw = it[ExtensionTable.contentRating] == 3,
installed = it[ExtensionTable.isInstalled],
hasUpdate = it[ExtensionTable.hasUpdate],
obsolete = it[ExtensionTable.isObsolete],
)
}
}
private val updateExtensionDatabaseMutex = Mutex()
private suspend fun updateExtensionDatabase(foundExtensions: List<OnlineExtension>) {
private suspend fun updateExtensionDatabase(foundExtensions: List<ExtensionInfo>) {
updateExtensionDatabaseMutex.withLock {
transaction {
val uniqueExtensions =
@@ -106,10 +104,10 @@ object ExtensionsList {
.selectAll()
.toList()
.associateBy { it[ExtensionTable.pkgName] }
val extensionsToUpdate = mutableListOf<Pair<OnlineExtension, ResultRow>>()
val extensionsToInsert = mutableListOf<OnlineExtension>()
val extensionsToUpdate = mutableListOf<Pair<ExtensionInfo, ResultRow>>()
val extensionsToInsert = mutableListOf<ExtensionInfo>()
val extensionsToDelete =
installedExtensions.filter { it.value[ExtensionTable.repo] != null }.mapNotNull { (pkgName, extension) ->
installedExtensions.filter { it.value[ExtensionTable.storeIndexUrl] != null }.mapNotNull { (pkgName, extension) ->
extension.takeUnless { uniqueExtensions.any { it.pkgName == pkgName } }
}
uniqueExtensions.forEach {
@@ -132,7 +130,7 @@ object ExtensionsList {
addBatch(EntityID(extensionRecord[ExtensionTable.id].value, ExtensionTable))
// Always update icon url and repo
this[ExtensionTable.iconUrl] = foundExtension.iconUrl
this[ExtensionTable.repo] = foundExtension.repo
this[ExtensionTable.storeIndexUrl] = foundExtension.storeIndexUrl
// add these because batch updates need matching columns
this[ExtensionTable.hasUpdate] = extensionRecord[ExtensionTable.hasUpdate]
@@ -168,13 +166,14 @@ object ExtensionsList {
extensionsToFullyUpdate.forEach { (foundExtension, extensionRecord) ->
addBatch(EntityID(extensionRecord[ExtensionTable.id].value, ExtensionTable))
// extension is not installed, so we can overwrite the data without a care
this[ExtensionTable.repo] = foundExtension.repo
this[ExtensionTable.storeIndexUrl] = foundExtension.storeIndexUrl
this[ExtensionTable.name] = foundExtension.name
this[ExtensionTable.extensionLib] = foundExtension.extensionLib
this[ExtensionTable.versionName] = foundExtension.versionName
this[ExtensionTable.versionCode] = foundExtension.versionCode
this[ExtensionTable.lang] = foundExtension.lang
this[ExtensionTable.isNsfw] = foundExtension.isNsfw
this[ExtensionTable.apkName] = foundExtension.apkName
this[ExtensionTable.contentRating] = foundExtension.contentRating.ordinal
this[ExtensionTable.apkUrl] = foundExtension.apkUrl
this[ExtensionTable.iconUrl] = foundExtension.iconUrl
}
}.toExecutable()
@@ -183,14 +182,15 @@ object ExtensionsList {
}
if (extensionsToInsert.isNotEmpty()) {
ExtensionTable.batchInsert(extensionsToInsert) { foundExtension ->
this[ExtensionTable.repo] = foundExtension.repo
this[ExtensionTable.storeIndexUrl] = foundExtension.storeIndexUrl
this[ExtensionTable.name] = foundExtension.name
this[ExtensionTable.pkgName] = foundExtension.pkgName
this[ExtensionTable.extensionLib] = foundExtension.extensionLib
this[ExtensionTable.versionName] = foundExtension.versionName
this[ExtensionTable.versionCode] = foundExtension.versionCode
this[ExtensionTable.lang] = foundExtension.lang
this[ExtensionTable.isNsfw] = foundExtension.isNsfw
this[ExtensionTable.apkName] = foundExtension.apkName
this[ExtensionTable.contentRating] = foundExtension.contentRating.ordinal
this[ExtensionTable.apkUrl] = foundExtension.apkUrl
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_NSFW = "tachiyomi.extension.nsfw"
const val LIB_VERSION_MIN = 1.3
const val LIB_VERSION_MAX = 1.5
const val LIB_VERSION_MAX = 1.6
/**
* Convert dex to jar, a wrapper for the dex2jar library

View File

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

View File

@@ -29,7 +29,11 @@ open class StubSource(
@Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getPopularManga"))
override fun fetchPopularManga(page: Int): Observable<MangasPage> = Observable.error(getSourceNotInstalledException())
override suspend fun getSearchManga(page: Int, query: String, filters: FilterList): MangasPage = throw getSourceNotInstalledException()
override suspend fun getSearchManga(
page: Int,
query: String,
filters: FilterList,
): MangasPage = throw getSourceNotInstalledException()
@Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getSearchManga"))
override fun fetchSearchManga(
@@ -45,7 +49,12 @@ open class StubSource(
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"))
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
@@ -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
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
data class OnlineExtensionSource(
val name: String,
val lang: String,
val id: Long,
val baseUrl: String,
)
data class OnlineExtension(
val repo: String,
data class ExtensionInfo(
val storeIndexUrl: String,
val name: String,
val pkgName: String,
val apkName: String,
val lang: String,
val versionCode: Int,
val versionName: String,
val isNsfw: Boolean,
val hasReadme: Boolean,
val hasChangelog: Boolean,
val sources: List<OnlineExtensionSource>,
val apkUrl: 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
* 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.ResultRow
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.table.columns.truncatingVarchar
import suwayomi.tachidesk.server.database.DBManager
import suwayomi.tachidesk.manga.model.table.columns.unlimitedVarchar
object ChapterTable : IntIdTable() {
val url = varchar("url", 2048)
@@ -46,7 +45,7 @@ object ChapterTable : IntIdTable() {
val version = long("version").default(0)
val isSyncing = bool("is_syncing").default(false)
val memo = json<JsonObject>("memo", DBManager.format)
val memo = unlimitedVarchar("memo")
}
fun ChapterTable.toDataClass(chapterEntry: ResultRow) =
@@ -69,5 +68,5 @@ fun ChapterTable.toDataClass(chapterEntry: ResultRow) =
pageCount = chapterEntry[pageCount],
lastModifiedAt = chapterEntry[lastModifiedAt],
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
object ExtensionTable : IntIdTable() {
val apkName = varchar("apk_name", 1024)
val repo = varchar("repo", 1024).nullable()
val apkName = varchar("apk_name", 1024).nullable()
val storeIndexUrl = varchar("store_index_url", 2048).nullable()
// default is the local source icon from tachiyomi
@Suppress("ktlint:standard:max-line-length")
@@ -23,10 +23,12 @@ object ExtensionTable : IntIdTable() {
val name = varchar("name", 128)
val pkgName = varchar("pkg_name", 128)
val apkUrl = varchar("apk_url", 2048)
val extensionLib = varchar("extension_lib", 16).nullable()
val versionName = varchar("version_name", 16)
val versionCode = integer("version_code")
val versionCode = long("version_code")
val lang = varchar("lang", 32)
val isNsfw = bool("is_nsfw")
val contentRating = integer("content_rating")
val isInstalled = bool("is_installed").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.UpdateStrategy
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.Json
import org.jetbrains.exposed.v1.core.ResultRow
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.model.dataclass.MangaDataClass
import suwayomi.tachidesk.manga.model.dataclass.toGenreList
import suwayomi.tachidesk.manga.model.table.columns.truncatingVarchar
import suwayomi.tachidesk.manga.model.table.columns.unlimitedVarchar
import suwayomi.tachidesk.server.database.DBManager
object MangaTable : IntIdTable() {
val url = varchar("url", 2048)
@@ -51,7 +49,7 @@ object MangaTable : IntIdTable() {
val lastModifiedAt = long("last_modified_at").default(0)
val version = long("version").default(0)
val isSyncing = bool("is_syncing").default(false)
val memo = json<JsonObject>("memo", DBManager.format)
val memo = unlimitedVarchar("memo")
}
fun MangaTable.toDataClass(mangaEntry: ResultRow) =
@@ -76,7 +74,7 @@ fun MangaTable.toDataClass(mangaEntry: ResultRow) =
updateStrategy = UpdateStrategy.valueOf(mangaEntry[updateStrategy]),
lastModifiedAt = mangaEntry[lastModifiedAt],
version = mangaEntry[version],
memo = mangaEntry[memo],
memo = Json.decodeFromString(mangaEntry[memo]),
)
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/. */
import org.jetbrains.exposed.v1.core.dao.id.IdTable
import suwayomi.tachidesk.manga.model.table.columns.unlimitedVarchar
object SourceTable : IdTable<Long>() {
override val id = long("id").entityId()
val name = varchar("name", 128)
val lang = varchar("lang", 32)
val extension = reference("extension", ExtensionTable)
val isNsfw = bool("is_nsfw").default(false)
val message = unlimitedVarchar("message").nullable()
val contentRating = integer("content_rating").default(0)
}