Non-Extension Index changes for 1.6

This commit is contained in:
Syer10
2026-06-15 20:11:55 -04:00
parent 934459f15f
commit ceac5f74c4
33 changed files with 954 additions and 615 deletions

View File

@@ -2,6 +2,7 @@
package suwayomi.tachidesk.graphql.mutations
import com.expediagroup.graphql.generator.annotations.GraphQLDeprecated
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import org.jetbrains.exposed.v1.core.LikePattern
@@ -167,6 +168,7 @@ class ChapterMutation {
)
@RequireAuth
@GraphQLDeprecated("Deprecated in Tachiyomix 1.6", ReplaceWith("fetchMangaAndChapters"))
fun fetchChapters(input: FetchChaptersInput): CompletableFuture<FetchChaptersPayload?> {
val (clientMutationId, mangaId) = input

View File

@@ -2,6 +2,7 @@
package suwayomi.tachidesk.graphql.mutations
import com.expediagroup.graphql.generator.annotations.GraphQLDeprecated
import org.jetbrains.exposed.v1.core.LikePattern
import org.jetbrains.exposed.v1.core.Op
import org.jetbrains.exposed.v1.core.and
@@ -14,12 +15,16 @@ import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import org.jetbrains.exposed.v1.jdbc.update
import suwayomi.tachidesk.graphql.directives.RequireAuth
import suwayomi.tachidesk.graphql.types.ChapterType
import suwayomi.tachidesk.graphql.types.MangaMetaType
import suwayomi.tachidesk.graphql.types.MangaType
import suwayomi.tachidesk.graphql.types.MetaInput
import suwayomi.tachidesk.manga.impl.Chapter
import suwayomi.tachidesk.manga.impl.Library
import suwayomi.tachidesk.manga.impl.Manga
import suwayomi.tachidesk.manga.impl.update.IUpdater
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogueSourceOrStub
import suwayomi.tachidesk.manga.model.table.ChapterTable
import suwayomi.tachidesk.manga.model.table.MangaMetaTable
import suwayomi.tachidesk.manga.model.table.MangaTable
import suwayomi.tachidesk.manga.model.table.toDataClass
@@ -146,6 +151,7 @@ class MangaMutation {
)
@RequireAuth
@GraphQLDeprecated("Deprecated in Tachiyomix 1.6", ReplaceWith("fetchMangaAndChapters"))
fun fetchManga(input: FetchMangaInput): CompletableFuture<FetchMangaPayload?> {
val (clientMutationId, id) = input
@@ -163,6 +169,58 @@ class MangaMutation {
}
}
data class FetchMangaAndChaptersInput(
val clientMutationId: String? = null,
val id: Int,
val fetchManga: Boolean,
val fetchChapters: Boolean,
)
data class FetchMangaAndChaptersPayload(
val clientMutationId: String?,
val manga: MangaType,
val chapters: List<ChapterType>,
)
@RequireAuth
fun fetchMangaAndChapters(input: FetchMangaAndChaptersInput): CompletableFuture<FetchMangaAndChaptersPayload?> {
val (clientMutationId, id, fetchManga, fetchChapters) = input
return future {
var mangaEntry =
transaction { MangaTable.selectAll().where { MangaTable.id eq id }.first() }
val source = getCatalogueSourceOrStub(mangaEntry[MangaTable.sourceReference])
val sMangaUpdate = Manga.fetchMangaAndChapters(
mangaEntry = mangaEntry,
source = source,
fetchDetails = fetchManga,
fetchChapters = fetchChapters
)
Manga.updateMangaDatabase(mangaEntry, source, sMangaUpdate.manga)
mangaEntry = transaction { MangaTable.selectAll().where { MangaTable.id eq id }.first() }
Chapter.updateChapterListDatabase(mangaEntry, sMangaUpdate.chapters, source)
val (manga, chapters) =
transaction {
Pair(
MangaTable.selectAll().where { MangaTable.id eq id }.first(),
ChapterTable
.selectAll()
.where { ChapterTable.manga eq id }
.orderBy(ChapterTable.sourceOrder)
.map { ChapterType(it) }
)
}
FetchMangaAndChaptersPayload(
clientMutationId = clientMutationId,
manga = MangaType(manga),
chapters = chapters,
)
}
}
data class SetMangaMetaInput(
val clientMutationId: String? = null,
val meta: MangaMetaType,

View File

@@ -7,16 +7,18 @@ 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 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 +34,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 +51,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 +104,268 @@ 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")
val mangaEntry = transaction {
MangaTable.selectAll().where { MangaTable.id eq mangaId }.first()
}
val source = getCatalogueSourceOrStub(mangaEntry[MangaTable.sourceReference])
// 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 chapters = Manga.fetchMangaAndChapters(
mangaEntry = mangaEntry,
source = source,
fetchDetails = false,
fetchChapters = true,
).chapters
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 mangaId }
.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,
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 = 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] = 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] = 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,19 @@ 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 okhttp3.CacheControl
import okhttp3.Response
import org.jetbrains.exposed.v1.core.ResultRow
@@ -32,10 +38,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 +50,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 +60,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 +78,83 @@ 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)
fetchManga(mangaId) ?: return MangaTable.toDataClass(mangaEntry)
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 = 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]
}
}
}
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)
}
}
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 +179,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 +249,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

@@ -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,13 @@ 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 +38,23 @@ 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

@@ -1,6 +1,8 @@
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
@@ -43,6 +45,8 @@ data class ChapterDataClass(
val pageCount: Int = -1,
val lastModifiedAt: Long = 0,
val version: Long = 0,
@JsonIgnore
val memo: JsonObject = JsonObject(emptyMap()),
) {
companion object {
fun fromSChapter(
@@ -60,6 +64,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

@@ -7,7 +7,9 @@ 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.trimAll
import suwayomi.tachidesk.manga.model.table.MangaStatus
@@ -44,6 +46,8 @@ data class MangaDataClass(
val trackers: List<MangaTrackerDataClass>? = null,
val lastModifiedAt: Long = 0,
val version: Long = 0,
@JsonIgnore
val memo: JsonObject = JsonObject(emptyMap()),
) {
override fun toString(): String = "\"$title\" (id= $id) (sourceId= $sourceId)"

View File

@@ -7,11 +7,14 @@ 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 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
object ChapterTable : IntIdTable() {
val url = varchar("url", 2048)
@@ -42,6 +45,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 = json<JsonObject>("memo", DBManager.format)
}
fun ChapterTable.toDataClass(chapterEntry: ResultRow) =
@@ -64,4 +69,5 @@ fun ChapterTable.toDataClass(chapterEntry: ResultRow) =
pageCount = chapterEntry[pageCount],
lastModifiedAt = chapterEntry[lastModifiedAt],
version = chapterEntry[version],
memo = chapterEntry[memo],
)

View File

@@ -9,13 +9,16 @@ 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 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)
@@ -48,6 +51,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)
}
fun MangaTable.toDataClass(mangaEntry: ResultRow) =
@@ -72,6 +76,7 @@ fun MangaTable.toDataClass(mangaEntry: ResultRow) =
updateStrategy = UpdateStrategy.valueOf(mangaEntry[updateStrategy]),
lastModifiedAt = mangaEntry[lastModifiedAt],
version = mangaEntry[version],
memo = mangaEntry[memo],
)
enum class MangaStatus(

View File

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

View File

@@ -0,0 +1,19 @@
package suwayomi.tachidesk.server.database.migration
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import de.neonew.exposed.migrations.helpers.SQLMigration
@Suppress("ClassName", "unused")
class M0057_AddMangaChapterMemoFields : SQLMigration() {
override val sql =
"""
ALTER TABLE MANGA ADD COLUMN memo JSON DEFAULT '{}';
ALTER TABLE CHAPTER ADD COLUMN memo JSON DEFAULT '{}';
""".trimIndent()
}