mirror of
https://github.com/Suwayomi/Suwayomi-Server.git
synced 2026-07-13 15:54:35 -05:00
* Rename "DownloadedFilesProvider" to "ChaptersFilesProvider" * Move files into sub packages * Further abstract "DownloadedFilesProvider" * Rename "getCachedImageResponse" to "getImageResponse" * Extract getting cached image response into new function * Decouple thumbnail cache and download * Download and delete permanent thumbnails When adding/removing manga from/to the library make sure the permanent thumbnail files will get handled properly * Move thumbnail cache to actual temp folder * Rename "mangaDownloadsRoot" to "downloadRoot" * Move manga downloads into "mangas" subfolder * Clear downloaded thumbnail
311 lines
13 KiB
Kotlin
311 lines
13 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.NetworkHelper
|
|
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 org.jetbrains.exposed.sql.ResultRow
|
|
import org.jetbrains.exposed.sql.SortOrder
|
|
import org.jetbrains.exposed.sql.and
|
|
import org.jetbrains.exposed.sql.insert
|
|
import org.jetbrains.exposed.sql.select
|
|
import org.jetbrains.exposed.sql.transactions.transaction
|
|
import org.jetbrains.exposed.sql.update
|
|
import org.kodein.di.DI
|
|
import org.kodein.di.conf.global
|
|
import org.kodein.di.instance
|
|
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.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.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
|
|
|
|
object Manga {
|
|
private fun truncate(text: String?, maxLength: Int): String? {
|
|
return if (text?.length ?: 0 > maxLength) {
|
|
text?.take(maxLength - 3) + "..."
|
|
} else {
|
|
text
|
|
}
|
|
}
|
|
|
|
suspend fun getManga(mangaId: Int, onlineFetch: Boolean = false): MangaDataClass {
|
|
var mangaEntry = transaction { MangaTable.select { 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.select { 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
|
|
)
|
|
}
|
|
}
|
|
|
|
suspend fun fetchManga(mangaId: Int): SManga? {
|
|
val mangaEntry = transaction { MangaTable.select { MangaTable.id eq mangaId }.first() }
|
|
|
|
val source = getCatalogueSourceOrNull(mangaEntry[MangaTable.sourceReference])
|
|
?: return null
|
|
val sManga = SManga.create().apply {
|
|
url = mangaEntry[MangaTable.url]
|
|
title = mangaEntry[MangaTable.title]
|
|
}
|
|
val networkManga = source.getMangaDetails(sManga)
|
|
sManga.copyFrom(networkManga)
|
|
|
|
transaction {
|
|
MangaTable.update({ MangaTable.id eq mangaId }) {
|
|
if (sManga.title != mangaEntry[MangaTable.title]) {
|
|
val canUpdateTitle = updateMangaDownloadDir(mangaId, sManga.title)
|
|
|
|
if (canUpdateTitle) {
|
|
it[MangaTable.title] = sManga.title
|
|
}
|
|
}
|
|
it[MangaTable.initialized] = true
|
|
|
|
it[MangaTable.artist] = sManga.artist
|
|
it[MangaTable.author] = sManga.author
|
|
it[MangaTable.description] = truncate(sManga.description, 4096)
|
|
it[MangaTable.genre] = sManga.genre
|
|
it[MangaTable.status] = sManga.status
|
|
if (!sManga.thumbnail_url.isNullOrEmpty() && sManga.thumbnail_url != mangaEntry[MangaTable.thumbnail_url]) {
|
|
it[MangaTable.thumbnail_url] = sManga.thumbnail_url
|
|
it[MangaTable.thumbnailUrlLastFetched] = Instant.now().epochSecond
|
|
clearThumbnail(mangaId)
|
|
}
|
|
|
|
it[MangaTable.realUrl] = runCatching {
|
|
(source as? HttpSource)?.getMangaUrl(sManga)
|
|
}.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
|
|
.select { (ChapterTable.manga eq mangaId) and (ChapterTable.isRead eq false) }
|
|
.count()
|
|
|
|
val downloadCount =
|
|
ChapterTable
|
|
.select { (ChapterTable.manga eq mangaId) and (ChapterTable.isDownloaded eq true) }
|
|
.count()
|
|
|
|
val chapterCount =
|
|
ChapterTable
|
|
.select { (ChapterTable.manga eq mangaId) }
|
|
.count()
|
|
|
|
val lastChapterRead =
|
|
ChapterTable
|
|
.select { (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
|
|
)
|
|
|
|
fun getMangaMetaMap(mangaId: Int): Map<String, String> {
|
|
return transaction {
|
|
MangaMetaTable.select { MangaMetaTable.ref eq mangaId }
|
|
.associate { it[MangaMetaTable.key] to it[MangaMetaTable.value] }
|
|
}
|
|
}
|
|
|
|
fun modifyMangaMeta(mangaId: Int, key: String, value: String) {
|
|
transaction {
|
|
val meta =
|
|
MangaMetaTable.select { (MangaMetaTable.ref eq mangaId) and (MangaMetaTable.key eq key) }
|
|
.firstOrNull()
|
|
|
|
if (meta == null) {
|
|
MangaMetaTable.insert {
|
|
it[MangaMetaTable.key] = key
|
|
it[MangaMetaTable.value] = value
|
|
it[MangaMetaTable.ref] = mangaId
|
|
}
|
|
} else {
|
|
MangaMetaTable.update({ (MangaMetaTable.ref eq mangaId) and (MangaMetaTable.key eq key) }) {
|
|
it[MangaMetaTable.value] = value
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private val applicationDirs by DI.global.instance<ApplicationDirs>()
|
|
private val network: NetworkHelper by injectLazy()
|
|
|
|
suspend fun fetchMangaThumbnail(mangaId: Int): Pair<InputStream, String> {
|
|
val cacheSaveDir = applicationDirs.tempThumbnailCacheRoot
|
|
val fileName = mangaId.toString()
|
|
|
|
val mangaEntry = transaction { MangaTable.select { MangaTable.id eq mangaId }.first() }
|
|
val sourceId = mangaEntry[MangaTable.sourceReference]
|
|
|
|
return when (val source = getCatalogueSourceOrStub(sourceId)) {
|
|
is HttpSource -> getImageResponse(cacheSaveDir, fileName) {
|
|
val thumbnailUrl = mangaEntry[MangaTable.thumbnail_url]
|
|
?: if (!mangaEntry[MangaTable.initialized]) {
|
|
// initialize then try again
|
|
getManga(mangaId)
|
|
transaction {
|
|
MangaTable.select { MangaTable.id eq mangaId }.first()
|
|
}[MangaTable.thumbnail_url]!!
|
|
} else {
|
|
// source provides no thumbnail url for this manga
|
|
throw NullPointerException("No thumbnail found")
|
|
}
|
|
|
|
source.client.newCall(
|
|
GET(thumbnailUrl, source.headers)
|
|
).await()
|
|
}
|
|
|
|
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)
|
|
).await()
|
|
}
|
|
|
|
else -> throw IllegalArgumentException("Unknown source")
|
|
}
|
|
}
|
|
|
|
suspend fun getMangaThumbnail(mangaId: Int): Pair<InputStream, String> {
|
|
val mangaEntry = transaction { MangaTable.select { MangaTable.id eq mangaId }.first() }
|
|
|
|
if (mangaEntry[MangaTable.inLibrary]) {
|
|
return try {
|
|
ThumbnailDownloadHelper.getImage(mangaId)
|
|
} catch (_: MissingThumbnailException) {
|
|
ThumbnailDownloadHelper.download(mangaId)
|
|
ThumbnailDownloadHelper.getImage(mangaId)
|
|
}
|
|
}
|
|
|
|
return fetchMangaThumbnail(mangaId)
|
|
}
|
|
|
|
private fun clearThumbnail(mangaId: Int) {
|
|
val fileName = mangaId.toString()
|
|
|
|
clearCachedImage(applicationDirs.tempThumbnailCacheRoot, fileName)
|
|
clearCachedImage(applicationDirs.thumbnailDownloadsRoot, fileName)
|
|
}
|
|
}
|