Extension API 1.6 (#2120)

* Non-Extension Index changes for 1.6

* Changelog

* Minor fixes

* Implement extension store

* Test build fix

* Docs

* Simplify fetching manga and chapters

* Use EMPTY JsonObject

* Update docs/Configuring-Suwayomi‐Server.md

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

* Improve Fetch Extension Store

* Fixes

* Simplify deprecated isNsfw in SourceQuery

* Simplify ContentRating in Source.kt

* Simplify isNsfw in SourceType

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

* Fix SearchTest

* Lint

* Lint

* Optimize imports and fix unchecked cast warning

* Proper extension store queries

* Optimize import fixes

* Add ContentRatingFilter

* Improve extension store sync

* fix: re-sync (#2121)

* Lint

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

* Use a single version of ContentRating

* Exclude ServerConfig.extensionStores from GraphQL

* Use syncDbToPrefs in ExtensionStoreMutation

* Optimize Imports

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

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

* Remove replaceWith and add specific description for GQL APIs

* Include OkHttp ZSTD

* Update to latest Mihon extension lib

* Fix latest Mihon Extension Lib

* Lint

* Optimize imports

* Lint

* Review fixes

* Add a index to extesnion table store url

* Lint

---------

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,14 @@
package suwayomi.tachidesk.manga.impl.extension.github
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import suwayomi.tachidesk.manga.model.dataclass.ExtensionStore
interface BaseNetworkExtensionStore {
fun toExtensionStore(indexUrl: String): ExtensionStore
}

View File

@@ -1,107 +0,0 @@
package suwayomi.tachidesk.manga.impl.extension.github
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.network.awaitSuccess
import eu.kanade.tachiyomi.network.parseAs
import io.github.oshai.kotlinlogging.KotlinLogging
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import suwayomi.tachidesk.manga.impl.util.PackageTools.LIB_VERSION_MAX
import suwayomi.tachidesk.manga.impl.util.PackageTools.LIB_VERSION_MIN
import uy.kohesive.injekt.injectLazy
object ExtensionGithubApi {
private val logger = KotlinLogging.logger {}
private val json: Json by injectLazy()
@Serializable
private data class ExtensionJsonObject(
val name: String,
val pkg: String,
val apk: String,
val lang: String,
val code: Int,
val version: String,
val nsfw: Int,
val hasReadme: Int = 0,
val hasChangelog: Int = 0,
val sources: List<ExtensionSourceJsonObject>?,
)
@Serializable
private data class ExtensionSourceJsonObject(
val name: String,
val lang: String,
val id: Long,
val baseUrl: String,
)
suspend fun findExtensions(repo: String): List<OnlineExtension> {
val response =
client.newCall(GET(repo)).awaitSuccess()
return with(json) {
response
.parseAs<List<ExtensionJsonObject>>()
.toExtensions(repo.substringBeforeLast('/') + '/')
}
}
fun getApkUrl(
repo: String,
apkName: String,
): String = "${repo}apk/$apkName"
private val client by lazy {
val network: NetworkHelper by injectLazy()
network.client
.newBuilder()
.addNetworkInterceptor { chain ->
val originalResponse = chain.proceed(chain.request())
originalResponse
.newBuilder()
.header("Content-Type", "application/json")
.build()
}.build()
}
private fun List<ExtensionJsonObject>.toExtensions(repo: String): List<OnlineExtension> =
this
.filter {
val libVersion = it.version.substringBeforeLast('.').toDouble()
libVersion in LIB_VERSION_MIN..LIB_VERSION_MAX
}.map {
OnlineExtension(
repo = repo,
name = it.name.substringAfter("Tachiyomi: "),
pkgName = it.pkg,
versionName = it.version,
versionCode = it.code,
lang = it.lang,
isNsfw = it.nsfw == 1,
hasReadme = it.hasReadme == 1,
hasChangelog = it.hasChangelog == 1,
sources = it.sources?.toExtensionSources() ?: emptyList(),
apkName = it.apk,
iconUrl = "${repo}icon/${it.pkg}.png",
)
}
private fun List<ExtensionSourceJsonObject>.toExtensionSources(): List<OnlineExtensionSource> =
this.map {
OnlineExtensionSource(
name = it.name,
lang = it.lang,
id = it.id,
baseUrl = it.baseUrl,
)
}
}

View File

@@ -0,0 +1,148 @@
package suwayomi.tachidesk.manga.impl.extension.github
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonNames
import kotlinx.serialization.protobuf.ProtoNumber
import suwayomi.tachidesk.manga.model.dataclass.ContentWarning
import suwayomi.tachidesk.manga.model.dataclass.ExtensionInfo
import suwayomi.tachidesk.manga.model.dataclass.ExtensionSource
import suwayomi.tachidesk.manga.model.dataclass.ExtensionStore
@OptIn(ExperimentalSerializationApi::class)
@Serializable
data class NetworkExtensionStore(
@ProtoNumber(1) val name: String,
@ProtoNumber(2) val badgeLabel: String,
@ProtoNumber(3) val signingKey: String,
@ProtoNumber(4) val contact: Contact,
@ProtoNumber(101) val extensionList: ExtensionList?,
@ProtoNumber(102) val extensionListUrl: String?,
) : BaseNetworkExtensionStore {
@Serializable
data class Contact(
@ProtoNumber(1) val website: String,
@ProtoNumber(2) val discord: String?,
)
@Serializable
data class ExtensionList(
@ProtoNumber(1) val extensions: List<Extension>,
)
@Serializable
data class Extension(
@ProtoNumber(1) val name: String,
@ProtoNumber(2) val packageName: String,
@ProtoNumber(3) val resources: Resources,
@ProtoNumber(4) val extensionLib: String,
@ProtoNumber(5) val versionCode: Long,
@ProtoNumber(6) val versionName: String,
@ProtoNumber(7) val contentWarning: ContentWarning,
@ProtoNumber(8) val sources: List<Source>,
)
@Serializable
data class Resources(
@ProtoNumber(1) val apkUrl: String,
@ProtoNumber(2) val iconUrl: String,
)
@Serializable
data class Source(
@ProtoNumber(1) val id: Long,
@ProtoNumber(2) val name: String,
@ProtoNumber(3) val language: String,
@ProtoNumber(4) val homeUrl: String = "",
@ProtoNumber(5) val mirrorUrls: List<String> = emptyList(),
// @ProtoNumber(6) val contentWarning: ContentWarning = ContentWarning.SAFE,
@ProtoNumber(7) val message: String? = null,
)
@Serializable
enum class ContentWarning {
@ProtoNumber(0)
@JsonNames("CONTENT_WARNING_UNSPECIFIED")
UNSPECIFIED,
@ProtoNumber(1)
@JsonNames("CONTENT_WARNING_SAFE")
SAFE,
@ProtoNumber(2)
@JsonNames("CONTENT_WARNING_MIXED")
MIXED,
@ProtoNumber(3)
@JsonNames("CONTENT_WARNING_NSFW")
NSFW,
}
override fun toExtensionStore(indexUrl: String): ExtensionStore =
ExtensionStore(
indexUrl = indexUrl,
name = name,
badgeLabel = badgeLabel,
signingKey = signingKey,
contact =
ExtensionStore.Contact(
website = contact.website,
discord = contact.discord,
),
isLegacy = false,
extensionListUrl = extensionListUrl,
)
}
fun NetworkExtensionStore.ExtensionList.toExtensionInfos(store: ExtensionStore): List<ExtensionInfo> =
extensions.map { extension ->
val lang = extension.sources.map { it.language }.toSet()
ExtensionInfo(
storeIndexUrl = store.indexUrl,
name = extension.name,
pkgName = extension.packageName,
apkUrl = extension.resources.apkUrl,
iconUrl = extension.resources.iconUrl,
extensionLib = extension.extensionLib,
versionCode = extension.versionCode,
versionName = extension.versionName,
lang = if (lang.size == 1) lang.first() else "all",
contentWarning =
when (extension.contentWarning) {
NetworkExtensionStore.ContentWarning.SAFE,
NetworkExtensionStore.ContentWarning.UNSPECIFIED,
-> ContentWarning.SAFE
NetworkExtensionStore.ContentWarning.MIXED -> ContentWarning.MIXED
NetworkExtensionStore.ContentWarning.NSFW -> ContentWarning.NSFW
},
sources =
extension.sources.map { source ->
ExtensionSource(
id = source.id,
name = source.name,
lang = source.language,
homeUrl = source.homeUrl,
message = source.message,
contentWarning =
when (extension.contentWarning) { // todo source.contentWarning
NetworkExtensionStore.ContentWarning.SAFE,
NetworkExtensionStore.ContentWarning.UNSPECIFIED,
-> ContentWarning.SAFE
NetworkExtensionStore.ContentWarning.MIXED -> ContentWarning.MIXED
NetworkExtensionStore.ContentWarning.NSFW -> ContentWarning.NSFW
},
)
},
)
}

View File

@@ -0,0 +1,77 @@
package suwayomi.tachidesk.manga.impl.extension.github
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import suwayomi.tachidesk.manga.model.dataclass.ContentWarning
import suwayomi.tachidesk.manga.model.dataclass.ExtensionInfo
import suwayomi.tachidesk.manga.model.dataclass.ExtensionSource
import suwayomi.tachidesk.manga.model.dataclass.ExtensionStore
@OptIn(ExperimentalSerializationApi::class)
@Serializable
data class NetworkLegacyExtension(
val name: String,
val pkg: String,
val apk: String,
val lang: String,
val version: String,
val code: Long,
val nsfw: Int,
val sources: List<Source>? = null,
) {
@Serializable
data class Source(
val id: Long,
val lang: String,
val name: String,
val baseUrl: String,
)
}
fun NetworkLegacyExtension.toExtensionInfo(
store: ExtensionStore,
storeBaseUrl: String,
): ExtensionInfo =
ExtensionInfo(
storeIndexUrl = store.indexUrl,
name = name.substringAfter("Tachiyomi: "),
pkgName = pkg,
apkUrl = "$storeBaseUrl/apk/$apk",
iconUrl = "$storeBaseUrl/icon/$pkg.png",
extensionLib = version.substringBeforeLast('.'),
versionCode = code,
versionName = version,
lang = lang,
contentWarning = if (nsfw == 1) ContentWarning.MIXED else ContentWarning.SAFE,
sources =
if (sources.isNullOrEmpty()) {
listOf(
ExtensionSource(
id = 0,
name = name,
lang = lang,
homeUrl = "",
message = null,
contentWarning = if (nsfw == 1) ContentWarning.MIXED else ContentWarning.SAFE,
),
)
} else {
sources.map { source ->
ExtensionSource(
id = source.id,
name = source.name,
lang = source.lang,
homeUrl = source.baseUrl,
message = null,
contentWarning = if (nsfw == 1) ContentWarning.MIXED else ContentWarning.SAFE,
)
}
},
)

View File

@@ -0,0 +1,41 @@
package suwayomi.tachidesk.manga.impl.extension.github
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import suwayomi.tachidesk.manga.model.dataclass.ExtensionStore
@Serializable
data class NetworkLegacyExtensionRepo(
@SerialName("index_v2") val indexV2: String?,
val meta: Meta,
) : BaseNetworkExtensionStore {
@Serializable
data class Meta(
val name: String,
val shortName: String?,
val website: String,
val signingKeyFingerprint: String,
)
override fun toExtensionStore(indexUrl: String): ExtensionStore =
ExtensionStore(
indexUrl = indexUrl,
name = meta.name,
badgeLabel = meta.shortName ?: meta.name,
signingKey = meta.signingKeyFingerprint,
contact =
ExtensionStore.Contact(
website = meta.website,
discord = null,
),
isLegacy = true,
extensionListUrl = null,
)
}

View File

@@ -1,30 +0,0 @@
package suwayomi.tachidesk.manga.impl.extension.github
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
data class OnlineExtensionSource(
val name: String,
val lang: String,
val id: Long,
val baseUrl: String,
)
data class OnlineExtension(
val repo: String,
val name: String,
val pkgName: String,
val apkName: String,
val lang: String,
val versionCode: Int,
val versionName: String,
val isNsfw: Boolean,
val hasReadme: Boolean,
val hasChangelog: Boolean,
val sources: List<OnlineExtensionSource>,
val iconUrl: String,
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,42 @@
package suwayomi.tachidesk.manga.model.dataclass
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
data class ExtensionInfo(
val storeIndexUrl: String,
val name: String,
val pkgName: String,
val apkUrl: String,
val iconUrl: String,
val extensionLib: String,
val versionCode: Long,
val versionName: String,
val lang: String,
val contentWarning: ContentWarning,
val sources: List<ExtensionSource>,
)
data class ExtensionSource(
val id: Long,
val name: String,
val lang: String,
val homeUrl: String,
val message: String?,
val contentWarning: ContentWarning,
)
enum class ContentWarning {
SAFE,
MIXED,
NSFW,
;
companion object {
fun valueOf(contentWarning: Int) = entries.find { it.ordinal == contentWarning } ?: SAFE
}
}

View File

@@ -0,0 +1,23 @@
package suwayomi.tachidesk.manga.model.dataclass
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
data class ExtensionStore(
val indexUrl: String,
val name: String,
val badgeLabel: String,
val signingKey: String,
val contact: Contact,
val isLegacy: Boolean,
val extensionListUrl: String?,
) {
data class Contact(
val website: String,
val discord: String?,
)
}

View File

@@ -7,8 +7,11 @@ package suwayomi.tachidesk.manga.model.dataclass
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import com.fasterxml.jackson.annotation.JsonIgnore
import eu.kanade.tachiyomi.source.model.UpdateStrategy
import kotlinx.serialization.json.JsonObject
import suwayomi.tachidesk.manga.impl.Manga.getMangaMetaMap
import suwayomi.tachidesk.manga.impl.util.lang.EMPTY
import suwayomi.tachidesk.manga.impl.util.lang.trimAll
import suwayomi.tachidesk.manga.model.table.MangaStatus
import java.time.Instant
@@ -44,6 +47,8 @@ data class MangaDataClass(
val trackers: List<MangaTrackerDataClass>? = null,
val lastModifiedAt: Long = 0,
val version: Long = 0,
@JsonIgnore
val memo: JsonObject = JsonObject.EMPTY,
) {
override fun toString(): String = "\"$title\" (id= $id) (sourceId= $sourceId)"

View File

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

View File

@@ -0,0 +1,21 @@
package suwayomi.tachidesk.manga.model.table
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import org.jetbrains.exposed.v1.core.dao.id.IntIdTable
object ExtensionStoreTable : IntIdTable() {
val indexUrl = varchar("index_url", 2048).uniqueIndex()
val name = varchar("name", 256)
val badgeLabel = varchar("badge_label", 32)
val signingKey = varchar("signing_key", 512)
val contactWebsite = varchar("contact_website", 2048)
val contactDiscord = varchar("contact_discord", 2048).nullable()
val isLegacy = bool("is_legacy").default(false)
val extensionListUrl = varchar("extension_list_url", 2048).nullable()
}

View File

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

View File

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

View File

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