mirror of
https://github.com/Suwayomi/Suwayomi-Server.git
synced 2026-07-08 05:14:37 -05:00
* Update exposed to v1 * Update Exposed * Add Kotlinx.DateTime extensions * Update H2 * Review comments --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Syer10 <syer10@users.noreply.github.com>
493 lines
21 KiB
Kotlin
493 lines
21 KiB
Kotlin
package suwayomi.tachidesk.manga.impl
|
|
|
|
/*
|
|
* 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.HttpException
|
|
import eu.kanade.tachiyomi.network.NetworkHelper
|
|
import eu.kanade.tachiyomi.network.awaitSuccess
|
|
import eu.kanade.tachiyomi.source.local.LocalSource
|
|
import eu.kanade.tachiyomi.source.model.SManga
|
|
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.javalin.http.HttpStatus
|
|
import okhttp3.CacheControl
|
|
import okhttp3.Response
|
|
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
|
|
import org.jetbrains.exposed.v1.core.eq
|
|
import org.jetbrains.exposed.v1.core.inList
|
|
import org.jetbrains.exposed.v1.core.statements.BatchUpdateStatement
|
|
import org.jetbrains.exposed.v1.jdbc.batchInsert
|
|
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
|
|
import suwayomi.tachidesk.manga.impl.util.source.StubSource
|
|
import suwayomi.tachidesk.manga.impl.util.storage.ImageResponse.clearCachedImage
|
|
import suwayomi.tachidesk.manga.impl.util.storage.ImageResponse.getImageResponse
|
|
import suwayomi.tachidesk.manga.impl.util.storage.ImageUtil
|
|
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
|
|
import uy.kohesive.injekt.injectLazy
|
|
import java.io.File
|
|
import java.io.IOException
|
|
import java.io.InputStream
|
|
import java.time.Instant
|
|
|
|
private val logger = KotlinLogging.logger { }
|
|
|
|
object Manga {
|
|
suspend fun getManga(
|
|
mangaId: Int,
|
|
onlineFetch: Boolean = false,
|
|
): MangaDataClass {
|
|
var mangaEntry = transaction { MangaTable.selectAll().where { MangaTable.id eq mangaId }.first() }
|
|
|
|
return if (!onlineFetch && mangaEntry[MangaTable.initialized]) {
|
|
getMangaDataClass(mangaId, mangaEntry)
|
|
} else { // initialize manga
|
|
val sManga = fetchManga(mangaId) ?: return getMangaDataClass(mangaId, 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]),
|
|
meta = getMangaMetaMap(mangaId),
|
|
realUrl = mangaEntry[MangaTable.realUrl],
|
|
lastFetchedAt = mangaEntry[MangaTable.lastFetchedAt],
|
|
chaptersLastFetchedAt = mangaEntry[MangaTable.chaptersLastFetchedAt],
|
|
updateStrategy = UpdateStrategy.valueOf(mangaEntry[MangaTable.updateStrategy]),
|
|
freshData = true,
|
|
trackers = Track.getTrackRecordsByMangaId(mangaId),
|
|
)
|
|
}
|
|
}
|
|
|
|
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
|
|
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])
|
|
},
|
|
)
|
|
|
|
transaction {
|
|
MangaTable.update({ MangaTable.id eq mangaId }) {
|
|
val remoteTitle =
|
|
try {
|
|
sManga.title
|
|
} catch (_: UninitializedPropertyAccessException) {
|
|
""
|
|
}
|
|
if (remoteTitle.isNotEmpty() && remoteTitle != mangaEntry[MangaTable.title]) {
|
|
val canUpdateTitle = updateMangaDownloadDir(mangaId, remoteTitle)
|
|
|
|
if (canUpdateTitle) {
|
|
it[MangaTable.title] = remoteTitle
|
|
}
|
|
}
|
|
it[MangaTable.initialized] = true
|
|
|
|
it[MangaTable.artist] = sManga.artist ?: mangaEntry[MangaTable.artist]
|
|
it[MangaTable.author] = sManga.author ?: mangaEntry[MangaTable.author]
|
|
it[MangaTable.description] = sManga.description
|
|
?: mangaEntry[MangaTable.description]
|
|
it[MangaTable.genre] = sManga.genre ?: mangaEntry[MangaTable.genre]
|
|
it[MangaTable.status] = sManga.status
|
|
if (!sManga.thumbnail_url.isNullOrEmpty()) {
|
|
it[MangaTable.thumbnail_url] = sManga.thumbnail_url
|
|
it[MangaTable.thumbnailUrlLastFetched] = Instant.now().epochSecond
|
|
clearThumbnail(mangaId)
|
|
}
|
|
|
|
it[MangaTable.realUrl] =
|
|
runCatching {
|
|
(source as? HttpSource)?.getMangaUrl(
|
|
SManga.create().apply {
|
|
url = mangaEntry[MangaTable.url]
|
|
title = remoteTitle.ifEmpty { mangaEntry[MangaTable.title] }
|
|
thumbnail_url = mangaEntry[MangaTable.thumbnail_url]
|
|
artist = sManga.artist ?: mangaEntry[MangaTable.artist]
|
|
author = sManga.author ?: mangaEntry[MangaTable.author]
|
|
description = sManga.description ?: mangaEntry[MangaTable.description]
|
|
genre = sManga.genre ?: mangaEntry[MangaTable.genre]
|
|
status = sManga.status
|
|
update_strategy = sManga.update_strategy
|
|
},
|
|
)
|
|
}.getOrNull()
|
|
|
|
it[MangaTable.lastFetchedAt] = Instant.now().epochSecond
|
|
|
|
it[MangaTable.updateStrategy] = sManga.update_strategy.name
|
|
}
|
|
}
|
|
|
|
return sManga
|
|
}
|
|
|
|
suspend fun getMangaFull(
|
|
mangaId: Int,
|
|
onlineFetch: Boolean = false,
|
|
): MangaDataClass {
|
|
val mangaDaaClass = getManga(mangaId, onlineFetch)
|
|
|
|
return transaction {
|
|
val unreadCount =
|
|
ChapterTable
|
|
.selectAll()
|
|
.where { (ChapterTable.manga eq mangaId) and (ChapterTable.isRead eq false) }
|
|
.count()
|
|
|
|
val downloadCount =
|
|
ChapterTable
|
|
.selectAll()
|
|
.where { (ChapterTable.manga eq mangaId) and (ChapterTable.isDownloaded eq true) }
|
|
.count()
|
|
|
|
val chapterCount =
|
|
ChapterTable
|
|
.selectAll()
|
|
.where { (ChapterTable.manga eq mangaId) }
|
|
.count()
|
|
|
|
val lastChapterRead =
|
|
ChapterTable
|
|
.selectAll()
|
|
.where { (ChapterTable.manga eq mangaId) }
|
|
.orderBy(ChapterTable.sourceOrder to SortOrder.DESC)
|
|
.firstOrNull { it[ChapterTable.isRead] }
|
|
|
|
mangaDaaClass.unreadCount = unreadCount
|
|
mangaDaaClass.downloadCount = downloadCount
|
|
mangaDaaClass.chapterCount = chapterCount
|
|
mangaDaaClass.lastChapterRead = lastChapterRead?.let { ChapterTable.toDataClass(it) }
|
|
|
|
mangaDaaClass
|
|
}
|
|
}
|
|
|
|
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]),
|
|
meta = getMangaMetaMap(mangaId),
|
|
realUrl = mangaEntry[MangaTable.realUrl],
|
|
lastFetchedAt = mangaEntry[MangaTable.lastFetchedAt],
|
|
chaptersLastFetchedAt = mangaEntry[MangaTable.chaptersLastFetchedAt],
|
|
updateStrategy = UpdateStrategy.valueOf(mangaEntry[MangaTable.updateStrategy]),
|
|
freshData = false,
|
|
trackers = Track.getTrackRecordsByMangaId(mangaId),
|
|
)
|
|
|
|
fun getMangaMetaMap(mangaId: Int): Map<String, String> =
|
|
transaction {
|
|
MangaMetaTable
|
|
.selectAll()
|
|
.where { MangaMetaTable.ref eq mangaId }
|
|
.associate { it[MangaMetaTable.key] to it[MangaMetaTable.value] }
|
|
}
|
|
|
|
fun modifyMangaMeta(
|
|
mangaId: Int,
|
|
key: String,
|
|
value: String,
|
|
) {
|
|
modifyMangasMetas(mapOf(mangaId to mapOf(key to value)))
|
|
}
|
|
|
|
fun modifyMangasMetas(metaByMangaId: Map<Int, Map<String, String>>) {
|
|
transaction {
|
|
val mangaIds = metaByMangaId.keys
|
|
val metaKeys = metaByMangaId.flatMap { it.value.keys }
|
|
|
|
val dbMetaByMangaId =
|
|
MangaMetaTable
|
|
.selectAll()
|
|
.where { (MangaMetaTable.ref inList mangaIds) and (MangaMetaTable.key inList metaKeys) }
|
|
.groupBy { it[MangaMetaTable.ref].value }
|
|
|
|
val existingMetaByMetaId =
|
|
mangaIds.flatMap { mangaId ->
|
|
val metaByKey = dbMetaByMangaId[mangaId].orEmpty().associateBy { it[MangaMetaTable.key] }
|
|
val existingMetas = metaByMangaId[mangaId].orEmpty().filter { (key) -> key in metaByKey.keys }
|
|
|
|
existingMetas.map { entry ->
|
|
val metaId = metaByKey[entry.key]!![MangaMetaTable.id].value
|
|
|
|
metaId to entry
|
|
}
|
|
}
|
|
|
|
val newMetaByMangaId =
|
|
mangaIds.flatMap { mangaId ->
|
|
val metaByKey = dbMetaByMangaId[mangaId].orEmpty().associateBy { it[MangaMetaTable.key] }
|
|
|
|
metaByMangaId[mangaId]
|
|
.orEmpty()
|
|
.filter { entry -> entry.key !in metaByKey.keys }
|
|
.map { entry -> mangaId to entry }
|
|
}
|
|
|
|
if (existingMetaByMetaId.isNotEmpty()) {
|
|
BatchUpdateStatement(MangaMetaTable)
|
|
.apply {
|
|
existingMetaByMetaId.forEach { (metaId, entry) ->
|
|
addBatch(EntityID(metaId, MangaMetaTable))
|
|
this[MangaMetaTable.value] = entry.value
|
|
}
|
|
}.toExecutable()
|
|
.execute(this@transaction)
|
|
}
|
|
|
|
if (newMetaByMangaId.isNotEmpty()) {
|
|
MangaMetaTable.batchInsert(newMetaByMangaId) { (mangaId, entry) ->
|
|
this[MangaMetaTable.ref] = EntityID(mangaId, MangaTable)
|
|
this[MangaMetaTable.key] = entry.key
|
|
this[MangaMetaTable.value] = entry.value
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private suspend fun fetchThumbnailUrl(mangaId: Int): String? {
|
|
getManga(mangaId, true)
|
|
return transaction {
|
|
MangaTable.selectAll().where { MangaTable.id eq mangaId }.first()
|
|
}[MangaTable.thumbnail_url]
|
|
}
|
|
|
|
private val applicationDirs: ApplicationDirs by injectLazy()
|
|
private val network: NetworkHelper by injectLazy()
|
|
|
|
private suspend fun fetchHttpSourceMangaThumbnail(
|
|
source: HttpSource,
|
|
mangaEntry: ResultRow,
|
|
refreshUrl: Boolean = false,
|
|
): Response {
|
|
val mangaId = mangaEntry[MangaTable.id].value
|
|
|
|
val requiresInitialization = mangaEntry[MangaTable.thumbnail_url] == null && !mangaEntry[MangaTable.initialized]
|
|
val refreshThumbnailUrl = refreshUrl || requiresInitialization
|
|
|
|
val thumbnailUrl =
|
|
if (refreshThumbnailUrl) {
|
|
fetchThumbnailUrl(mangaId)
|
|
} else {
|
|
mangaEntry[MangaTable.thumbnail_url]
|
|
} ?: throw NullPointerException("No thumbnail found")
|
|
|
|
return try {
|
|
source.client
|
|
.newCall(
|
|
GET(thumbnailUrl, source.headers, cache = CacheControl.FORCE_NETWORK),
|
|
).awaitSuccess()
|
|
} catch (e: HttpException) {
|
|
val tryToRefreshUrl =
|
|
!refreshUrl &&
|
|
listOf(
|
|
HttpStatus.GONE.code,
|
|
HttpStatus.MOVED_PERMANENTLY.code,
|
|
HttpStatus.NOT_FOUND.code,
|
|
523, // (Cloudflare) Origin Is Unreachable
|
|
522, // (Cloudflare) Connection timed out
|
|
).contains(e.code)
|
|
if (!tryToRefreshUrl) {
|
|
throw e
|
|
}
|
|
|
|
fetchHttpSourceMangaThumbnail(source, mangaEntry, refreshUrl = true)
|
|
}
|
|
}
|
|
|
|
suspend fun fetchMangaThumbnail(mangaId: Int): Pair<InputStream, String> {
|
|
val cacheSaveDir = applicationDirs.tempThumbnailCacheRoot
|
|
val fileName = mangaId.toString()
|
|
|
|
val mangaEntry = transaction { MangaTable.selectAll().where { MangaTable.id eq mangaId }.first() }
|
|
val sourceId = mangaEntry[MangaTable.sourceReference]
|
|
|
|
return when (val source = getCatalogueSourceOrStub(sourceId)) {
|
|
is HttpSource -> {
|
|
getImageResponse(cacheSaveDir, fileName) {
|
|
fetchHttpSourceMangaThumbnail(source, mangaEntry)
|
|
}
|
|
}
|
|
|
|
is LocalSource -> {
|
|
val imageFile =
|
|
mangaEntry[MangaTable.thumbnail_url]?.let {
|
|
val file = File(it)
|
|
if (file.exists()) {
|
|
file
|
|
} else {
|
|
null
|
|
}
|
|
} ?: throw IOException("Thumbnail does not exist")
|
|
val contentType =
|
|
ImageUtil.findImageType { imageFile.inputStream() }?.mime
|
|
?: "image/jpeg"
|
|
imageFile.inputStream() to contentType
|
|
}
|
|
|
|
is StubSource -> {
|
|
getImageResponse(cacheSaveDir, fileName) {
|
|
val thumbnailUrl =
|
|
mangaEntry[MangaTable.thumbnail_url]
|
|
?: throw NullPointerException("No thumbnail found")
|
|
network.client
|
|
.newCall(
|
|
GET(thumbnailUrl, cache = CacheControl.FORCE_NETWORK),
|
|
).await()
|
|
}
|
|
}
|
|
|
|
else -> {
|
|
throw IllegalArgumentException("Unknown source")
|
|
}
|
|
}
|
|
}
|
|
|
|
suspend fun getMangaThumbnail(mangaId: Int): Pair<InputStream, String> {
|
|
val mangaEntry = transaction { MangaTable.selectAll().where { MangaTable.id eq mangaId }.first() }
|
|
|
|
if (mangaEntry[MangaTable.inLibrary] && mangaEntry[MangaTable.sourceReference] != LocalSource.ID) {
|
|
return try {
|
|
ThumbnailDownloadHelper.getImage(mangaId)
|
|
} catch (_: MissingThumbnailException) {
|
|
ThumbnailDownloadHelper.download(mangaId)
|
|
ThumbnailDownloadHelper.getImage(mangaId)
|
|
}
|
|
}
|
|
|
|
return fetchMangaThumbnail(mangaId)
|
|
}
|
|
|
|
fun clearThumbnail(mangaId: Int) {
|
|
val fileName = mangaId.toString()
|
|
|
|
clearCachedImage(applicationDirs.tempThumbnailCacheRoot, fileName)
|
|
clearCachedImage(applicationDirs.thumbnailDownloadsRoot, fileName)
|
|
}
|
|
|
|
fun getLatestChapter(mangaId: Int): ChapterDataClass? =
|
|
transaction {
|
|
ChapterTable.selectAll().where { ChapterTable.manga eq mangaId }.maxByOrNull { it[ChapterTable.sourceOrder] }
|
|
}?.let { ChapterTable.toDataClass(it) }
|
|
|
|
fun getUnreadChapters(mangaId: Int): List<ChapterDataClass> =
|
|
transaction {
|
|
ChapterTable
|
|
.selectAll()
|
|
.where { (ChapterTable.manga eq mangaId) and (ChapterTable.isRead eq false) }
|
|
.orderBy(ChapterTable.sourceOrder to SortOrder.DESC)
|
|
.map { ChapterTable.toDataClass(it) }
|
|
}
|
|
|
|
fun isInIncludedDownloadCategory(
|
|
logContext: KLogger = logger,
|
|
mangaId: Int,
|
|
): Boolean {
|
|
val log = KotlinLogging.logger("${logContext.name}::isInExcludedDownloadCategory($mangaId)")
|
|
|
|
// Verify the manga is configured to be downloaded based on it's categories.
|
|
var mangaCategories = CategoryManga.getMangaCategories(mangaId).toSet()
|
|
// if the manga has no categories, then it's implicitly in the default category
|
|
if (mangaCategories.isEmpty()) {
|
|
val defaultCategory = Category.getCategoryById(Category.DEFAULT_CATEGORY_ID)!!
|
|
mangaCategories = setOf(defaultCategory)
|
|
}
|
|
|
|
val downloadCategoriesMap = Category.getCategoryList().groupBy { it.includeInDownload }
|
|
val unsetCategories = downloadCategoriesMap[IncludeOrExclude.UNSET].orEmpty()
|
|
// We only download if it's in the include list, and not in the exclude list.
|
|
// Use the unset categories as the included categories if the included categories is
|
|
// empty
|
|
val includedCategories = downloadCategoriesMap[IncludeOrExclude.INCLUDE].orEmpty().ifEmpty { unsetCategories }
|
|
val excludedCategories = downloadCategoriesMap[IncludeOrExclude.EXCLUDE].orEmpty()
|
|
// Only download manga that aren't in any excluded categories
|
|
val mangaExcludeCategories = mangaCategories.intersect(excludedCategories.toSet())
|
|
if (mangaExcludeCategories.isNotEmpty()) {
|
|
log.debug { "download excluded by categories: '${mangaExcludeCategories.joinToString("', '") { it.name }}'" }
|
|
return false
|
|
}
|
|
val mangaDownloadCategories = mangaCategories.intersect(includedCategories.toSet())
|
|
if (mangaDownloadCategories.isNotEmpty()) {
|
|
log.debug { "download inluded by categories: '${mangaDownloadCategories.joinToString("', '") { it.name }}'" }
|
|
} else {
|
|
log.debug { "skipping download due to download categories configuration" }
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
}
|