mirror of
https://github.com/Suwayomi/Suwayomi-Server.git
synced 2026-07-12 15:24:33 -05:00
Switch to a new Ktlint Formatter (#705)
* Switch to new Ktlint plugin * Add ktlintCheck to PR builds * Run formatter * Put ktlint version in libs toml * Fix lint * Use Zip4Java from libs.toml
This commit is contained in:
@@ -35,10 +35,11 @@ object Category {
|
||||
|
||||
return transaction {
|
||||
if (CategoryTable.select { CategoryTable.name eq name }.firstOrNull() == null) {
|
||||
val newCategoryId = CategoryTable.insertAndGetId {
|
||||
it[CategoryTable.name] = name
|
||||
it[CategoryTable.order] = Int.MAX_VALUE
|
||||
}.value
|
||||
val newCategoryId =
|
||||
CategoryTable.insertAndGetId {
|
||||
it[CategoryTable.name] = name
|
||||
it[CategoryTable.order] = Int.MAX_VALUE
|
||||
}.value
|
||||
|
||||
normalizeCategories()
|
||||
|
||||
@@ -49,10 +50,20 @@ object Category {
|
||||
}
|
||||
}
|
||||
|
||||
fun updateCategory(categoryId: Int, name: String?, isDefault: Boolean?, includeInUpdate: Int?) {
|
||||
fun updateCategory(
|
||||
categoryId: Int,
|
||||
name: String?,
|
||||
isDefault: Boolean?,
|
||||
includeInUpdate: Int?,
|
||||
) {
|
||||
transaction {
|
||||
CategoryTable.update({ CategoryTable.id eq categoryId }) {
|
||||
if (categoryId != DEFAULT_CATEGORY_ID && name != null && !name.equals(DEFAULT_CATEGORY_NAME, ignoreCase = true)) it[CategoryTable.name] = name
|
||||
if (
|
||||
categoryId != DEFAULT_CATEGORY_ID && name != null &&
|
||||
!name.equals(DEFAULT_CATEGORY_NAME, ignoreCase = true)
|
||||
) {
|
||||
it[CategoryTable.name] = name
|
||||
}
|
||||
if (categoryId != DEFAULT_CATEGORY_ID && isDefault != null) it[CategoryTable.isDefault] = isDefault
|
||||
if (includeInUpdate != null) it[CategoryTable.includeInUpdate] = includeInUpdate
|
||||
}
|
||||
@@ -62,10 +73,16 @@ object Category {
|
||||
/**
|
||||
* Move the category from order number `from` to `to`
|
||||
*/
|
||||
fun reorderCategory(from: Int, to: Int) {
|
||||
fun reorderCategory(
|
||||
from: Int,
|
||||
to: Int,
|
||||
) {
|
||||
if (from == 0 || to == 0) return
|
||||
transaction {
|
||||
val categories = CategoryTable.select { CategoryTable.id neq DEFAULT_CATEGORY_ID }.orderBy(CategoryTable.order to SortOrder.ASC).toMutableList()
|
||||
val categories =
|
||||
CategoryTable.select {
|
||||
CategoryTable.id neq DEFAULT_CATEGORY_ID
|
||||
}.orderBy(CategoryTable.order to SortOrder.ASC).toMutableList()
|
||||
categories.add(to - 1, categories.removeAt(from - 1))
|
||||
categories.forEachIndexed { index, cat ->
|
||||
CategoryTable.update({ CategoryTable.id eq cat[CategoryTable.id].value }) {
|
||||
@@ -98,14 +115,15 @@ object Category {
|
||||
}
|
||||
}
|
||||
|
||||
private fun needsDefaultCategory() = transaction {
|
||||
MangaTable
|
||||
.leftJoin(CategoryMangaTable)
|
||||
.select { MangaTable.inLibrary eq true }
|
||||
.andWhere { CategoryMangaTable.manga.isNull() }
|
||||
.empty()
|
||||
.not()
|
||||
}
|
||||
private fun needsDefaultCategory() =
|
||||
transaction {
|
||||
MangaTable
|
||||
.leftJoin(CategoryMangaTable)
|
||||
.select { MangaTable.inLibrary eq true }
|
||||
.andWhere { CategoryMangaTable.manga.isNull() }
|
||||
.empty()
|
||||
.not()
|
||||
}
|
||||
|
||||
const val DEFAULT_CATEGORY_ID = 0
|
||||
const val DEFAULT_CATEGORY_NAME = "Default"
|
||||
@@ -156,11 +174,16 @@ object Category {
|
||||
}
|
||||
}
|
||||
|
||||
fun modifyMeta(categoryId: Int, key: String, value: String) {
|
||||
fun modifyMeta(
|
||||
categoryId: Int,
|
||||
key: String,
|
||||
value: String,
|
||||
) {
|
||||
transaction {
|
||||
val meta = transaction {
|
||||
CategoryMetaTable.select { (CategoryMetaTable.ref eq categoryId) and (CategoryMetaTable.key eq key) }
|
||||
}.firstOrNull()
|
||||
val meta =
|
||||
transaction {
|
||||
CategoryMetaTable.select { (CategoryMetaTable.ref eq categoryId) and (CategoryMetaTable.key eq key) }
|
||||
}.firstOrNull()
|
||||
|
||||
if (meta == null) {
|
||||
CategoryMetaTable.insert {
|
||||
|
||||
@@ -31,9 +31,16 @@ import suwayomi.tachidesk.manga.model.table.MangaTable
|
||||
import suwayomi.tachidesk.manga.model.table.toDataClass
|
||||
|
||||
object CategoryManga {
|
||||
fun addMangaToCategory(mangaId: Int, categoryId: Int) {
|
||||
fun addMangaToCategory(
|
||||
mangaId: Int,
|
||||
categoryId: Int,
|
||||
) {
|
||||
if (categoryId == DEFAULT_CATEGORY_ID) return
|
||||
fun notAlreadyInCategory() = CategoryMangaTable.select { (CategoryMangaTable.category eq categoryId) and (CategoryMangaTable.manga eq mangaId) }.isEmpty()
|
||||
|
||||
fun notAlreadyInCategory() =
|
||||
CategoryMangaTable.select {
|
||||
(CategoryMangaTable.category eq categoryId) and (CategoryMangaTable.manga eq mangaId)
|
||||
}.isEmpty()
|
||||
|
||||
transaction {
|
||||
if (notAlreadyInCategory()) {
|
||||
@@ -45,7 +52,10 @@ object CategoryManga {
|
||||
}
|
||||
}
|
||||
|
||||
fun removeMangaFromCategory(mangaId: Int, categoryId: Int) {
|
||||
fun removeMangaFromCategory(
|
||||
mangaId: Int,
|
||||
categoryId: Int,
|
||||
) {
|
||||
if (categoryId == DEFAULT_CATEGORY_ID) return
|
||||
transaction {
|
||||
CategoryMangaTable.deleteWhere { (CategoryMangaTable.category eq categoryId) and (CategoryMangaTable.manga eq mangaId) }
|
||||
@@ -57,12 +67,18 @@ object CategoryManga {
|
||||
*/
|
||||
fun getCategoryMangaList(categoryId: Int): List<MangaDataClass> {
|
||||
// Select the required columns from the MangaTable and add the aggregate functions to compute unread, download, and chapter counts
|
||||
val unreadCount = wrapAsExpression<Long>(
|
||||
ChapterTable.slice(ChapterTable.id.count()).select((ChapterTable.isRead eq false) and (ChapterTable.manga eq MangaTable.id))
|
||||
)
|
||||
val downloadedCount = wrapAsExpression<Long>(
|
||||
ChapterTable.slice(ChapterTable.id.count()).select((ChapterTable.isDownloaded eq true) and (ChapterTable.manga eq MangaTable.id))
|
||||
)
|
||||
val unreadCount =
|
||||
wrapAsExpression<Long>(
|
||||
ChapterTable.slice(
|
||||
ChapterTable.id.count(),
|
||||
).select((ChapterTable.isRead eq false) and (ChapterTable.manga eq MangaTable.id)),
|
||||
)
|
||||
val downloadedCount =
|
||||
wrapAsExpression<Long>(
|
||||
ChapterTable.slice(
|
||||
ChapterTable.id.count(),
|
||||
).select((ChapterTable.isDownloaded eq true) and (ChapterTable.manga eq MangaTable.id)),
|
||||
)
|
||||
|
||||
val chapterCount = ChapterTable.id.count().alias("chapter_count")
|
||||
val lastReadAt = ChapterTable.lastReadAt.max().alias("last_read_at")
|
||||
@@ -80,19 +96,20 @@ object CategoryManga {
|
||||
|
||||
return transaction {
|
||||
// Fetch data from the MangaTable and join with the CategoryMangaTable, if a category is specified
|
||||
val query = if (categoryId == DEFAULT_CATEGORY_ID) {
|
||||
MangaTable
|
||||
.leftJoin(ChapterTable, { MangaTable.id }, { ChapterTable.manga })
|
||||
.leftJoin(CategoryMangaTable)
|
||||
.slice(columns = selectedColumns)
|
||||
.select { (MangaTable.inLibrary eq true) and CategoryMangaTable.category.isNull() }
|
||||
} else {
|
||||
MangaTable
|
||||
.innerJoin(CategoryMangaTable)
|
||||
.leftJoin(ChapterTable, { MangaTable.id }, { ChapterTable.manga })
|
||||
.slice(columns = selectedColumns)
|
||||
.select { (MangaTable.inLibrary eq true) and (CategoryMangaTable.category eq categoryId) }
|
||||
}
|
||||
val query =
|
||||
if (categoryId == DEFAULT_CATEGORY_ID) {
|
||||
MangaTable
|
||||
.leftJoin(ChapterTable, { MangaTable.id }, { ChapterTable.manga })
|
||||
.leftJoin(CategoryMangaTable)
|
||||
.slice(columns = selectedColumns)
|
||||
.select { (MangaTable.inLibrary eq true) and CategoryMangaTable.category.isNull() }
|
||||
} else {
|
||||
MangaTable
|
||||
.innerJoin(CategoryMangaTable)
|
||||
.leftJoin(ChapterTable, { MangaTable.id }, { ChapterTable.manga })
|
||||
.slice(columns = selectedColumns)
|
||||
.select { (MangaTable.inLibrary eq true) and (CategoryMangaTable.category eq categoryId) }
|
||||
}
|
||||
|
||||
// Join with the ChapterTable to fetch the last read chapter for each manga
|
||||
query.groupBy(*MangaTable.columns.toTypedArray()).map(transform)
|
||||
@@ -104,7 +121,9 @@ object CategoryManga {
|
||||
*/
|
||||
fun getMangaCategories(mangaId: Int): List<CategoryDataClass> {
|
||||
return transaction {
|
||||
CategoryMangaTable.innerJoin(CategoryTable).select { CategoryMangaTable.manga eq mangaId }.orderBy(CategoryTable.order to SortOrder.ASC).map {
|
||||
CategoryMangaTable.innerJoin(CategoryTable).select {
|
||||
CategoryMangaTable.manga eq mangaId
|
||||
}.orderBy(CategoryTable.order to SortOrder.ASC).map {
|
||||
CategoryTable.toDataClass(it)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +45,10 @@ object Chapter {
|
||||
private val logger = KotlinLogging.logger { }
|
||||
|
||||
/** get chapter list when showing a manga */
|
||||
suspend fun getChapterList(mangaId: Int, onlineFetch: Boolean = false): List<ChapterDataClass> {
|
||||
suspend fun getChapterList(
|
||||
mangaId: Int,
|
||||
onlineFetch: Boolean = false,
|
||||
): List<ChapterDataClass> {
|
||||
return if (onlineFetch) {
|
||||
getSourceChapters(mangaId)
|
||||
} else {
|
||||
@@ -68,10 +71,11 @@ object Chapter {
|
||||
private suspend fun getSourceChapters(mangaId: Int): List<ChapterDataClass> {
|
||||
val chapterList = fetchChapterList(mangaId)
|
||||
|
||||
val dbChapterMap = transaction {
|
||||
ChapterTable.select { ChapterTable.manga eq mangaId }
|
||||
.associateBy({ it[ChapterTable.url] }, { it })
|
||||
}
|
||||
val dbChapterMap =
|
||||
transaction {
|
||||
ChapterTable.select { ChapterTable.manga eq mangaId }
|
||||
.associateBy({ it[ChapterTable.url] }, { it })
|
||||
}
|
||||
|
||||
val chapterIds = chapterList.map { dbChapterMap.getValue(it.url)[ChapterTable.id] }
|
||||
val chapterMetas = getChaptersMetaMaps(chapterIds)
|
||||
@@ -88,21 +92,17 @@ object Chapter {
|
||||
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],
|
||||
|
||||
chapterCount = chapterList.size,
|
||||
meta = chapterMetas.getValue(dbChapter[ChapterTable.id])
|
||||
meta = chapterMetas.getValue(dbChapter[ChapterTable.id]),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -111,10 +111,11 @@ object Chapter {
|
||||
val manga = getManga(mangaId)
|
||||
val source = getCatalogueSourceOrStub(manga.sourceId.toLong())
|
||||
|
||||
val sManga = SManga.create().apply {
|
||||
title = manga.title
|
||||
url = manga.url
|
||||
}
|
||||
val sManga =
|
||||
SManga.create().apply {
|
||||
title = manga.title
|
||||
url = manga.url
|
||||
}
|
||||
|
||||
val numberOfCurrentChapters = getCountOfMangaChapters(mangaId)
|
||||
val chapterList = source.getChapterList(sManga)
|
||||
@@ -143,9 +144,10 @@ object Chapter {
|
||||
it[fetchedAt] = now++
|
||||
it[ChapterTable.manga] = mangaId
|
||||
|
||||
it[realUrl] = runCatching {
|
||||
(source as? HttpSource)?.getChapterUrl(fetchedChapter)
|
||||
}.getOrNull()
|
||||
it[realUrl] =
|
||||
runCatching {
|
||||
(source as? HttpSource)?.getChapterUrl(fetchedChapter)
|
||||
}.getOrNull()
|
||||
}
|
||||
} else {
|
||||
ChapterTable.update({ ChapterTable.url eq fetchedChapter.url }) {
|
||||
@@ -157,9 +159,10 @@ object Chapter {
|
||||
it[sourceOrder] = index + 1
|
||||
it[ChapterTable.manga] = mangaId
|
||||
|
||||
it[realUrl] = runCatching {
|
||||
(source as? HttpSource)?.getChapterUrl(fetchedChapter)
|
||||
}.getOrNull()
|
||||
it[realUrl] =
|
||||
runCatching {
|
||||
(source as? HttpSource)?.getChapterUrl(fetchedChapter)
|
||||
}.getOrNull()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -169,18 +172,20 @@ object Chapter {
|
||||
}
|
||||
}
|
||||
|
||||
val newChapters = transaction {
|
||||
ChapterTable.select { ChapterTable.manga eq mangaId }
|
||||
.orderBy(ChapterTable.sourceOrder to SortOrder.DESC).toList()
|
||||
}
|
||||
val newChapters =
|
||||
transaction {
|
||||
ChapterTable.select { ChapterTable.manga eq mangaId }
|
||||
.orderBy(ChapterTable.sourceOrder to SortOrder.DESC).toList()
|
||||
}
|
||||
|
||||
// clear any orphaned/duplicate chapters that are in the db but not in `chapterList`
|
||||
val dbChapterCount = newChapters.count()
|
||||
if (dbChapterCount > chapterList.size) { // we got some clean up due
|
||||
val dbChapterList = transaction {
|
||||
ChapterTable.select { ChapterTable.manga eq mangaId }
|
||||
.orderBy(ChapterTable.url to ASC).toList()
|
||||
}
|
||||
val dbChapterList =
|
||||
transaction {
|
||||
ChapterTable.select { ChapterTable.manga eq mangaId }
|
||||
.orderBy(ChapterTable.url to ASC).toList()
|
||||
}
|
||||
val chapterUrls = chapterList.map { it.url }.toSet()
|
||||
|
||||
dbChapterList.forEachIndexed { index, dbChapter ->
|
||||
@@ -203,7 +208,11 @@ object Chapter {
|
||||
return chapterList
|
||||
}
|
||||
|
||||
private fun downloadNewChapters(mangaId: Int, prevNumberOfChapters: Int, updatedChapterList: List<ResultRow>) {
|
||||
private fun downloadNewChapters(
|
||||
mangaId: Int,
|
||||
prevNumberOfChapters: Int,
|
||||
updatedChapterList: List<ResultRow>,
|
||||
) {
|
||||
// convert numbers to be index based
|
||||
val currentNumberOfChapters = (prevNumberOfChapters - 1).coerceAtLeast(0)
|
||||
val updatedNumberOfChapters = (updatedChapterList.size - 1).coerceAtLeast(0)
|
||||
@@ -224,8 +233,9 @@ object Chapter {
|
||||
// make sure to only consider the latest chapters. e.g. old unread chapters should be ignored
|
||||
val latestReadChapterIndex =
|
||||
updatedChapterList.indexOfFirst { it[ChapterTable.isRead] }.takeIf { it > -1 } ?: return
|
||||
val unreadChapters = updatedChapterList.subList(numberOfNewChapters, latestReadChapterIndex)
|
||||
.filter { !it[ChapterTable.isRead] }
|
||||
val unreadChapters =
|
||||
updatedChapterList.subList(numberOfNewChapters, latestReadChapterIndex)
|
||||
.filter { !it[ChapterTable.isRead] }
|
||||
|
||||
val skipDueToUnreadChapters = serverConfig.excludeEntryWithUnreadChapters.value && unreadChapters.isNotEmpty()
|
||||
if (skipDueToUnreadChapters) {
|
||||
@@ -235,9 +245,10 @@ object Chapter {
|
||||
val firstChapterToDownloadIndex =
|
||||
(numberOfNewChapters - serverConfig.autoDownloadAheadLimit.value).coerceAtLeast(0)
|
||||
|
||||
val chapterIdsToDownload = newChapters.subList(firstChapterToDownloadIndex, numberOfNewChapters)
|
||||
.filter { !it[ChapterTable.isRead] && !it[ChapterTable.isDownloaded] }
|
||||
.map { it[ChapterTable.id].value }
|
||||
val chapterIdsToDownload =
|
||||
newChapters.subList(firstChapterToDownloadIndex, numberOfNewChapters)
|
||||
.filter { !it[ChapterTable.isRead] && !it[ChapterTable.isDownloaded] }
|
||||
.map { it[ChapterTable.id].value }
|
||||
|
||||
if (chapterIdsToDownload.isEmpty()) {
|
||||
return
|
||||
@@ -254,7 +265,7 @@ object Chapter {
|
||||
isRead: Boolean?,
|
||||
isBookmarked: Boolean?,
|
||||
markPrevRead: Boolean?,
|
||||
lastPageRead: Int?
|
||||
lastPageRead: Int?,
|
||||
) {
|
||||
transaction {
|
||||
if (listOf(isRead, isBookmarked, lastPageRead).any { it != null }) {
|
||||
@@ -285,23 +296,26 @@ object Chapter {
|
||||
val isRead: Boolean? = null,
|
||||
val isBookmarked: Boolean? = null,
|
||||
val lastPageRead: Int? = null,
|
||||
val delete: Boolean? = null
|
||||
val delete: Boolean? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MangaChapterBatchEditInput(
|
||||
val chapterIds: List<Int>? = null,
|
||||
val chapterIndexes: List<Int>? = null,
|
||||
val change: ChapterChange?
|
||||
val change: ChapterChange?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ChapterBatchEditInput(
|
||||
val chapterIds: List<Int>? = null,
|
||||
val change: ChapterChange?
|
||||
val change: ChapterChange?,
|
||||
)
|
||||
|
||||
fun modifyChapters(input: MangaChapterBatchEditInput, mangaId: Int? = null) {
|
||||
fun modifyChapters(
|
||||
input: MangaChapterBatchEditInput,
|
||||
mangaId: Int? = null,
|
||||
) {
|
||||
// Make sure change is defined
|
||||
if (input.change == null) return
|
||||
val (isRead, isBookmarked, lastPageRead, delete) = input.change
|
||||
@@ -315,25 +329,26 @@ object Chapter {
|
||||
if (listOfNotNull(isRead, isBookmarked, lastPageRead).isEmpty()) return
|
||||
|
||||
// Make sure some filter is defined
|
||||
val condition = when {
|
||||
mangaId != null ->
|
||||
// mangaId is not null, scope query under manga
|
||||
when {
|
||||
input.chapterIds != null ->
|
||||
Op.build { (ChapterTable.manga eq mangaId) and (ChapterTable.id inList input.chapterIds) }
|
||||
input.chapterIndexes != null ->
|
||||
Op.build { (ChapterTable.manga eq mangaId) and (ChapterTable.sourceOrder inList input.chapterIndexes) }
|
||||
else -> null
|
||||
val condition =
|
||||
when {
|
||||
mangaId != null ->
|
||||
// mangaId is not null, scope query under manga
|
||||
when {
|
||||
input.chapterIds != null ->
|
||||
Op.build { (ChapterTable.manga eq mangaId) and (ChapterTable.id inList input.chapterIds) }
|
||||
input.chapterIndexes != null ->
|
||||
Op.build { (ChapterTable.manga eq mangaId) and (ChapterTable.sourceOrder inList input.chapterIndexes) }
|
||||
else -> null
|
||||
}
|
||||
else -> {
|
||||
// mangaId is null, only chapterIndexes is valid for this case
|
||||
when {
|
||||
input.chapterIds != null ->
|
||||
Op.build { (ChapterTable.id inList input.chapterIds) }
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
// mangaId is null, only chapterIndexes is valid for this case
|
||||
when {
|
||||
input.chapterIds != null ->
|
||||
Op.build { (ChapterTable.id inList input.chapterIds) }
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
} ?: return
|
||||
} ?: return
|
||||
|
||||
transaction {
|
||||
val now = Instant.now().epochSecond
|
||||
@@ -368,7 +383,12 @@ object Chapter {
|
||||
}
|
||||
}
|
||||
|
||||
fun modifyChapterMeta(mangaId: Int, chapterIndex: Int, key: String, value: String) {
|
||||
fun modifyChapterMeta(
|
||||
mangaId: Int,
|
||||
chapterIndex: Int,
|
||||
key: String,
|
||||
value: String,
|
||||
) {
|
||||
transaction {
|
||||
val chapterId =
|
||||
ChapterTable.select { (ChapterTable.manga eq mangaId) and (ChapterTable.sourceOrder eq chapterIndex) }
|
||||
@@ -377,7 +397,11 @@ object Chapter {
|
||||
}
|
||||
}
|
||||
|
||||
fun modifyChapterMeta(chapterId: Int, key: String, value: String) {
|
||||
fun modifyChapterMeta(
|
||||
chapterId: Int,
|
||||
key: String,
|
||||
value: String,
|
||||
) {
|
||||
transaction {
|
||||
val meta =
|
||||
ChapterMetaTable.select { (ChapterMetaTable.ref eq chapterId) and (ChapterMetaTable.key eq key) }
|
||||
@@ -397,7 +421,10 @@ object Chapter {
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteChapter(mangaId: Int, chapterIndex: Int) {
|
||||
fun deleteChapter(
|
||||
mangaId: Int,
|
||||
chapterIndex: Int,
|
||||
) {
|
||||
transaction {
|
||||
val chapterId =
|
||||
ChapterTable.select { (ChapterTable.manga eq mangaId) and (ChapterTable.sourceOrder eq chapterIndex) }
|
||||
@@ -411,19 +438,23 @@ object Chapter {
|
||||
}
|
||||
}
|
||||
|
||||
private fun deleteChapters(input: MangaChapterBatchEditInput, mangaId: Int? = null) {
|
||||
private fun deleteChapters(
|
||||
input: MangaChapterBatchEditInput,
|
||||
mangaId: Int? = null,
|
||||
) {
|
||||
if (input.chapterIds != null) {
|
||||
deleteChapters(input.chapterIds)
|
||||
} else if (input.chapterIndexes != null && mangaId != null) {
|
||||
transaction {
|
||||
val chapterIds = ChapterTable.slice(ChapterTable.manga, ChapterTable.id)
|
||||
.select { (ChapterTable.sourceOrder inList input.chapterIndexes) and (ChapterTable.manga eq mangaId) }
|
||||
.map { row ->
|
||||
val chapterId = row[ChapterTable.id].value
|
||||
ChapterDownloadHelper.delete(mangaId, chapterId)
|
||||
val chapterIds =
|
||||
ChapterTable.slice(ChapterTable.manga, ChapterTable.id)
|
||||
.select { (ChapterTable.sourceOrder inList input.chapterIndexes) and (ChapterTable.manga eq mangaId) }
|
||||
.map { row ->
|
||||
val chapterId = row[ChapterTable.id].value
|
||||
ChapterDownloadHelper.delete(mangaId, chapterId)
|
||||
|
||||
chapterId
|
||||
}
|
||||
chapterId
|
||||
}
|
||||
|
||||
ChapterTable.update({ ChapterTable.id inList chapterIds }) {
|
||||
it[isDownloaded] = false
|
||||
@@ -457,7 +488,7 @@ object Chapter {
|
||||
.map {
|
||||
MangaChapterDataClass(
|
||||
MangaTable.toDataClass(it),
|
||||
ChapterTable.toDataClass(it)
|
||||
ChapterTable.toDataClass(it),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,11 +12,18 @@ import java.io.File
|
||||
import java.io.InputStream
|
||||
|
||||
object ChapterDownloadHelper {
|
||||
fun getImage(mangaId: Int, chapterId: Int, index: Int): Pair<InputStream, String> {
|
||||
fun getImage(
|
||||
mangaId: Int,
|
||||
chapterId: Int,
|
||||
index: Int,
|
||||
): Pair<InputStream, String> {
|
||||
return provider(mangaId, chapterId).getImage().execute(index)
|
||||
}
|
||||
|
||||
fun delete(mangaId: Int, chapterId: Int): Boolean {
|
||||
fun delete(
|
||||
mangaId: Int,
|
||||
chapterId: Int,
|
||||
): Boolean {
|
||||
return provider(mangaId, chapterId).delete()
|
||||
}
|
||||
|
||||
@@ -25,13 +32,16 @@ object ChapterDownloadHelper {
|
||||
chapterId: Int,
|
||||
download: DownloadChapter,
|
||||
scope: CoroutineScope,
|
||||
step: suspend (DownloadChapter?, Boolean) -> Unit
|
||||
step: suspend (DownloadChapter?, Boolean) -> Unit,
|
||||
): Boolean {
|
||||
return provider(mangaId, chapterId).download().execute(download, scope, step)
|
||||
}
|
||||
|
||||
// return the appropriate provider based on how the download was saved. For the logic is simple but will evolve when new types of downloads are available
|
||||
private fun provider(mangaId: Int, chapterId: Int): ChaptersFilesProvider {
|
||||
private fun provider(
|
||||
mangaId: Int,
|
||||
chapterId: Int,
|
||||
): ChaptersFilesProvider {
|
||||
val chapterFolder = File(getChapterDownloadPath(mangaId, chapterId))
|
||||
val cbzFile = File(getChapterCbzPath(mangaId, chapterId))
|
||||
if (cbzFile.exists()) return ArchiveProvider(mangaId, chapterId)
|
||||
|
||||
@@ -29,9 +29,10 @@ object Library {
|
||||
val manga = getManga(mangaId)
|
||||
if (!manga.inLibrary) {
|
||||
transaction {
|
||||
val defaultCategories = CategoryTable.select {
|
||||
(CategoryTable.isDefault eq true) and (CategoryTable.id neq Category.DEFAULT_CATEGORY_ID)
|
||||
}.toList()
|
||||
val defaultCategories =
|
||||
CategoryTable.select {
|
||||
(CategoryTable.isDefault eq true) and (CategoryTable.id neq Category.DEFAULT_CATEGORY_ID)
|
||||
}.toList()
|
||||
val existingCategories = CategoryMangaTable.select { CategoryMangaTable.manga eq mangaId }.toList()
|
||||
|
||||
MangaTable.update({ MangaTable.id eq manga.id }) {
|
||||
@@ -66,7 +67,10 @@ object Library {
|
||||
}
|
||||
}
|
||||
|
||||
fun handleMangaThumbnail(mangaId: Int, inLibrary: Boolean) {
|
||||
fun handleMangaThumbnail(
|
||||
mangaId: Int,
|
||||
inLibrary: Boolean,
|
||||
) {
|
||||
scope.launch {
|
||||
try {
|
||||
if (inLibrary) {
|
||||
|
||||
@@ -58,7 +58,10 @@ import java.util.concurrent.ConcurrentHashMap
|
||||
private val logger = KotlinLogging.logger { }
|
||||
|
||||
object Manga {
|
||||
private fun truncate(text: String?, maxLength: Int): String? {
|
||||
private fun truncate(
|
||||
text: String?,
|
||||
maxLength: Int,
|
||||
): String? {
|
||||
return if (text?.length ?: 0 > maxLength) {
|
||||
text?.take(maxLength - 3) + "..."
|
||||
} else {
|
||||
@@ -66,7 +69,10 @@ object Manga {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getManga(mangaId: Int, onlineFetch: Boolean = false): MangaDataClass {
|
||||
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]) {
|
||||
@@ -79,14 +85,11 @@ object Manga {
|
||||
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,
|
||||
@@ -100,7 +103,7 @@ object Manga {
|
||||
lastFetchedAt = mangaEntry[MangaTable.lastFetchedAt],
|
||||
chaptersLastFetchedAt = mangaEntry[MangaTable.chaptersLastFetchedAt],
|
||||
updateStrategy = UpdateStrategy.valueOf(mangaEntry[MangaTable.updateStrategy]),
|
||||
freshData = true
|
||||
freshData = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -108,12 +111,14 @@ object Manga {
|
||||
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 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)
|
||||
|
||||
@@ -139,9 +144,10 @@ object Manga {
|
||||
clearThumbnail(mangaId)
|
||||
}
|
||||
|
||||
it[MangaTable.realUrl] = runCatching {
|
||||
(source as? HttpSource)?.getMangaUrl(sManga)
|
||||
}.getOrNull()
|
||||
it[MangaTable.realUrl] =
|
||||
runCatching {
|
||||
(source as? HttpSource)?.getMangaUrl(sManga)
|
||||
}.getOrNull()
|
||||
|
||||
it[MangaTable.lastFetchedAt] = Instant.now().epochSecond
|
||||
|
||||
@@ -152,7 +158,10 @@ object Manga {
|
||||
return sManga
|
||||
}
|
||||
|
||||
suspend fun getMangaFull(mangaId: Int, onlineFetch: Boolean = false): MangaDataClass {
|
||||
suspend fun getMangaFull(
|
||||
mangaId: Int,
|
||||
onlineFetch: Boolean = false,
|
||||
): MangaDataClass {
|
||||
val mangaDaaClass = getManga(mangaId, onlineFetch)
|
||||
|
||||
return transaction {
|
||||
@@ -186,17 +195,17 @@ object Manga {
|
||||
}
|
||||
}
|
||||
|
||||
private fun getMangaDataClass(mangaId: Int, mangaEntry: ResultRow) = MangaDataClass(
|
||||
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],
|
||||
@@ -210,7 +219,7 @@ object Manga {
|
||||
lastFetchedAt = mangaEntry[MangaTable.lastFetchedAt],
|
||||
chaptersLastFetchedAt = mangaEntry[MangaTable.chaptersLastFetchedAt],
|
||||
updateStrategy = UpdateStrategy.valueOf(mangaEntry[MangaTable.updateStrategy]),
|
||||
freshData = false
|
||||
freshData = false,
|
||||
)
|
||||
|
||||
fun getMangaMetaMap(mangaId: Int): Map<String, String> {
|
||||
@@ -220,7 +229,11 @@ object Manga {
|
||||
}
|
||||
}
|
||||
|
||||
fun modifyMangaMeta(mangaId: Int, key: String, value: String) {
|
||||
fun modifyMangaMeta(
|
||||
mangaId: Int,
|
||||
key: String,
|
||||
value: String,
|
||||
) {
|
||||
transaction {
|
||||
val meta =
|
||||
MangaMetaTable.select { (MangaMetaTable.ref eq mangaId) and (MangaMetaTable.key eq key) }
|
||||
@@ -251,45 +264,51 @@ object Manga {
|
||||
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")
|
||||
}
|
||||
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()
|
||||
}
|
||||
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"
|
||||
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()
|
||||
}
|
||||
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")
|
||||
}
|
||||
@@ -319,14 +338,18 @@ object Manga {
|
||||
|
||||
private val downloadAheadQueue = ConcurrentHashMap<String, ConcurrentHashMap.KeySetView<Int, Boolean>>()
|
||||
private var downloadAheadTimer: Timer? = null
|
||||
fun downloadAhead(mangaIds: List<Int>, latestReadChapterIds: List<Int> = emptyList()) {
|
||||
|
||||
private const val MANGAS_KEY = "mangaIds"
|
||||
private const val CHAPTERS_KEY = "chapterIds"
|
||||
|
||||
fun downloadAhead(
|
||||
mangaIds: List<Int>,
|
||||
latestReadChapterIds: List<Int> = emptyList(),
|
||||
) {
|
||||
if (serverConfig.autoDownloadAheadLimit.value == 0) {
|
||||
return
|
||||
}
|
||||
|
||||
val MANGAS_KEY = "mangaIds"
|
||||
val CHAPTERS_KEY = "chapterIds"
|
||||
|
||||
val updateDownloadAheadQueue = { key: String, ids: List<Int> ->
|
||||
val idSet = downloadAheadQueue[key] ?: ConcurrentHashMap.newKeySet()
|
||||
idSet.addAll(ids)
|
||||
@@ -339,17 +362,21 @@ object Manga {
|
||||
// handle cases where this function gets called multiple times in quick succession.
|
||||
// this could happen in case e.g. multiple chapters get marked as read without batching the operation
|
||||
downloadAheadTimer?.cancel()
|
||||
downloadAheadTimer = Timer().apply {
|
||||
schedule(
|
||||
object : TimerTask() {
|
||||
override fun run() {
|
||||
downloadAheadChapters(downloadAheadQueue[MANGAS_KEY]!!.toList(), downloadAheadQueue[CHAPTERS_KEY]!!.toList())
|
||||
downloadAheadQueue.clear()
|
||||
}
|
||||
},
|
||||
5000
|
||||
)
|
||||
}
|
||||
downloadAheadTimer =
|
||||
Timer().apply {
|
||||
schedule(
|
||||
object : TimerTask() {
|
||||
override fun run() {
|
||||
downloadAheadChapters(
|
||||
downloadAheadQueue[MANGAS_KEY]?.toList().orEmpty(),
|
||||
downloadAheadQueue[CHAPTERS_KEY]?.toList().orEmpty(),
|
||||
)
|
||||
downloadAheadQueue.clear()
|
||||
}
|
||||
},
|
||||
5000,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -370,29 +397,38 @@ object Manga {
|
||||
*
|
||||
* will download the unread chapters starting from chapter 15
|
||||
*/
|
||||
private fun downloadAheadChapters(mangaIds: List<Int>, latestReadChapterIds: List<Int>) {
|
||||
val mangaToLatestReadChapterIndex = transaction {
|
||||
ChapterTable.select { (ChapterTable.manga inList mangaIds) and (ChapterTable.isRead eq true) }
|
||||
.orderBy(ChapterTable.sourceOrder to SortOrder.DESC).groupBy { it[ChapterTable.manga].value }
|
||||
}.mapValues { (_, chapters) -> chapters.firstOrNull()?.let { it[ChapterTable.sourceOrder] } ?: 0 }
|
||||
private fun downloadAheadChapters(
|
||||
mangaIds: List<Int>,
|
||||
latestReadChapterIds: List<Int>,
|
||||
) {
|
||||
val mangaToLatestReadChapterIndex =
|
||||
transaction {
|
||||
ChapterTable.select { (ChapterTable.manga inList mangaIds) and (ChapterTable.isRead eq true) }
|
||||
.orderBy(ChapterTable.sourceOrder to SortOrder.DESC).groupBy { it[ChapterTable.manga].value }
|
||||
}.mapValues { (_, chapters) -> chapters.firstOrNull()?.let { it[ChapterTable.sourceOrder] } ?: 0 }
|
||||
|
||||
val mangaToUnreadChaptersMap = transaction {
|
||||
ChapterTable.select { (ChapterTable.manga inList mangaIds) and (ChapterTable.isRead eq false) }
|
||||
.orderBy(ChapterTable.sourceOrder to SortOrder.DESC)
|
||||
.groupBy { it[ChapterTable.manga].value }
|
||||
}
|
||||
val mangaToUnreadChaptersMap =
|
||||
transaction {
|
||||
ChapterTable.select { (ChapterTable.manga inList mangaIds) and (ChapterTable.isRead eq false) }
|
||||
.orderBy(ChapterTable.sourceOrder to SortOrder.DESC)
|
||||
.groupBy { it[ChapterTable.manga].value }
|
||||
}
|
||||
|
||||
val chapterIdsToDownload = mangaToUnreadChaptersMap.map { (mangaId, unreadChapters) ->
|
||||
val latestReadChapterIndex = mangaToLatestReadChapterIndex[mangaId] ?: 0
|
||||
val lastChapterToDownloadIndex =
|
||||
unreadChapters.indexOfLast { it[ChapterTable.sourceOrder] > latestReadChapterIndex && it[ChapterTable.id].value !in latestReadChapterIds }
|
||||
val unreadChaptersToConsider = unreadChapters.subList(0, lastChapterToDownloadIndex + 1)
|
||||
val firstChapterToDownloadIndex =
|
||||
(unreadChaptersToConsider.size - serverConfig.autoDownloadAheadLimit.value).coerceAtLeast(0)
|
||||
unreadChaptersToConsider.subList(firstChapterToDownloadIndex, lastChapterToDownloadIndex + 1)
|
||||
.filter { !it[ChapterTable.isDownloaded] }
|
||||
.map { it[ChapterTable.id].value }
|
||||
}.flatten()
|
||||
val chapterIdsToDownload =
|
||||
mangaToUnreadChaptersMap.map { (mangaId, unreadChapters) ->
|
||||
val latestReadChapterIndex = mangaToLatestReadChapterIndex[mangaId] ?: 0
|
||||
val lastChapterToDownloadIndex =
|
||||
unreadChapters.indexOfLast {
|
||||
it[ChapterTable.sourceOrder] > latestReadChapterIndex &&
|
||||
it[ChapterTable.id].value !in latestReadChapterIds
|
||||
}
|
||||
val unreadChaptersToConsider = unreadChapters.subList(0, lastChapterToDownloadIndex + 1)
|
||||
val firstChapterToDownloadIndex =
|
||||
(unreadChaptersToConsider.size - serverConfig.autoDownloadAheadLimit.value).coerceAtLeast(0)
|
||||
unreadChaptersToConsider.subList(firstChapterToDownloadIndex, lastChapterToDownloadIndex + 1)
|
||||
.filter { !it[ChapterTable.isDownloaded] }
|
||||
.map { it[ChapterTable.id].value }
|
||||
}.flatten()
|
||||
|
||||
logger.info { "downloadAheadChapters: download chapters [${chapterIdsToDownload.joinToString(", ")}]" }
|
||||
|
||||
|
||||
@@ -26,29 +26,35 @@ object MangaList {
|
||||
return "/api/v1/manga/$mangaId/thumbnail"
|
||||
}
|
||||
|
||||
suspend fun getMangaList(sourceId: Long, pageNum: Int = 1, popular: Boolean): PagedMangaListDataClass {
|
||||
suspend fun getMangaList(
|
||||
sourceId: Long,
|
||||
pageNum: Int = 1,
|
||||
popular: Boolean,
|
||||
): PagedMangaListDataClass {
|
||||
require(pageNum > 0) {
|
||||
"pageNum = $pageNum is not in valid range"
|
||||
}
|
||||
val source = getCatalogueSourceOrStub(sourceId)
|
||||
val mangasPage = if (popular) {
|
||||
source.getPopularManga(pageNum)
|
||||
} else {
|
||||
if (source.supportsLatest) {
|
||||
source.getLatestUpdates(pageNum)
|
||||
val mangasPage =
|
||||
if (popular) {
|
||||
source.getPopularManga(pageNum)
|
||||
} else {
|
||||
throw Exception("Source $source doesn't support latest")
|
||||
if (source.supportsLatest) {
|
||||
source.getLatestUpdates(pageNum)
|
||||
} else {
|
||||
throw Exception("Source $source doesn't support latest")
|
||||
}
|
||||
}
|
||||
}
|
||||
return mangasPage.processEntries(sourceId)
|
||||
}
|
||||
|
||||
fun MangasPage.insertOrGet(sourceId: Long): List<Int> {
|
||||
return transaction {
|
||||
mangas.map { manga ->
|
||||
val mangaEntry = MangaTable.select {
|
||||
(MangaTable.url eq manga.url) and (MangaTable.sourceReference eq sourceId)
|
||||
}.firstOrNull()
|
||||
val mangaEntry =
|
||||
MangaTable.select {
|
||||
(MangaTable.url eq manga.url) and (MangaTable.sourceReference eq sourceId)
|
||||
}.firstOrNull()
|
||||
if (mangaEntry == null) { // create manga entry
|
||||
MangaTable.insertAndGetId {
|
||||
it[url] = manga.url
|
||||
@@ -73,89 +79,87 @@ object MangaList {
|
||||
|
||||
fun MangasPage.processEntries(sourceId: Long): PagedMangaListDataClass {
|
||||
val mangasPage = this
|
||||
val mangaList = transaction {
|
||||
return@transaction mangasPage.mangas.map { manga ->
|
||||
var mangaEntry = MangaTable.select {
|
||||
(MangaTable.url eq manga.url) and (MangaTable.sourceReference eq sourceId)
|
||||
}.firstOrNull()
|
||||
if (mangaEntry == null) { // create manga entry
|
||||
val mangaId = MangaTable.insertAndGetId {
|
||||
it[url] = manga.url
|
||||
it[title] = manga.title
|
||||
val mangaList =
|
||||
transaction {
|
||||
return@transaction mangasPage.mangas.map { manga ->
|
||||
var mangaEntry =
|
||||
MangaTable.select {
|
||||
(MangaTable.url eq manga.url) and (MangaTable.sourceReference eq sourceId)
|
||||
}.firstOrNull()
|
||||
if (mangaEntry == null) { // create manga entry
|
||||
val mangaId =
|
||||
MangaTable.insertAndGetId {
|
||||
it[url] = manga.url
|
||||
it[title] = manga.title
|
||||
|
||||
it[artist] = manga.artist
|
||||
it[author] = manga.author
|
||||
it[description] = manga.description
|
||||
it[genre] = manga.genre
|
||||
it[status] = manga.status
|
||||
it[thumbnail_url] = manga.thumbnail_url
|
||||
it[updateStrategy] = manga.update_strategy.name
|
||||
it[artist] = manga.artist
|
||||
it[author] = manga.author
|
||||
it[description] = manga.description
|
||||
it[genre] = manga.genre
|
||||
it[status] = manga.status
|
||||
it[thumbnail_url] = manga.thumbnail_url
|
||||
it[updateStrategy] = manga.update_strategy.name
|
||||
|
||||
it[sourceReference] = sourceId
|
||||
}.value
|
||||
it[sourceReference] = sourceId
|
||||
}.value
|
||||
|
||||
mangaEntry = MangaTable.select {
|
||||
(MangaTable.url eq manga.url) and (MangaTable.sourceReference eq sourceId)
|
||||
}.first()
|
||||
mangaEntry =
|
||||
MangaTable.select {
|
||||
(MangaTable.url eq manga.url) and (MangaTable.sourceReference eq sourceId)
|
||||
}.first()
|
||||
|
||||
MangaDataClass(
|
||||
id = mangaId,
|
||||
sourceId = sourceId.toString(),
|
||||
|
||||
url = manga.url,
|
||||
title = manga.title,
|
||||
thumbnailUrl = proxyThumbnailUrl(mangaId),
|
||||
thumbnailUrlLastFetched = mangaEntry[MangaTable.thumbnailUrlLastFetched],
|
||||
|
||||
initialized = manga.initialized,
|
||||
|
||||
artist = manga.artist,
|
||||
author = manga.author,
|
||||
description = manga.description,
|
||||
genre = manga.genre.toGenreList(),
|
||||
status = MangaStatus.valueOf(manga.status).name,
|
||||
inLibrary = false, // It's a new manga entry
|
||||
inLibraryAt = 0,
|
||||
meta = getMangaMetaMap(mangaId),
|
||||
realUrl = mangaEntry[MangaTable.realUrl],
|
||||
lastFetchedAt = mangaEntry[MangaTable.lastFetchedAt],
|
||||
chaptersLastFetchedAt = mangaEntry[MangaTable.chaptersLastFetchedAt],
|
||||
updateStrategy = UpdateStrategy.valueOf(mangaEntry[MangaTable.updateStrategy]),
|
||||
freshData = true
|
||||
)
|
||||
} else {
|
||||
val mangaId = mangaEntry[MangaTable.id].value
|
||||
MangaDataClass(
|
||||
id = mangaId,
|
||||
sourceId = sourceId.toString(),
|
||||
|
||||
url = manga.url,
|
||||
title = manga.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],
|
||||
meta = getMangaMetaMap(mangaId),
|
||||
realUrl = mangaEntry[MangaTable.realUrl],
|
||||
lastFetchedAt = mangaEntry[MangaTable.lastFetchedAt],
|
||||
chaptersLastFetchedAt = mangaEntry[MangaTable.chaptersLastFetchedAt],
|
||||
updateStrategy = UpdateStrategy.valueOf(mangaEntry[MangaTable.updateStrategy]),
|
||||
freshData = false
|
||||
)
|
||||
MangaDataClass(
|
||||
id = mangaId,
|
||||
sourceId = sourceId.toString(),
|
||||
url = manga.url,
|
||||
title = manga.title,
|
||||
thumbnailUrl = proxyThumbnailUrl(mangaId),
|
||||
thumbnailUrlLastFetched = mangaEntry[MangaTable.thumbnailUrlLastFetched],
|
||||
initialized = manga.initialized,
|
||||
artist = manga.artist,
|
||||
author = manga.author,
|
||||
description = manga.description,
|
||||
genre = manga.genre.toGenreList(),
|
||||
status = MangaStatus.valueOf(manga.status).name,
|
||||
inLibrary = false, // It's a new manga entry
|
||||
inLibraryAt = 0,
|
||||
meta = getMangaMetaMap(mangaId),
|
||||
realUrl = mangaEntry[MangaTable.realUrl],
|
||||
lastFetchedAt = mangaEntry[MangaTable.lastFetchedAt],
|
||||
chaptersLastFetchedAt = mangaEntry[MangaTable.chaptersLastFetchedAt],
|
||||
updateStrategy = UpdateStrategy.valueOf(mangaEntry[MangaTable.updateStrategy]),
|
||||
freshData = true,
|
||||
)
|
||||
} else {
|
||||
val mangaId = mangaEntry[MangaTable.id].value
|
||||
MangaDataClass(
|
||||
id = mangaId,
|
||||
sourceId = sourceId.toString(),
|
||||
url = manga.url,
|
||||
title = manga.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],
|
||||
meta = getMangaMetaMap(mangaId),
|
||||
realUrl = mangaEntry[MangaTable.realUrl],
|
||||
lastFetchedAt = mangaEntry[MangaTable.lastFetchedAt],
|
||||
chaptersLastFetchedAt = mangaEntry[MangaTable.chaptersLastFetchedAt],
|
||||
updateStrategy = UpdateStrategy.valueOf(mangaEntry[MangaTable.updateStrategy]),
|
||||
freshData = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return PagedMangaListDataClass(
|
||||
mangaList,
|
||||
mangasPage.hasNextPage
|
||||
mangasPage.hasNextPage,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,21 +31,30 @@ object Page {
|
||||
* A page might have a imageUrl ready from the get go, or we might need to
|
||||
* go an extra step and call fetchImageUrl to get it.
|
||||
*/
|
||||
suspend fun getTrueImageUrl(page: Page, source: HttpSource): String {
|
||||
suspend fun getTrueImageUrl(
|
||||
page: Page,
|
||||
source: HttpSource,
|
||||
): String {
|
||||
if (page.imageUrl == null) {
|
||||
page.imageUrl = source.getImageUrl(page)
|
||||
}
|
||||
return page.imageUrl!!
|
||||
}
|
||||
|
||||
suspend fun getPageImage(mangaId: Int, chapterIndex: Int, index: Int, progressFlow: ((StateFlow<Int>) -> Unit)? = null): Pair<InputStream, String> {
|
||||
suspend fun getPageImage(
|
||||
mangaId: Int,
|
||||
chapterIndex: Int,
|
||||
index: Int,
|
||||
progressFlow: ((StateFlow<Int>) -> Unit)? = null,
|
||||
): Pair<InputStream, String> {
|
||||
val mangaEntry = transaction { MangaTable.select { MangaTable.id eq mangaId }.first() }
|
||||
val source = getCatalogueSourceOrStub(mangaEntry[MangaTable.sourceReference])
|
||||
val chapterEntry = transaction {
|
||||
ChapterTable.select {
|
||||
(ChapterTable.sourceOrder eq chapterIndex) and (ChapterTable.manga eq mangaId)
|
||||
}.first()
|
||||
}
|
||||
val chapterEntry =
|
||||
transaction {
|
||||
ChapterTable.select {
|
||||
(ChapterTable.sourceOrder eq chapterIndex) and (ChapterTable.manga eq mangaId)
|
||||
}.first()
|
||||
}
|
||||
val chapterId = chapterEntry[ChapterTable.id].value
|
||||
|
||||
val pageEntry =
|
||||
@@ -54,11 +63,12 @@ object Page {
|
||||
.orderBy(PageTable.index to SortOrder.ASC)
|
||||
.limit(1, index.toLong()).first()
|
||||
}
|
||||
val tachiyomiPage = Page(
|
||||
pageEntry[PageTable.index],
|
||||
pageEntry[PageTable.url],
|
||||
pageEntry[PageTable.imageUrl]
|
||||
)
|
||||
val tachiyomiPage =
|
||||
Page(
|
||||
pageEntry[PageTable.index],
|
||||
pageEntry[PageTable.url],
|
||||
pageEntry[PageTable.imageUrl],
|
||||
)
|
||||
progressFlow?.invoke(tachiyomiPage.progress)
|
||||
|
||||
// we treat Local source differently
|
||||
|
||||
@@ -20,13 +20,21 @@ import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogue
|
||||
import suwayomi.tachidesk.manga.model.dataclass.PagedMangaListDataClass
|
||||
|
||||
object Search {
|
||||
suspend fun sourceSearch(sourceId: Long, searchTerm: String, pageNum: Int): PagedMangaListDataClass {
|
||||
suspend fun sourceSearch(
|
||||
sourceId: Long,
|
||||
searchTerm: String,
|
||||
pageNum: Int,
|
||||
): PagedMangaListDataClass {
|
||||
val source = getCatalogueSourceOrStub(sourceId)
|
||||
val searchManga = source.getSearchManga(pageNum, searchTerm, getFilterListOf(source))
|
||||
return searchManga.processEntries(sourceId)
|
||||
}
|
||||
|
||||
suspend fun sourceFilter(sourceId: Long, pageNum: Int, filter: FilterData): PagedMangaListDataClass {
|
||||
suspend fun sourceFilter(
|
||||
sourceId: Long,
|
||||
pageNum: Int,
|
||||
filter: FilterData,
|
||||
): PagedMangaListDataClass {
|
||||
val source = getCatalogueSourceOrStub(sourceId)
|
||||
val filterList = if (filter.filter != null) buildFilterList(sourceId, filter.filter) else source.getFilterList()
|
||||
val searchManga = source.getSearchManga(pageNum, filter.searchTerm ?: "", filterList)
|
||||
@@ -35,14 +43,20 @@ object Search {
|
||||
|
||||
private val filterListCache = mutableMapOf<Long, FilterList>()
|
||||
|
||||
private fun getFilterListOf(source: CatalogueSource, reset: Boolean = false): FilterList {
|
||||
private fun getFilterListOf(
|
||||
source: CatalogueSource,
|
||||
reset: Boolean = false,
|
||||
): FilterList {
|
||||
if (reset || !filterListCache.containsKey(source.id)) {
|
||||
filterListCache[source.id] = source.getFilterList()
|
||||
}
|
||||
return filterListCache[source.id]!!
|
||||
}
|
||||
|
||||
fun getFilterList(sourceId: Long, reset: Boolean): List<FilterObject> {
|
||||
fun getFilterList(
|
||||
sourceId: Long,
|
||||
reset: Boolean,
|
||||
): List<FilterObject> {
|
||||
val source = getCatalogueSourceOrStub(sourceId)
|
||||
|
||||
return getFilterListOf(source, reset).list.map {
|
||||
@@ -70,30 +84,37 @@ object Search {
|
||||
is Filter.Select<*> -> FilterObject("Select", item)
|
||||
else -> throw RuntimeException("Illegal Group item type!")
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
else -> it
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Filter.Select<*>.getValuesType(): String = values::class.java.componentType!!.simpleName
|
||||
|
||||
class SerializableGroup(name: String, state: List<FilterObject>) : Filter<List<FilterObject>>(name, state)
|
||||
|
||||
data class FilterObject(
|
||||
val type: String,
|
||||
val filter: Filter<*>
|
||||
val filter: Filter<*>,
|
||||
)
|
||||
|
||||
fun setFilter(sourceId: Long, changes: List<FilterChange>) {
|
||||
fun setFilter(
|
||||
sourceId: Long,
|
||||
changes: List<FilterChange>,
|
||||
) {
|
||||
val source = getCatalogueSourceOrStub(sourceId)
|
||||
val filterList = getFilterListOf(source, false)
|
||||
updateFilterList(filterList, changes)
|
||||
}
|
||||
|
||||
private fun updateFilterList(filterList: FilterList, changes: List<FilterChange>): FilterList {
|
||||
private fun updateFilterList(
|
||||
filterList: FilterList,
|
||||
changes: List<FilterChange>,
|
||||
): FilterList {
|
||||
changes.forEach { change ->
|
||||
when (val filter = filterList[change.position]) {
|
||||
is Filter.Header -> {
|
||||
@@ -124,7 +145,10 @@ object Search {
|
||||
return filterList
|
||||
}
|
||||
|
||||
fun buildFilterList(sourceId: Long, changes: List<FilterChange>): FilterList {
|
||||
fun buildFilterList(
|
||||
sourceId: Long,
|
||||
changes: List<FilterChange>,
|
||||
): FilterList {
|
||||
val source = getCatalogueSourceOrStub(sourceId)
|
||||
val filterList = source.getFilterList()
|
||||
return updateFilterList(filterList, changes)
|
||||
@@ -135,13 +159,13 @@ object Search {
|
||||
@Serializable
|
||||
data class FilterChange(
|
||||
val position: Int,
|
||||
val state: String
|
||||
val state: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class FilterData(
|
||||
val searchTerm: String?,
|
||||
val filter: List<FilterChange>?
|
||||
val filter: List<FilterChange>?,
|
||||
)
|
||||
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
|
||||
@@ -46,7 +46,7 @@ object Source {
|
||||
catalogueSource.supportsLatest,
|
||||
catalogueSource is ConfigurableSource,
|
||||
it[SourceTable.isNsfw],
|
||||
catalogueSource.toString()
|
||||
catalogueSource.toString(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -63,12 +63,12 @@ object Source {
|
||||
source[SourceTable.name],
|
||||
source[SourceTable.lang],
|
||||
getExtensionIconUrl(
|
||||
extension[ExtensionTable.apkName]
|
||||
extension[ExtensionTable.apkName],
|
||||
),
|
||||
catalogueSource.supportsLatest,
|
||||
catalogueSource is ConfigurableSource,
|
||||
source[SourceTable.isNsfw],
|
||||
catalogueSource.toString()
|
||||
catalogueSource.toString(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -85,7 +85,7 @@ object Source {
|
||||
*/
|
||||
data class PreferenceObject(
|
||||
val type: String,
|
||||
val props: Any
|
||||
val props: Any,
|
||||
)
|
||||
|
||||
var preferenceScreenMap: MutableMap<Long, PreferenceScreen> = mutableMapOf()
|
||||
@@ -119,7 +119,7 @@ object Source {
|
||||
|
||||
data class SourcePreferenceChange(
|
||||
val position: Int,
|
||||
val value: String
|
||||
val value: String,
|
||||
)
|
||||
|
||||
private val jsonMapper by DI.global.instance<JsonMapper>()
|
||||
@@ -137,7 +137,7 @@ object Source {
|
||||
"Set<String>" -> jsonMapper.fromJsonString(value, List::class.java as Class<List<String>>).toSet()
|
||||
else -> throw RuntimeException("Unsupported type conversion")
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
val screen = preferenceScreenMap[sourceId]!!
|
||||
val pref = screen.preferences[position]
|
||||
|
||||
@@ -12,5 +12,5 @@ data class BackupFlags(
|
||||
val includeCategories: Boolean,
|
||||
val includeChapters: Boolean,
|
||||
val includeTracking: Boolean,
|
||||
val includeHistory: Boolean
|
||||
val includeHistory: Boolean,
|
||||
)
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
package suwayomi.tachidesk.manga.impl.backup.models
|
||||
|
||||
import java.io.Serializable
|
||||
|
||||
interface Category : Serializable {
|
||||
|
||||
var id: Int?
|
||||
|
||||
var name: String
|
||||
|
||||
var order: Int
|
||||
|
||||
var flags: Int
|
||||
|
||||
companion object {
|
||||
|
||||
fun create(name: String): Category = CategoryImpl().apply {
|
||||
this.name = name
|
||||
}
|
||||
|
||||
fun createDefault(): Category = create("Default").apply { id = 0 }
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
package suwayomi.tachidesk.manga.impl.backup.models
|
||||
|
||||
class CategoryImpl : Category {
|
||||
|
||||
override var id: Int? = null
|
||||
|
||||
override lateinit var name: String
|
||||
|
||||
override var order: Int = 0
|
||||
|
||||
override var flags: Int = 0
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other == null || javaClass != other.javaClass) return false
|
||||
|
||||
val category = other as Category
|
||||
return name == category.name
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
return name.hashCode()
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
@file:Suppress("ktlint:standard:property-naming")
|
||||
|
||||
package suwayomi.tachidesk.manga.impl.backup.models
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import java.io.Serializable
|
||||
|
||||
interface Chapter : SChapter, Serializable {
|
||||
|
||||
var id: Long?
|
||||
|
||||
var manga_id: Long?
|
||||
@@ -23,9 +24,9 @@ interface Chapter : SChapter, Serializable {
|
||||
get() = chapter_number >= 0f
|
||||
|
||||
companion object {
|
||||
|
||||
fun create(): Chapter = ChapterImpl().apply {
|
||||
chapter_number = -1f
|
||||
}
|
||||
fun create(): Chapter =
|
||||
ChapterImpl().apply {
|
||||
chapter_number = -1f
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
@file:Suppress("ktlint:standard:property-naming")
|
||||
|
||||
package suwayomi.tachidesk.manga.impl.backup.models
|
||||
|
||||
import org.jetbrains.exposed.sql.ResultRow
|
||||
import suwayomi.tachidesk.manga.model.table.ChapterTable
|
||||
|
||||
class ChapterImpl : Chapter {
|
||||
|
||||
override var id: Long? = null
|
||||
|
||||
override var manga_id: Long? = null
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
package suwayomi.tachidesk.manga.impl.backup.models
|
||||
|
||||
import java.io.Serializable
|
||||
|
||||
/**
|
||||
* Object containing the history statistics of a chapter
|
||||
*/
|
||||
interface History : Serializable {
|
||||
|
||||
/**
|
||||
* Id of history object.
|
||||
*/
|
||||
var id: Long?
|
||||
|
||||
/**
|
||||
* Chapter id of history object.
|
||||
*/
|
||||
var chapter_id: Long
|
||||
|
||||
/**
|
||||
* Last time chapter was read in time long format
|
||||
*/
|
||||
var last_read: Long
|
||||
|
||||
/**
|
||||
* Total time chapter was read - todo not yet implemented
|
||||
*/
|
||||
var time_read: Long
|
||||
|
||||
companion object {
|
||||
|
||||
/**
|
||||
* History constructor
|
||||
*
|
||||
* @param chapter chapter object
|
||||
* @return history object
|
||||
*/
|
||||
fun create(chapter: Chapter): History = HistoryImpl().apply {
|
||||
this.chapter_id = chapter.id!!
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
package suwayomi.tachidesk.manga.impl.backup.models
|
||||
|
||||
/**
|
||||
* Object containing the history statistics of a chapter
|
||||
*/
|
||||
class HistoryImpl : History {
|
||||
|
||||
/**
|
||||
* Id of history object.
|
||||
*/
|
||||
override var id: Long? = null
|
||||
|
||||
/**
|
||||
* Chapter id of history object.
|
||||
*/
|
||||
override var chapter_id: Long = 0
|
||||
|
||||
/**
|
||||
* Last time chapter was read in time long format
|
||||
*/
|
||||
override var last_read: Long = 0
|
||||
|
||||
/**
|
||||
* Total time chapter was read - todo not yet implemented
|
||||
*/
|
||||
override var time_read: Long = 0
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
@file:Suppress("ktlint:standard:property-naming")
|
||||
|
||||
package suwayomi.tachidesk.manga.impl.backup.models
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
@@ -15,7 +17,6 @@ object ReadingModeType {
|
||||
}
|
||||
|
||||
interface Manga : SManga {
|
||||
|
||||
var id: Long?
|
||||
|
||||
var source: Long
|
||||
@@ -48,11 +49,17 @@ interface Manga : SManga {
|
||||
return genre?.split(", ")?.map { it.trim() }
|
||||
}
|
||||
|
||||
private fun setChapterFlags(flag: Int, mask: Int) {
|
||||
private fun setChapterFlags(
|
||||
flag: Int,
|
||||
mask: Int,
|
||||
) {
|
||||
chapter_flags = chapter_flags and mask.inv() or (flag and mask)
|
||||
}
|
||||
|
||||
private fun setViewerFlags(flag: Int, mask: Int) {
|
||||
private fun setViewerFlags(
|
||||
flag: Int,
|
||||
mask: Int,
|
||||
) {
|
||||
viewer_flags = viewer_flags and mask.inv() or (flag and mask)
|
||||
}
|
||||
|
||||
@@ -86,7 +93,6 @@ interface Manga : SManga {
|
||||
set(rotationType) = setViewerFlags(rotationType, OrientationType.MASK)
|
||||
|
||||
companion object {
|
||||
|
||||
// Generic filter that does not filter anything
|
||||
const val SHOW_ALL = 0x00000000
|
||||
|
||||
@@ -115,15 +121,21 @@ interface Manga : SManga {
|
||||
const val CHAPTER_DISPLAY_NUMBER = 0x00100000
|
||||
const val CHAPTER_DISPLAY_MASK = 0x00100000
|
||||
|
||||
fun create(source: Long): Manga = MangaImpl().apply {
|
||||
this.source = source
|
||||
}
|
||||
fun create(source: Long): Manga =
|
||||
MangaImpl().apply {
|
||||
this.source = source
|
||||
}
|
||||
|
||||
fun create(pathUrl: String, title: String, source: Long = 0): Manga = MangaImpl().apply {
|
||||
url = pathUrl
|
||||
this.title = title
|
||||
this.source = source
|
||||
}
|
||||
fun create(
|
||||
pathUrl: String,
|
||||
title: String,
|
||||
source: Long = 0,
|
||||
): Manga =
|
||||
MangaImpl().apply {
|
||||
url = pathUrl
|
||||
this.title = title
|
||||
this.source = source
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
package suwayomi.tachidesk.manga.impl.backup.models
|
||||
|
||||
class MangaCategory {
|
||||
|
||||
var id: Long? = null
|
||||
|
||||
var manga_id: Long = 0
|
||||
|
||||
var category_id: Int = 0
|
||||
|
||||
companion object {
|
||||
|
||||
fun create(manga: Manga, category: Category): MangaCategory {
|
||||
val mc = MangaCategory()
|
||||
mc.manga_id = manga.id!!
|
||||
mc.category_id = category.id!!
|
||||
return mc
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
package suwayomi.tachidesk.manga.impl.backup.models
|
||||
|
||||
class MangaChapter(val manga: Manga, val chapter: Chapter)
|
||||
@@ -1,10 +0,0 @@
|
||||
package suwayomi.tachidesk.manga.impl.backup.models
|
||||
|
||||
/**
|
||||
* Object containing manga, chapter and history
|
||||
*
|
||||
* @param manga object containing manga
|
||||
* @param chapter object containing chater
|
||||
* @param history object containing history
|
||||
*/
|
||||
data class MangaChapterHistory(val manga: Manga, val chapter: Chapter, val history: History)
|
||||
@@ -1,3 +1,5 @@
|
||||
@file:Suppress("ktlint:standard:property-naming")
|
||||
|
||||
package suwayomi.tachidesk.manga.impl.backup.models
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.UpdateStrategy
|
||||
@@ -5,7 +7,6 @@ import org.jetbrains.exposed.sql.ResultRow
|
||||
import suwayomi.tachidesk.manga.model.table.MangaTable
|
||||
|
||||
open class MangaImpl : Manga {
|
||||
|
||||
override var id: Long? = null
|
||||
|
||||
override var source: Long = -1
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
@file:Suppress("ktlint:standard:property-naming")
|
||||
|
||||
package suwayomi.tachidesk.manga.impl.backup.models
|
||||
|
||||
import java.io.Serializable
|
||||
|
||||
interface Track : Serializable {
|
||||
|
||||
var id: Long?
|
||||
|
||||
var manga_id: Long
|
||||
@@ -39,8 +40,9 @@ interface Track : Serializable {
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun create(serviceId: Int): Track = TrackImpl().apply {
|
||||
sync_id = serviceId
|
||||
}
|
||||
fun create(serviceId: Int): Track =
|
||||
TrackImpl().apply {
|
||||
sync_id = serviceId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
@file:Suppress("ktlint:standard:property-naming")
|
||||
|
||||
package suwayomi.tachidesk.manga.impl.backup.models
|
||||
|
||||
class TrackImpl : Track {
|
||||
|
||||
override var id: Long? = null
|
||||
|
||||
override var manga_id: Long = 0
|
||||
|
||||
@@ -51,7 +51,7 @@ object ProtoBackupExport : ProtoBackupBase() {
|
||||
private val logger = KotlinLogging.logger { }
|
||||
private val applicationDirs by DI.global.instance<ApplicationDirs>()
|
||||
private var backupSchedulerJobId: String = ""
|
||||
private const val lastAutomatedBackupKey = "lastAutomatedBackupKey"
|
||||
private const val LAST_AUTOMATED_BACKUP_KEY = "lastAutomatedBackupKey"
|
||||
private val preferences = Preferences.userNodeForPackage(ProtoBackupExport::class.java)
|
||||
|
||||
init {
|
||||
@@ -59,10 +59,10 @@ object ProtoBackupExport : ProtoBackupBase() {
|
||||
combine(serverConfig.backupInterval, serverConfig.backupTime) { interval, timeOfDay ->
|
||||
Pair(
|
||||
interval,
|
||||
timeOfDay
|
||||
timeOfDay,
|
||||
)
|
||||
},
|
||||
::scheduleAutomatedBackupTask
|
||||
::scheduleAutomatedBackupTask,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ object ProtoBackupExport : ProtoBackupBase() {
|
||||
val task = {
|
||||
cleanupAutomatedBackups()
|
||||
createAutomatedBackup()
|
||||
preferences.putLong(lastAutomatedBackupKey, System.currentTimeMillis())
|
||||
preferences.putLong(LAST_AUTOMATED_BACKUP_KEY, System.currentTimeMillis())
|
||||
}
|
||||
|
||||
val (hour, minute) = serverConfig.backupTime.value.split(":").map { it.toInt() }
|
||||
@@ -86,7 +86,7 @@ object ProtoBackupExport : ProtoBackupBase() {
|
||||
val backupInterval = serverConfig.backupInterval.value.days.coerceAtLeast(1.days)
|
||||
|
||||
// trigger last backup in case the server wasn't running on the scheduled time
|
||||
val lastAutomatedBackup = preferences.getLong(lastAutomatedBackupKey, System.currentTimeMillis())
|
||||
val lastAutomatedBackup = preferences.getLong(LAST_AUTOMATED_BACKUP_KEY, System.currentTimeMillis())
|
||||
val wasPreviousBackupTriggered =
|
||||
(System.currentTimeMillis() - lastAutomatedBackup) < backupInterval.inWholeMilliseconds
|
||||
if (!wasPreviousBackupTriggered) {
|
||||
@@ -105,8 +105,8 @@ object ProtoBackupExport : ProtoBackupBase() {
|
||||
includeCategories = true,
|
||||
includeChapters = true,
|
||||
includeTracking = true,
|
||||
includeHistory = true
|
||||
)
|
||||
includeHistory = true,
|
||||
),
|
||||
).use { input ->
|
||||
val automatedBackupDir = File(applicationDirs.automatedBackupRoot)
|
||||
automatedBackupDir.mkdirs()
|
||||
@@ -162,14 +162,15 @@ object ProtoBackupExport : ProtoBackupBase() {
|
||||
|
||||
val databaseManga = transaction { MangaTable.select { MangaTable.inLibrary eq true } }
|
||||
|
||||
val backup: Backup = transaction {
|
||||
Backup(
|
||||
backupManga(databaseManga, flags),
|
||||
backupCategories(),
|
||||
emptyList(),
|
||||
backupExtensionInfo(databaseManga)
|
||||
)
|
||||
}
|
||||
val backup: Backup =
|
||||
transaction {
|
||||
Backup(
|
||||
backupManga(databaseManga, flags),
|
||||
backupCategories(),
|
||||
emptyList(),
|
||||
backupExtensionInfo(databaseManga),
|
||||
)
|
||||
}
|
||||
|
||||
val byteArray = parser.encodeToByteArray(BackupSerializer, backup)
|
||||
|
||||
@@ -179,48 +180,54 @@ object ProtoBackupExport : ProtoBackupBase() {
|
||||
return byteStream.toByteArray().inputStream()
|
||||
}
|
||||
|
||||
private fun backupManga(databaseManga: Query, flags: BackupFlags): List<BackupManga> {
|
||||
private fun backupManga(
|
||||
databaseManga: Query,
|
||||
flags: BackupFlags,
|
||||
): List<BackupManga> {
|
||||
return databaseManga.map { mangaRow ->
|
||||
val backupManga = BackupManga(
|
||||
source = mangaRow[MangaTable.sourceReference],
|
||||
url = mangaRow[MangaTable.url],
|
||||
title = mangaRow[MangaTable.title],
|
||||
artist = mangaRow[MangaTable.artist],
|
||||
author = mangaRow[MangaTable.author],
|
||||
description = mangaRow[MangaTable.description],
|
||||
genre = mangaRow[MangaTable.genre]?.split(", ") ?: emptyList(),
|
||||
status = MangaStatus.valueOf(mangaRow[MangaTable.status]).value,
|
||||
thumbnailUrl = mangaRow[MangaTable.thumbnail_url],
|
||||
dateAdded = TimeUnit.SECONDS.toMillis(mangaRow[MangaTable.inLibraryAt]),
|
||||
viewer = 0, // not supported in Tachidesk
|
||||
updateStrategy = UpdateStrategy.valueOf(mangaRow[MangaTable.updateStrategy])
|
||||
)
|
||||
val backupManga =
|
||||
BackupManga(
|
||||
source = mangaRow[MangaTable.sourceReference],
|
||||
url = mangaRow[MangaTable.url],
|
||||
title = mangaRow[MangaTable.title],
|
||||
artist = mangaRow[MangaTable.artist],
|
||||
author = mangaRow[MangaTable.author],
|
||||
description = mangaRow[MangaTable.description],
|
||||
genre = mangaRow[MangaTable.genre]?.split(", ") ?: emptyList(),
|
||||
status = MangaStatus.valueOf(mangaRow[MangaTable.status]).value,
|
||||
thumbnailUrl = mangaRow[MangaTable.thumbnail_url],
|
||||
dateAdded = TimeUnit.SECONDS.toMillis(mangaRow[MangaTable.inLibraryAt]),
|
||||
viewer = 0, // not supported in Tachidesk
|
||||
updateStrategy = UpdateStrategy.valueOf(mangaRow[MangaTable.updateStrategy]),
|
||||
)
|
||||
|
||||
val mangaId = mangaRow[MangaTable.id].value
|
||||
|
||||
if (flags.includeChapters) {
|
||||
val chapters = transaction {
|
||||
ChapterTable.select { ChapterTable.manga eq mangaId }
|
||||
.orderBy(ChapterTable.sourceOrder to SortOrder.DESC)
|
||||
.map {
|
||||
ChapterTable.toDataClass(it)
|
||||
}
|
||||
}
|
||||
val chapters =
|
||||
transaction {
|
||||
ChapterTable.select { ChapterTable.manga eq mangaId }
|
||||
.orderBy(ChapterTable.sourceOrder to SortOrder.DESC)
|
||||
.map {
|
||||
ChapterTable.toDataClass(it)
|
||||
}
|
||||
}
|
||||
|
||||
backupManga.chapters = chapters.map {
|
||||
BackupChapter(
|
||||
it.url,
|
||||
it.name,
|
||||
it.scanlator,
|
||||
it.read,
|
||||
it.bookmarked,
|
||||
it.lastPageRead,
|
||||
TimeUnit.SECONDS.toMillis(it.fetchedAt),
|
||||
it.uploadDate,
|
||||
it.chapterNumber,
|
||||
chapters.size - it.index
|
||||
)
|
||||
}
|
||||
backupManga.chapters =
|
||||
chapters.map {
|
||||
BackupChapter(
|
||||
it.url,
|
||||
it.name,
|
||||
it.scanlator,
|
||||
it.read,
|
||||
it.bookmarked,
|
||||
it.lastPageRead,
|
||||
TimeUnit.SECONDS.toMillis(it.fetchedAt),
|
||||
it.uploadDate,
|
||||
it.chapterNumber,
|
||||
chapters.size - it.index,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (flags.includeCategories) {
|
||||
@@ -246,7 +253,7 @@ object ProtoBackupExport : ProtoBackupBase() {
|
||||
BackupCategory(
|
||||
it.name,
|
||||
it.order,
|
||||
0 // not supported in Tachidesk
|
||||
0, // not supported in Tachidesk
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -260,7 +267,7 @@ object ProtoBackupExport : ProtoBackupBase() {
|
||||
val sourceRow = SourceTable.select { SourceTable.id eq it }.firstOrNull()
|
||||
BackupSource(
|
||||
sourceRow?.get(SourceTable.name) ?: "",
|
||||
it
|
||||
it,
|
||||
)
|
||||
}
|
||||
.toList()
|
||||
|
||||
@@ -47,9 +47,12 @@ object ProtoBackupImport : ProtoBackupBase() {
|
||||
private val errors = mutableListOf<Pair<Date, String>>()
|
||||
|
||||
private val backupMutex = Mutex()
|
||||
|
||||
sealed class BackupRestoreState {
|
||||
data object Idle : BackupRestoreState()
|
||||
|
||||
data class RestoringCategories(val totalManga: Int) : BackupRestoreState()
|
||||
|
||||
data class RestoringManga(val current: Int, val totalManga: Int, val title: String) : BackupRestoreState()
|
||||
}
|
||||
|
||||
@@ -70,26 +73,28 @@ object ProtoBackupImport : ProtoBackupBase() {
|
||||
restoreCategories(backup.backupCategories)
|
||||
}
|
||||
|
||||
val categoryMapping = transaction {
|
||||
backup.backupCategories.associate {
|
||||
it.order to CategoryTable.select { CategoryTable.name eq it.name }.first()[CategoryTable.id].value
|
||||
val categoryMapping =
|
||||
transaction {
|
||||
backup.backupCategories.associate {
|
||||
it.order to CategoryTable.select { CategoryTable.name eq it.name }.first()[CategoryTable.id].value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Store source mapping for error messages
|
||||
sourceMapping = backup.getSourceMap()
|
||||
|
||||
// Restore individual manga
|
||||
backup.backupManga.forEachIndexed { index, manga ->
|
||||
backupRestoreState.value = BackupRestoreState.RestoringManga(
|
||||
current = index + 1,
|
||||
totalManga = backup.backupManga.size,
|
||||
title = manga.title
|
||||
)
|
||||
backupRestoreState.value =
|
||||
BackupRestoreState.RestoringManga(
|
||||
current = index + 1,
|
||||
totalManga = backup.backupManga.size,
|
||||
title = manga.title,
|
||||
)
|
||||
restoreManga(
|
||||
backupManga = manga,
|
||||
backupCategories = backup.backupCategories,
|
||||
categoryMapping = categoryMapping
|
||||
categoryMapping = categoryMapping,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -126,7 +131,7 @@ object ProtoBackupImport : ProtoBackupBase() {
|
||||
private fun restoreManga(
|
||||
backupManga: BackupManga,
|
||||
backupCategories: List<BackupCategory>,
|
||||
categoryMapping: Map<Int, Int>
|
||||
categoryMapping: Map<Int, Int>,
|
||||
) {
|
||||
val manga = backupManga.getMangaImpl()
|
||||
val chapters = backupManga.getChaptersImpl()
|
||||
@@ -150,36 +155,38 @@ object ProtoBackupImport : ProtoBackupBase() {
|
||||
history: List<BackupHistory>,
|
||||
tracks: List<Track>,
|
||||
backupCategories: List<BackupCategory>,
|
||||
categoryMapping: Map<Int, Int>
|
||||
categoryMapping: Map<Int, Int>,
|
||||
) {
|
||||
val dbManga = transaction {
|
||||
MangaTable.select { (MangaTable.url eq manga.url) and (MangaTable.sourceReference eq manga.source) }
|
||||
.firstOrNull()
|
||||
}
|
||||
val dbManga =
|
||||
transaction {
|
||||
MangaTable.select { (MangaTable.url eq manga.url) and (MangaTable.sourceReference eq manga.source) }
|
||||
.firstOrNull()
|
||||
}
|
||||
|
||||
if (dbManga == null) { // Manga not in database
|
||||
transaction {
|
||||
// insert manga to database
|
||||
val mangaId = MangaTable.insertAndGetId {
|
||||
it[url] = manga.url
|
||||
it[title] = manga.title
|
||||
val mangaId =
|
||||
MangaTable.insertAndGetId {
|
||||
it[url] = manga.url
|
||||
it[title] = manga.title
|
||||
|
||||
it[artist] = manga.artist
|
||||
it[author] = manga.author
|
||||
it[description] = manga.description
|
||||
it[genre] = manga.genre
|
||||
it[status] = manga.status
|
||||
it[thumbnail_url] = manga.thumbnail_url
|
||||
it[updateStrategy] = manga.update_strategy.name
|
||||
it[artist] = manga.artist
|
||||
it[author] = manga.author
|
||||
it[description] = manga.description
|
||||
it[genre] = manga.genre
|
||||
it[status] = manga.status
|
||||
it[thumbnail_url] = manga.thumbnail_url
|
||||
it[updateStrategy] = manga.update_strategy.name
|
||||
|
||||
it[sourceReference] = manga.source
|
||||
it[sourceReference] = manga.source
|
||||
|
||||
it[initialized] = manga.description != null
|
||||
it[initialized] = manga.description != null
|
||||
|
||||
it[inLibrary] = manga.favorite
|
||||
it[inLibrary] = manga.favorite
|
||||
|
||||
it[inLibraryAt] = TimeUnit.MILLISECONDS.toSeconds(manga.date_added)
|
||||
}.value
|
||||
it[inLibraryAt] = TimeUnit.MILLISECONDS.toSeconds(manga.date_added)
|
||||
}.value
|
||||
|
||||
// insert chapter data
|
||||
val chaptersLength = chapters.size
|
||||
|
||||
@@ -24,7 +24,7 @@ object ProtoBackupValidator {
|
||||
val missingTrackers: List<String>,
|
||||
val mangasMissingSources: List<String>,
|
||||
@JsonIgnore
|
||||
val missingSourceIds: List<Pair<Long, String>>
|
||||
val missingSourceIds: List<Pair<Long, String>>,
|
||||
)
|
||||
|
||||
fun validate(backup: Backup): ValidationResult {
|
||||
@@ -34,9 +34,10 @@ object ProtoBackupValidator {
|
||||
|
||||
val sources = backup.getSourceMap()
|
||||
|
||||
val missingSources = transaction {
|
||||
sources.filter { SourceTable.select { SourceTable.id eq it.key }.firstOrNull() == null }
|
||||
}
|
||||
val missingSources =
|
||||
transaction {
|
||||
sources.filter { SourceTable.select { SourceTable.id eq it.key }.firstOrNull() == null }
|
||||
}
|
||||
|
||||
// val trackers = backup.backupManga
|
||||
// .flatMap { it.tracking }
|
||||
@@ -56,7 +57,7 @@ object ProtoBackupValidator {
|
||||
.sorted(),
|
||||
missingTrackers,
|
||||
emptyList(),
|
||||
missingSources.toList()
|
||||
missingSources.toList(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ data class Backup(
|
||||
@ProtoNumber(2) var backupCategories: List<BackupCategory> = emptyList(),
|
||||
// Bump by 100 to specify this is a 0.x value
|
||||
@ProtoNumber(100) var brokenBackupSources: List<BrokenBackupSource> = emptyList(),
|
||||
@ProtoNumber(101) var backupSources: List<BackupSource> = emptyList()
|
||||
@ProtoNumber(101) var backupSources: List<BackupSource> = emptyList(),
|
||||
) {
|
||||
fun getSourceMap(): Map<Long, String> {
|
||||
return (brokenBackupSources.map { BackupSource(it.name, it.sourceId) } + backupSources)
|
||||
|
||||
@@ -2,8 +2,6 @@ package suwayomi.tachidesk.manga.impl.backup.proto.models
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.protobuf.ProtoNumber
|
||||
import suwayomi.tachidesk.manga.impl.backup.models.Category
|
||||
import suwayomi.tachidesk.manga.impl.backup.models.CategoryImpl
|
||||
|
||||
@Serializable
|
||||
class BackupCategory(
|
||||
@@ -11,23 +9,5 @@ class BackupCategory(
|
||||
@ProtoNumber(2) var order: Int = 0,
|
||||
// @ProtoNumber(3) val updateInterval: Int = 0, 1.x value not used in 0.x
|
||||
// Bump by 100 to specify this is a 0.x value
|
||||
@ProtoNumber(100) var flags: Int = 0
|
||||
) {
|
||||
fun getCategoryImpl(): CategoryImpl {
|
||||
return CategoryImpl().apply {
|
||||
name = this@BackupCategory.name
|
||||
flags = this@BackupCategory.flags
|
||||
order = this@BackupCategory.order
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun copyFrom(category: Category): BackupCategory {
|
||||
return BackupCategory(
|
||||
name = category.name,
|
||||
order = category.order,
|
||||
flags = category.flags
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ProtoNumber(100) var flags: Int = 0,
|
||||
)
|
||||
|
||||
@@ -20,7 +20,7 @@ data class BackupChapter(
|
||||
@ProtoNumber(8) var dateUpload: Long = 0,
|
||||
// chapterNumber is called number is 1.x
|
||||
@ProtoNumber(9) var chapterNumber: Float = 0F,
|
||||
@ProtoNumber(10) var sourceOrder: Int = 0
|
||||
@ProtoNumber(10) var sourceOrder: Int = 0,
|
||||
) {
|
||||
fun toChapterImpl(): ChapterImpl {
|
||||
return ChapterImpl().apply {
|
||||
@@ -49,7 +49,7 @@ data class BackupChapter(
|
||||
lastPageRead = chapter.last_page_read,
|
||||
dateFetch = chapter.date_fetch,
|
||||
dateUpload = chapter.date_upload,
|
||||
sourceOrder = chapter.source_order
|
||||
sourceOrder = chapter.source_order,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,11 +6,11 @@ import kotlinx.serialization.protobuf.ProtoNumber
|
||||
@Serializable
|
||||
data class BrokenBackupHistory(
|
||||
@ProtoNumber(0) var url: String,
|
||||
@ProtoNumber(1) var lastRead: Long
|
||||
@ProtoNumber(1) var lastRead: Long,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class BackupHistory(
|
||||
@ProtoNumber(1) var url: String,
|
||||
@ProtoNumber(2) var lastRead: Long
|
||||
@ProtoNumber(2) var lastRead: Long,
|
||||
)
|
||||
|
||||
@@ -37,7 +37,7 @@ data class BackupManga(
|
||||
@ProtoNumber(102) var brokenHistory: List<BrokenBackupHistory> = emptyList(),
|
||||
@ProtoNumber(103) var viewer_flags: Int? = null,
|
||||
@ProtoNumber(104) var history: List<BackupHistory> = emptyList(),
|
||||
@ProtoNumber(105) var updateStrategy: UpdateStrategy = UpdateStrategy.ALWAYS_UPDATE
|
||||
@ProtoNumber(105) var updateStrategy: UpdateStrategy = UpdateStrategy.ALWAYS_UPDATE,
|
||||
) {
|
||||
fun getMangaImpl(): MangaImpl {
|
||||
return MangaImpl().apply {
|
||||
@@ -86,7 +86,7 @@ data class BackupManga(
|
||||
dateAdded = manga.date_added,
|
||||
viewer = manga.readingModeType,
|
||||
viewer_flags = manga.viewer_flags,
|
||||
chapterFlags = manga.chapter_flags
|
||||
chapterFlags = manga.chapter_flags,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,19 +7,19 @@ import kotlinx.serialization.protobuf.ProtoNumber
|
||||
@Serializable
|
||||
data class BrokenBackupSource(
|
||||
@ProtoNumber(0) var name: String = "",
|
||||
@ProtoNumber(1) var sourceId: Long
|
||||
@ProtoNumber(1) var sourceId: Long,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class BackupSource(
|
||||
@ProtoNumber(1) var name: String = "",
|
||||
@ProtoNumber(2) var sourceId: Long
|
||||
@ProtoNumber(2) var sourceId: Long,
|
||||
) {
|
||||
companion object {
|
||||
fun copyFrom(source: Source): BackupSource {
|
||||
return BackupSource(
|
||||
name = source.name,
|
||||
sourceId = source.id
|
||||
sourceId = source.id,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ data class BackupTracking(
|
||||
// startedReadingDate is called startReadTime in 1.x
|
||||
@ProtoNumber(10) var startedReadingDate: Long = 0,
|
||||
// finishedReadingDate is called endReadTime in 1.x
|
||||
@ProtoNumber(11) var finishedReadingDate: Long = 0
|
||||
@ProtoNumber(11) var finishedReadingDate: Long = 0,
|
||||
) {
|
||||
fun getTrackingImpl(): TrackImpl {
|
||||
return TrackImpl().apply {
|
||||
@@ -58,7 +58,7 @@ data class BackupTracking(
|
||||
status = track.status,
|
||||
startedReadingDate = track.started_reading_date,
|
||||
finishedReadingDate = track.finished_reading_date,
|
||||
trackingUrl = track.tracking_url
|
||||
trackingUrl = track.tracking_url,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,11 @@ import suwayomi.tachidesk.manga.model.table.PageTable
|
||||
import suwayomi.tachidesk.manga.model.table.toDataClass
|
||||
import java.io.File
|
||||
|
||||
suspend fun getChapterDownloadReady(chapterId: Int? = null, chapterIndex: Int? = null, mangaId: Int? = null): ChapterDataClass {
|
||||
suspend fun getChapterDownloadReady(
|
||||
chapterId: Int? = null,
|
||||
chapterIndex: Int? = null,
|
||||
mangaId: Int? = null,
|
||||
): ChapterDataClass {
|
||||
val chapter = ChapterForDownload(chapterId, chapterIndex, mangaId)
|
||||
|
||||
return chapter.asDownloadReady()
|
||||
@@ -39,14 +43,17 @@ suspend fun getChapterDownloadReadyById(chapterId: Int): ChapterDataClass {
|
||||
return getChapterDownloadReady(chapterId = chapterId)
|
||||
}
|
||||
|
||||
suspend fun getChapterDownloadReadyByIndex(chapterIndex: Int, mangaId: Int): ChapterDataClass {
|
||||
suspend fun getChapterDownloadReadyByIndex(
|
||||
chapterIndex: Int,
|
||||
mangaId: Int,
|
||||
): ChapterDataClass {
|
||||
return getChapterDownloadReady(chapterIndex = chapterIndex, mangaId = mangaId)
|
||||
}
|
||||
|
||||
private class ChapterForDownload(
|
||||
optChapterId: Int? = null,
|
||||
optChapterIndex: Int? = null,
|
||||
optMangaId: Int? = null
|
||||
optMangaId: Int? = null,
|
||||
) {
|
||||
suspend fun asDownloadReady(): ChapterDataClass {
|
||||
if (isNotCompletelyDownloaded()) {
|
||||
@@ -74,7 +81,11 @@ private class ChapterForDownload(
|
||||
mangaId = chapterEntry[ChapterTable.manga].value
|
||||
}
|
||||
|
||||
private fun freshChapterEntry(optChapterId: Int? = null, optChapterIndex: Int? = null, optMangaId: Int? = null) = transaction {
|
||||
private fun freshChapterEntry(
|
||||
optChapterId: Int? = null,
|
||||
optChapterIndex: Int? = null,
|
||||
optMangaId: Int? = null,
|
||||
) = transaction {
|
||||
ChapterTable.select {
|
||||
if (optChapterId != null) {
|
||||
ChapterTable.id eq optChapterId
|
||||
@@ -94,7 +105,7 @@ private class ChapterForDownload(
|
||||
SChapter.create().apply {
|
||||
url = chapterEntry[ChapterTable.url]
|
||||
name = chapterEntry[ChapterTable.name]
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -126,7 +137,7 @@ private class ChapterForDownload(
|
||||
|
||||
private fun updatePageCount(
|
||||
pageList: List<Page>,
|
||||
chapterId: Int
|
||||
chapterId: Int,
|
||||
) {
|
||||
transaction {
|
||||
ChapterTable.update({ ChapterTable.id eq chapterId }) {
|
||||
@@ -141,7 +152,7 @@ private class ChapterForDownload(
|
||||
return !(
|
||||
chapterEntry[ChapterTable.isDownloaded] &&
|
||||
(firstPageExists() || File(getChapterCbzPath(mangaId, chapterEntry[ChapterTable.id].value)).exists())
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun firstPageExists(): Boolean {
|
||||
@@ -154,7 +165,7 @@ private class ChapterForDownload(
|
||||
|
||||
return ImageResponse.findFileNameStartingWith(
|
||||
chapterDir,
|
||||
getPageName(0)
|
||||
getPageName(0),
|
||||
) != null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,16 +57,16 @@ object DownloadManager {
|
||||
private val downloadQueue = CopyOnWriteArrayList<DownloadChapter>()
|
||||
private val downloaders = ConcurrentHashMap<String, Downloader>()
|
||||
|
||||
private const val downloadQueueKey = "downloadQueueKey"
|
||||
private const val DOWNLOAD_QUEUE_KEY = "downloadQueueKey"
|
||||
private val sharedPreferences =
|
||||
Injekt.get<Application>().getSharedPreferences(DownloadManager::class.jvmName, Context.MODE_PRIVATE)
|
||||
|
||||
private fun loadDownloadQueue(): List<Int> {
|
||||
return sharedPreferences.getStringSet(downloadQueueKey, emptySet())?.mapNotNull { it.toInt() } ?: emptyList()
|
||||
return sharedPreferences.getStringSet(DOWNLOAD_QUEUE_KEY, emptySet())?.mapNotNull { it.toInt() } ?: emptyList()
|
||||
}
|
||||
|
||||
private fun saveDownloadQueue() {
|
||||
sharedPreferences.edit().putStringSet(downloadQueueKey, downloadQueue.map { it.chapter.id.toString() }.toSet())
|
||||
sharedPreferences.edit().putStringSet(DOWNLOAD_QUEUE_KEY, downloadQueue.map { it.chapter.id.toString() }.toSet())
|
||||
.apply()
|
||||
}
|
||||
|
||||
@@ -95,30 +95,32 @@ object DownloadManager {
|
||||
|
||||
fun notifyClient(ctx: WsContext) {
|
||||
ctx.send(
|
||||
getStatus()
|
||||
getStatus(),
|
||||
)
|
||||
}
|
||||
|
||||
fun handleRequest(ctx: WsMessageContext) {
|
||||
when (ctx.message()) {
|
||||
"STATUS" -> notifyClient(ctx)
|
||||
else -> ctx.send(
|
||||
"""
|
||||
else ->
|
||||
ctx.send(
|
||||
"""
|
||||
|Invalid command.
|
||||
|Supported commands are:
|
||||
| - STATUS
|
||||
| sends the current download status
|
||||
|
|
||||
""".trimMargin()
|
||||
)
|
||||
""".trimMargin(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val notifyFlow = MutableSharedFlow<Unit>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
|
||||
val status = notifyFlow.sample(1.seconds)
|
||||
.onStart { emit(Unit) }
|
||||
.map { getStatus() }
|
||||
val status =
|
||||
notifyFlow.sample(1.seconds)
|
||||
.onStart { emit(Unit) }
|
||||
.map { getStatus() }
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
@@ -129,6 +131,7 @@ object DownloadManager {
|
||||
}
|
||||
|
||||
private val saveQueueFlow = MutableSharedFlow<Unit>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
|
||||
init {
|
||||
saveQueueFlow.onEach { saveDownloadQueue() }.launchIn(scope)
|
||||
}
|
||||
@@ -159,11 +162,12 @@ object DownloadManager {
|
||||
} else {
|
||||
Status.Started
|
||||
},
|
||||
downloadQueue.toList()
|
||||
downloadQueue.toList(),
|
||||
)
|
||||
}
|
||||
|
||||
private val downloaderWatch = MutableSharedFlow<Unit>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
|
||||
init {
|
||||
serverConfig.subscribeTo(serverConfig.maxSourcesInParallel, { maxSourcesInParallel ->
|
||||
val runningDownloaders = downloaders.values.filter { it.isActive }
|
||||
@@ -186,14 +190,18 @@ object DownloadManager {
|
||||
val runningDownloaders = downloaders.values.filter { it.isActive }
|
||||
val availableDownloads = downloadQueue.filter { it.state != Error }
|
||||
|
||||
logger.info { "Running: ${runningDownloaders.size}, Queued: ${availableDownloads.size}, Failed: ${downloadQueue.size - availableDownloads.size}" }
|
||||
logger.info {
|
||||
"Running: ${runningDownloaders.size}, " +
|
||||
"Queued: ${availableDownloads.size}, " +
|
||||
"Failed: ${downloadQueue.size - availableDownloads.size}"
|
||||
}
|
||||
|
||||
if (runningDownloaders.size < serverConfig.maxSourcesInParallel.value) {
|
||||
availableDownloads.asSequence()
|
||||
.map { it.manga.sourceId }
|
||||
.distinct()
|
||||
.minus(
|
||||
runningDownloaders.map { it.sourceId }.toSet()
|
||||
runningDownloaders.map { it.sourceId }.toSet(),
|
||||
)
|
||||
.take(serverConfig.maxSourcesInParallel.value - runningDownloaders.size)
|
||||
.map { getDownloader(it) }
|
||||
@@ -212,24 +220,29 @@ object DownloadManager {
|
||||
}
|
||||
}
|
||||
|
||||
private fun getDownloader(sourceId: String) = downloaders.getOrPut(sourceId) {
|
||||
Downloader(
|
||||
scope = scope,
|
||||
sourceId = sourceId,
|
||||
downloadQueue = downloadQueue,
|
||||
notifier = ::notifyAllClients,
|
||||
onComplete = ::refreshDownloaders,
|
||||
onDownloadFinished = ::triggerSaveDownloadQueue
|
||||
)
|
||||
}
|
||||
|
||||
fun enqueueWithChapterIndex(mangaId: Int, chapterIndex: Int) {
|
||||
val chapter = transaction {
|
||||
ChapterTable
|
||||
.slice(ChapterTable.id)
|
||||
.select { ChapterTable.manga.eq(mangaId) and ChapterTable.sourceOrder.eq(chapterIndex) }
|
||||
.first()
|
||||
private fun getDownloader(sourceId: String) =
|
||||
downloaders.getOrPut(sourceId) {
|
||||
Downloader(
|
||||
scope = scope,
|
||||
sourceId = sourceId,
|
||||
downloadQueue = downloadQueue,
|
||||
notifier = ::notifyAllClients,
|
||||
onComplete = ::refreshDownloaders,
|
||||
onDownloadFinished = ::triggerSaveDownloadQueue,
|
||||
)
|
||||
}
|
||||
|
||||
fun enqueueWithChapterIndex(
|
||||
mangaId: Int,
|
||||
chapterIndex: Int,
|
||||
) {
|
||||
val chapter =
|
||||
transaction {
|
||||
ChapterTable
|
||||
.slice(ChapterTable.id)
|
||||
.select { ChapterTable.manga.eq(mangaId) and ChapterTable.sourceOrder.eq(chapterIndex) }
|
||||
.first()
|
||||
}
|
||||
enqueue(EnqueueInput(chapterIds = listOf(chapter[ChapterTable.id].value)))
|
||||
}
|
||||
|
||||
@@ -237,35 +250,38 @@ object DownloadManager {
|
||||
// Input might have additional formats in the future, such as "All for mangaID" or "Unread for categoryID"
|
||||
// Having this input format is just future-proofing
|
||||
data class EnqueueInput(
|
||||
val chapterIds: List<Int>?
|
||||
val chapterIds: List<Int>?,
|
||||
)
|
||||
|
||||
fun enqueue(input: EnqueueInput) {
|
||||
if (input.chapterIds.isNullOrEmpty()) return
|
||||
|
||||
val chapters = transaction {
|
||||
(ChapterTable innerJoin MangaTable)
|
||||
.select { ChapterTable.id inList input.chapterIds }
|
||||
.orderBy(ChapterTable.manga)
|
||||
.orderBy(ChapterTable.sourceOrder)
|
||||
.toList()
|
||||
}
|
||||
|
||||
val mangas = transaction {
|
||||
chapters.distinctBy { chapter -> chapter[MangaTable.id] }
|
||||
.map { MangaTable.toDataClass(it) }
|
||||
.associateBy { it.id }
|
||||
}
|
||||
|
||||
val inputPairs = transaction {
|
||||
chapters.map {
|
||||
Pair(
|
||||
// this should be safe because mangas is created above from chapters
|
||||
mangas[it[ChapterTable.manga].value]!!,
|
||||
ChapterTable.toDataClass(it)
|
||||
)
|
||||
val chapters =
|
||||
transaction {
|
||||
(ChapterTable innerJoin MangaTable)
|
||||
.select { ChapterTable.id inList input.chapterIds }
|
||||
.orderBy(ChapterTable.manga)
|
||||
.orderBy(ChapterTable.sourceOrder)
|
||||
.toList()
|
||||
}
|
||||
|
||||
val mangas =
|
||||
transaction {
|
||||
chapters.distinctBy { chapter -> chapter[MangaTable.id] }
|
||||
.map { MangaTable.toDataClass(it) }
|
||||
.associateBy { it.id }
|
||||
}
|
||||
|
||||
val inputPairs =
|
||||
transaction {
|
||||
chapters.map {
|
||||
Pair(
|
||||
// this should be safe because mangas is created above from chapters
|
||||
mangas[it[ChapterTable.manga].value]!!,
|
||||
ChapterTable.toDataClass(it),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
addMultipleToQueue(inputPairs)
|
||||
}
|
||||
@@ -289,17 +305,21 @@ object DownloadManager {
|
||||
* Tries to add chapter to queue.
|
||||
* If chapter is added, returns the created DownloadChapter, otherwise returns null
|
||||
*/
|
||||
private fun addToQueue(manga: MangaDataClass, chapter: ChapterDataClass): DownloadChapter? {
|
||||
private fun addToQueue(
|
||||
manga: MangaDataClass,
|
||||
chapter: ChapterDataClass,
|
||||
): DownloadChapter? {
|
||||
val downloadChapter = downloadQueue.firstOrNull { it.mangaId == manga.id && it.chapterIndex == chapter.index }
|
||||
|
||||
val addToQueue = downloadChapter == null
|
||||
if (addToQueue) {
|
||||
val newDownloadChapter = DownloadChapter(
|
||||
chapter.index,
|
||||
manga.id,
|
||||
chapter,
|
||||
manga
|
||||
)
|
||||
val newDownloadChapter =
|
||||
DownloadChapter(
|
||||
chapter.index,
|
||||
manga.id,
|
||||
chapter,
|
||||
manga,
|
||||
)
|
||||
downloadQueue.add(newDownloadChapter)
|
||||
triggerSaveDownloadQueue()
|
||||
logger.debug { "Added chapter ${chapter.id} to download queue ($newDownloadChapter)" }
|
||||
@@ -325,11 +345,17 @@ object DownloadManager {
|
||||
dequeue(downloadQueue.filter { it.chapter.id in input.chapterIds }.toSet())
|
||||
}
|
||||
|
||||
fun dequeue(chapterIndex: Int, mangaId: Int) {
|
||||
fun dequeue(
|
||||
chapterIndex: Int,
|
||||
mangaId: Int,
|
||||
) {
|
||||
dequeue(downloadQueue.filter { it.mangaId == mangaId && it.chapterIndex == chapterIndex }.toSet())
|
||||
}
|
||||
|
||||
fun dequeue(mangaIds: List<Int>, chaptersToIgnore: List<Int> = emptyList()) {
|
||||
fun dequeue(
|
||||
mangaIds: List<Int>,
|
||||
chaptersToIgnore: List<Int> = emptyList(),
|
||||
) {
|
||||
dequeue(downloadQueue.filter { it.mangaId in mangaIds && it.chapter.id !in chaptersToIgnore }.toSet())
|
||||
}
|
||||
|
||||
@@ -342,21 +368,33 @@ object DownloadManager {
|
||||
notifyAllClients()
|
||||
}
|
||||
|
||||
fun reorder(chapterIndex: Int, mangaId: Int, to: Int) {
|
||||
val download = downloadQueue.find { it.mangaId == mangaId && it.chapterIndex == chapterIndex }
|
||||
?: return
|
||||
fun reorder(
|
||||
chapterIndex: Int,
|
||||
mangaId: Int,
|
||||
to: Int,
|
||||
) {
|
||||
val download =
|
||||
downloadQueue.find { it.mangaId == mangaId && it.chapterIndex == chapterIndex }
|
||||
?: return
|
||||
|
||||
reorder(download, to)
|
||||
}
|
||||
|
||||
fun reorder(chapterId: Int, to: Int) {
|
||||
val download = downloadQueue.find { it.chapter.id == chapterId }
|
||||
?: return
|
||||
fun reorder(
|
||||
chapterId: Int,
|
||||
to: Int,
|
||||
) {
|
||||
val download =
|
||||
downloadQueue.find { it.chapter.id == chapterId }
|
||||
?: return
|
||||
|
||||
reorder(download, to)
|
||||
}
|
||||
|
||||
private fun reorder(download: DownloadChapter, to: Int) {
|
||||
private fun reorder(
|
||||
download: DownloadChapter,
|
||||
to: Int,
|
||||
) {
|
||||
require(to >= 0) { "'to' must be over or equal to 0" }
|
||||
|
||||
logger.debug { "reorder download $download from ${downloadQueue.indexOf(download)} to $to" }
|
||||
@@ -400,5 +438,5 @@ object DownloadManager {
|
||||
enum class DownloaderState(val state: Int) {
|
||||
Stopped(0),
|
||||
Running(1),
|
||||
Paused(2)
|
||||
Paused(2),
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ class Downloader(
|
||||
private val downloadQueue: CopyOnWriteArrayList<DownloadChapter>,
|
||||
private val notifier: (immediate: Boolean) -> Unit,
|
||||
private val onComplete: () -> Unit,
|
||||
private val onDownloadFinished: () -> Unit
|
||||
private val onDownloadFinished: () -> Unit,
|
||||
) {
|
||||
private val logger = KotlinLogging.logger("${Downloader::class.java.name} source($sourceId)")
|
||||
|
||||
@@ -45,9 +45,13 @@ class Downloader(
|
||||
get() = downloadQueue.filter { it.manga.sourceId == sourceId }
|
||||
|
||||
class StopDownloadException : Exception("Cancelled download")
|
||||
|
||||
class PauseDownloadException : Exception("Pause download")
|
||||
|
||||
private suspend fun step(download: DownloadChapter?, immediate: Boolean) {
|
||||
private suspend fun step(
|
||||
download: DownloadChapter?,
|
||||
immediate: Boolean,
|
||||
) {
|
||||
notifier(immediate)
|
||||
currentCoroutineContext().ensureActive()
|
||||
if (download != null && download != availableSourceDownloads.firstOrNull { it.state != Error }) {
|
||||
@@ -64,16 +68,17 @@ class Downloader(
|
||||
|
||||
fun start() {
|
||||
if (!isActive) {
|
||||
job = scope.launch {
|
||||
run()
|
||||
}.also { job ->
|
||||
job.invokeOnCompletion {
|
||||
if (it !is CancellationException) {
|
||||
logger.debug { "completed" }
|
||||
onComplete()
|
||||
job =
|
||||
scope.launch {
|
||||
run()
|
||||
}.also { job ->
|
||||
job.invokeOnCompletion {
|
||||
if (it !is CancellationException) {
|
||||
logger.debug { "completed" }
|
||||
onComplete()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
logger.debug { "started" }
|
||||
notifier(false)
|
||||
}
|
||||
@@ -84,7 +89,10 @@ class Downloader(
|
||||
logger.debug { "stopped" }
|
||||
}
|
||||
|
||||
private suspend fun finishDownload(logger: KLogger, download: DownloadChapter) {
|
||||
private suspend fun finishDownload(
|
||||
logger: KLogger,
|
||||
download: DownloadChapter,
|
||||
) {
|
||||
downloadQueue.removeIf { it.mangaId == download.mangaId && it.chapterIndex == download.chapterIndex }
|
||||
step(null, false)
|
||||
logger.debug { "finished" }
|
||||
@@ -93,9 +101,10 @@ class Downloader(
|
||||
|
||||
private suspend fun run() {
|
||||
while (downloadQueue.isNotEmpty() && currentCoroutineContext().isActive) {
|
||||
val download = availableSourceDownloads.firstOrNull {
|
||||
(it.state == Queued || it.state == Finished || (it.state == Error && it.tries < 3)) // 3 re-tries per download
|
||||
} ?: break
|
||||
val download =
|
||||
availableSourceDownloads.firstOrNull {
|
||||
(it.state == Queued || it.state == Finished || (it.state == Error && it.tries < 3)) // 3 re-tries per download
|
||||
} ?: break
|
||||
|
||||
val logContext = "${logger.name} - downloadChapter($download))"
|
||||
val downloadLogger = KotlinLogging.logger(logContext)
|
||||
@@ -120,7 +129,9 @@ class Downloader(
|
||||
ChapterDownloadHelper.download(download.mangaId, download.chapter.id, download, scope, this::step)
|
||||
download.state = Finished
|
||||
transaction {
|
||||
ChapterTable.update({ (ChapterTable.manga eq download.mangaId) and (ChapterTable.sourceOrder eq download.chapterIndex) }) {
|
||||
ChapterTable.update(
|
||||
{ (ChapterTable.manga eq download.mangaId) and (ChapterTable.sourceOrder eq download.chapterIndex) },
|
||||
) {
|
||||
it[isDownloaded] = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ abstract class ChaptersFilesProvider(val mangaId: Int, val chapterId: Int) : Dow
|
||||
open suspend fun downloadImpl(
|
||||
download: DownloadChapter,
|
||||
scope: CoroutineScope,
|
||||
step: suspend (DownloadChapter?, Boolean) -> Unit
|
||||
step: suspend (DownloadChapter?, Boolean) -> Unit,
|
||||
): Boolean {
|
||||
val pageCount = download.chapter.pageCount
|
||||
val chapterDir = getChapterCachePath(mangaId, chapterId)
|
||||
@@ -42,16 +42,17 @@ abstract class ChaptersFilesProvider(val mangaId: Int, val chapterId: Int) : Dow
|
||||
Page.getPageImage(
|
||||
mangaId = download.mangaId,
|
||||
chapterIndex = download.chapterIndex,
|
||||
index = pageNum
|
||||
index = pageNum,
|
||||
) { flow ->
|
||||
pageProgressJob = flow
|
||||
.sample(100)
|
||||
.distinctUntilChanged()
|
||||
.onEach {
|
||||
download.progress = (pageNum.toFloat() + (it.toFloat() * 0.01f)) / pageCount
|
||||
step(null, false) // don't throw on canceled download here since we can't do anything
|
||||
}
|
||||
.launchIn(scope)
|
||||
pageProgressJob =
|
||||
flow
|
||||
.sample(100)
|
||||
.distinctUntilChanged()
|
||||
.onEach {
|
||||
download.progress = (pageNum.toFloat() + (it.toFloat() * 0.01f)) / pageCount
|
||||
step(null, false) // don't throw on canceled download here since we can't do anything
|
||||
}
|
||||
.launchIn(scope)
|
||||
}
|
||||
} finally {
|
||||
// always cancel the page progress job even if it throws an exception to avoid memory leaks
|
||||
|
||||
@@ -13,7 +13,11 @@ fun interface FileDownload0Args : FileDownload {
|
||||
}
|
||||
|
||||
fun interface FileDownload3Args<A, B, C> : FileDownload {
|
||||
suspend fun execute(a: A, b: B, c: C): Boolean
|
||||
suspend fun execute(
|
||||
a: A,
|
||||
b: B,
|
||||
c: C,
|
||||
): Boolean
|
||||
|
||||
override suspend fun executeDownload(vararg args: Any): Boolean {
|
||||
return execute(args[0] as A, args[1] as B, args[2] as C)
|
||||
|
||||
@@ -28,7 +28,7 @@ class ArchiveProvider(mangaId: Int, chapterId: Int) : ChaptersFilesProvider(mang
|
||||
override suspend fun downloadImpl(
|
||||
download: DownloadChapter,
|
||||
scope: CoroutineScope,
|
||||
step: suspend (DownloadChapter?, Boolean) -> Unit
|
||||
step: suspend (DownloadChapter?, Boolean) -> Unit,
|
||||
): Boolean {
|
||||
val mangaDownloadFolder = File(getMangaDownloadDir(mangaId))
|
||||
val outputFile = File(getChapterCbzPath(mangaId, chapterId))
|
||||
@@ -71,7 +71,10 @@ class ArchiveProvider(mangaId: Int, chapterId: Int) : ChaptersFilesProvider(mang
|
||||
return false
|
||||
}
|
||||
|
||||
private fun handleExistingCbzFile(cbzFile: File, chapterFolder: File) {
|
||||
private fun handleExistingCbzFile(
|
||||
cbzFile: File,
|
||||
chapterFolder: File,
|
||||
) {
|
||||
if (!chapterFolder.exists()) chapterFolder.mkdirs()
|
||||
ZipArchiveInputStream(cbzFile.inputStream()).use { zipInputStream ->
|
||||
var zipEntry = zipInputStream.nextEntry
|
||||
|
||||
@@ -25,7 +25,7 @@ class FolderProvider(mangaId: Int, chapterId: Int) : ChaptersFilesProvider(manga
|
||||
override suspend fun downloadImpl(
|
||||
download: DownloadChapter,
|
||||
scope: CoroutineScope,
|
||||
step: suspend (DownloadChapter?, Boolean) -> Unit
|
||||
step: suspend (DownloadChapter?, Boolean) -> Unit,
|
||||
): Boolean {
|
||||
val chapterDir = getChapterDownloadPath(mangaId, chapterId)
|
||||
val folder = File(chapterDir)
|
||||
@@ -51,10 +51,14 @@ class FolderProvider(mangaId: Int, chapterId: Int) : ChaptersFilesProvider(manga
|
||||
return File(chapterDir).deleteRecursively()
|
||||
}
|
||||
|
||||
private fun isExistingFile(folder: File, fileName: String): Boolean {
|
||||
val existingFile = folder.listFiles { file ->
|
||||
file.isFile && file.name.startsWith(fileName)
|
||||
}?.firstOrNull()
|
||||
private fun isExistingFile(
|
||||
folder: File,
|
||||
fileName: String,
|
||||
): Boolean {
|
||||
val existingFile =
|
||||
folder.listFiles { file ->
|
||||
file.isFile && file.name.startsWith(fileName)
|
||||
}?.firstOrNull()
|
||||
return existingFile?.exists() == true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ class DownloadChapter(
|
||||
var manga: MangaDataClass,
|
||||
var state: DownloadState = Queued,
|
||||
var progress: Float = 0f,
|
||||
var tries: Int = 0
|
||||
var tries: Int = 0,
|
||||
) {
|
||||
override fun toString(): String {
|
||||
return "${manga.title} ($mangaId) - ${chapter.name} (${chapter.id}) | state= $state, tries= $tries, progress= $progress"
|
||||
|
||||
@@ -11,5 +11,5 @@ enum class DownloadState(val state: Int) {
|
||||
Queued(0),
|
||||
Downloading(1),
|
||||
Finished(2),
|
||||
Error(3)
|
||||
Error(3),
|
||||
}
|
||||
|
||||
@@ -8,10 +8,11 @@ package suwayomi.tachidesk.manga.impl.download.model
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
||||
|
||||
enum class Status {
|
||||
Stopped, Started;
|
||||
Stopped,
|
||||
Started,
|
||||
}
|
||||
|
||||
data class DownloadStatus(
|
||||
val status: Status,
|
||||
val queue: List<DownloadChapter>
|
||||
val queue: List<DownloadChapter>,
|
||||
)
|
||||
|
||||
@@ -71,7 +71,10 @@ object Extension {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun installExternalExtension(inputStream: InputStream, apkName: String): Int {
|
||||
suspend fun installExternalExtension(
|
||||
inputStream: InputStream,
|
||||
apkName: String,
|
||||
): Int {
|
||||
return installAPK(true) {
|
||||
val savePath = "${applicationDirs.extensionsRoot}/$apkName"
|
||||
logger.debug { "Saving apk at $apkName" }
|
||||
@@ -87,15 +90,19 @@ object Extension {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun installAPK(forceReinstall: Boolean = false, fetcher: suspend () -> String): Int {
|
||||
suspend fun installAPK(
|
||||
forceReinstall: Boolean = false,
|
||||
fetcher: suspend () -> String,
|
||||
): Int {
|
||||
val apkFilePath = fetcher()
|
||||
val apkName = File(apkFilePath).name
|
||||
|
||||
// check if we don't have the extension already installed
|
||||
// if it's installed and we want to update, it first has to be uninstalled
|
||||
val isInstalled = transaction {
|
||||
ExtensionTable.select { ExtensionTable.apkName eq apkName }.firstOrNull()
|
||||
}?.get(ExtensionTable.isInstalled) ?: false
|
||||
val isInstalled =
|
||||
transaction {
|
||||
ExtensionTable.select { ExtensionTable.apkName eq apkName }.firstOrNull()
|
||||
}?.get(ExtensionTable.isInstalled) ?: false
|
||||
|
||||
val fileNameWithoutType = apkName.substringBefore(".apk")
|
||||
|
||||
@@ -119,7 +126,7 @@ object Extension {
|
||||
if (libVersion < LIB_VERSION_MIN || libVersion > LIB_VERSION_MAX) {
|
||||
throw Exception(
|
||||
"Lib version is $libVersion, while only versions " +
|
||||
"$LIB_VERSION_MIN to $LIB_VERSION_MAX are allowed"
|
||||
"$LIB_VERSION_MIN to $LIB_VERSION_MAX are allowed",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -148,18 +155,20 @@ object Extension {
|
||||
|
||||
// collect sources from the extension
|
||||
val extensionMainClassInstance = loadExtensionSources(jarFilePath, className)
|
||||
val sources: List<CatalogueSource> = when (extensionMainClassInstance) {
|
||||
is Source -> listOf(extensionMainClassInstance)
|
||||
is SourceFactory -> extensionMainClassInstance.createSources()
|
||||
else -> throw RuntimeException("Unknown source class type! ${extensionMainClassInstance.javaClass}")
|
||||
}.map { it as CatalogueSource }
|
||||
val sources: List<CatalogueSource> =
|
||||
when (extensionMainClassInstance) {
|
||||
is Source -> listOf(extensionMainClassInstance)
|
||||
is SourceFactory -> extensionMainClassInstance.createSources()
|
||||
else -> throw RuntimeException("Unknown source class type! ${extensionMainClassInstance.javaClass}")
|
||||
}.map { it as CatalogueSource }
|
||||
|
||||
val langs = sources.map { it.lang }.toSet()
|
||||
val extensionLang = when (langs.size) {
|
||||
0 -> ""
|
||||
1 -> langs.first()
|
||||
else -> "all"
|
||||
}
|
||||
val extensionLang =
|
||||
when (langs.size) {
|
||||
0 -> ""
|
||||
1 -> langs.first()
|
||||
else -> "all"
|
||||
}
|
||||
|
||||
val extensionName = packageInfo.applicationInfo.nonLocalizedLabel.toString().substringAfter("Tachiyomi: ")
|
||||
|
||||
@@ -205,7 +214,10 @@ object Extension {
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractAssetsFromApk(apkPath: String, jarPath: String) {
|
||||
private fun extractAssetsFromApk(
|
||||
apkPath: String,
|
||||
jarPath: String,
|
||||
) {
|
||||
val apkFile = File(apkPath)
|
||||
val jarFile = File(jarPath)
|
||||
|
||||
@@ -256,7 +268,10 @@ object Extension {
|
||||
|
||||
private val network: NetworkHelper by injectLazy()
|
||||
|
||||
private suspend fun downloadAPKFile(url: String, savePath: String) {
|
||||
private suspend fun downloadAPKFile(
|
||||
url: String,
|
||||
savePath: String,
|
||||
) {
|
||||
val request = Request.Builder().url(url).build()
|
||||
val response = network.client.newCall(request).await()
|
||||
|
||||
@@ -275,23 +290,24 @@ object Extension {
|
||||
val extensionRecord = transaction { ExtensionTable.select { ExtensionTable.pkgName eq pkgName }.first() }
|
||||
val fileNameWithoutType = extensionRecord[ExtensionTable.apkName].substringBefore(".apk")
|
||||
val jarPath = "${applicationDirs.extensionsRoot}/$fileNameWithoutType.jar"
|
||||
val sources = transaction {
|
||||
val extensionId = extensionRecord[ExtensionTable.id].value
|
||||
val sources =
|
||||
transaction {
|
||||
val extensionId = extensionRecord[ExtensionTable.id].value
|
||||
|
||||
val sources = SourceTable.select { SourceTable.extension eq extensionId }.map { it[SourceTable.id].value }
|
||||
val sources = SourceTable.select { SourceTable.extension eq extensionId }.map { it[SourceTable.id].value }
|
||||
|
||||
SourceTable.deleteWhere { SourceTable.extension eq extensionId }
|
||||
SourceTable.deleteWhere { SourceTable.extension eq extensionId }
|
||||
|
||||
if (extensionRecord[ExtensionTable.isObsolete]) {
|
||||
ExtensionTable.deleteWhere { ExtensionTable.pkgName eq pkgName }
|
||||
} else {
|
||||
ExtensionTable.update({ ExtensionTable.pkgName eq pkgName }) {
|
||||
it[isInstalled] = false
|
||||
if (extensionRecord[ExtensionTable.isObsolete]) {
|
||||
ExtensionTable.deleteWhere { ExtensionTable.pkgName eq pkgName }
|
||||
} else {
|
||||
ExtensionTable.update({ ExtensionTable.pkgName eq pkgName }) {
|
||||
it[isInstalled] = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sources
|
||||
}
|
||||
sources
|
||||
}
|
||||
|
||||
if (File(jarPath).exists()) {
|
||||
// free up the file descriptor if exists
|
||||
@@ -323,17 +339,18 @@ object Extension {
|
||||
}
|
||||
|
||||
suspend fun getExtensionIcon(apkName: String): Pair<InputStream, String> {
|
||||
val iconUrl = if (apkName == "localSource") {
|
||||
""
|
||||
} else {
|
||||
transaction { ExtensionTable.select { ExtensionTable.apkName eq apkName }.first() }[ExtensionTable.iconUrl]
|
||||
}
|
||||
val iconUrl =
|
||||
if (apkName == "localSource") {
|
||||
""
|
||||
} else {
|
||||
transaction { ExtensionTable.select { ExtensionTable.apkName eq apkName }.first() }[ExtensionTable.iconUrl]
|
||||
}
|
||||
|
||||
val cacheSaveDir = "${applicationDirs.extensionsRoot}/icon"
|
||||
|
||||
return getImageResponse(cacheSaveDir, apkName) {
|
||||
network.client.newCall(
|
||||
GET(iconUrl)
|
||||
GET(iconUrl),
|
||||
).await()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,23 +48,24 @@ object ExtensionsList {
|
||||
return extensionTableAsDataClass()
|
||||
}
|
||||
|
||||
fun extensionTableAsDataClass() = transaction {
|
||||
ExtensionTable.selectAll().filter { it[ExtensionTable.name] != LocalSource.EXTENSION_NAME }.map {
|
||||
ExtensionDataClass(
|
||||
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]
|
||||
)
|
||||
fun extensionTableAsDataClass() =
|
||||
transaction {
|
||||
ExtensionTable.selectAll().filter { it[ExtensionTable.name] != LocalSource.EXTENSION_NAME }.map {
|
||||
ExtensionDataClass(
|
||||
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],
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateExtensionDatabase(foundExtensions: List<OnlineExtension>) {
|
||||
transaction {
|
||||
|
||||
@@ -36,7 +36,7 @@ object ExtensionGithubApi {
|
||||
val nsfw: Int,
|
||||
val hasReadme: Int = 0,
|
||||
val hasChangelog: Int = 0,
|
||||
val sources: List<ExtensionSourceJsonObject>?
|
||||
val sources: List<ExtensionSourceJsonObject>?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@@ -44,27 +44,29 @@ object ExtensionGithubApi {
|
||||
val name: String,
|
||||
val lang: String,
|
||||
val id: Long,
|
||||
val baseUrl: String
|
||||
val baseUrl: String,
|
||||
)
|
||||
|
||||
private var requiresFallbackSource = false
|
||||
|
||||
suspend fun findExtensions(): List<OnlineExtension> {
|
||||
val githubResponse = if (requiresFallbackSource) {
|
||||
null
|
||||
} else {
|
||||
try {
|
||||
client.newCall(GET("${REPO_URL_PREFIX}index.min.json")).awaitSuccess()
|
||||
} catch (e: Throwable) {
|
||||
logger.error(e) { "Failed to get extensions from GitHub" }
|
||||
requiresFallbackSource = true
|
||||
val githubResponse =
|
||||
if (requiresFallbackSource) {
|
||||
null
|
||||
} else {
|
||||
try {
|
||||
client.newCall(GET("${REPO_URL_PREFIX}index.min.json")).awaitSuccess()
|
||||
} catch (e: Throwable) {
|
||||
logger.error(e) { "Failed to get extensions from GitHub" }
|
||||
requiresFallbackSource = true
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val response = githubResponse ?: run {
|
||||
client.newCall(GET("${FALLBACK_REPO_URL_PREFIX}index.min.json")).awaitSuccess()
|
||||
}
|
||||
val response =
|
||||
githubResponse ?: run {
|
||||
client.newCall(GET("${FALLBACK_REPO_URL_PREFIX}index.min.json")).awaitSuccess()
|
||||
}
|
||||
|
||||
return with(json) {
|
||||
response
|
||||
@@ -107,7 +109,7 @@ object ExtensionGithubApi {
|
||||
hasChangelog = it.hasChangelog == 1,
|
||||
sources = it.sources?.toExtensionSources() ?: emptyList(),
|
||||
apkName = it.apk,
|
||||
iconUrl = "${REPO_URL_PREFIX}icon/${it.apk.replace(".apk", ".png")}"
|
||||
iconUrl = "${REPO_URL_PREFIX}icon/${it.apk.replace(".apk", ".png")}",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -118,7 +120,7 @@ object ExtensionGithubApi {
|
||||
name = it.name,
|
||||
lang = it.lang,
|
||||
id = it.id,
|
||||
baseUrl = it.baseUrl
|
||||
baseUrl = it.baseUrl,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ data class OnlineExtensionSource(
|
||||
val name: String,
|
||||
val lang: String,
|
||||
val id: Long,
|
||||
val baseUrl: String
|
||||
val baseUrl: String,
|
||||
)
|
||||
|
||||
data class OnlineExtension(
|
||||
@@ -25,5 +25,5 @@ data class OnlineExtension(
|
||||
val hasReadme: Boolean,
|
||||
val hasChangelog: Boolean,
|
||||
val sources: List<OnlineExtensionSource>,
|
||||
val iconUrl: String
|
||||
val iconUrl: String,
|
||||
)
|
||||
|
||||
@@ -5,7 +5,14 @@ import suwayomi.tachidesk.manga.model.dataclass.CategoryDataClass
|
||||
|
||||
interface IUpdater {
|
||||
fun getLastUpdateTimestamp(): Long
|
||||
fun addCategoriesToUpdateQueue(categories: List<CategoryDataClass>, clear: Boolean?, forceAll: Boolean)
|
||||
|
||||
fun addCategoriesToUpdateQueue(
|
||||
categories: List<CategoryDataClass>,
|
||||
clear: Boolean?,
|
||||
forceAll: Boolean,
|
||||
)
|
||||
|
||||
val status: StateFlow<UpdateStatus>
|
||||
|
||||
fun reset()
|
||||
}
|
||||
|
||||
@@ -7,10 +7,10 @@ enum class JobStatus {
|
||||
RUNNING,
|
||||
COMPLETE,
|
||||
FAILED,
|
||||
SKIPPED
|
||||
SKIPPED,
|
||||
}
|
||||
|
||||
data class UpdateJob(
|
||||
val manga: MangaDataClass,
|
||||
val status: JobStatus = JobStatus.PENDING
|
||||
val status: JobStatus = JobStatus.PENDING,
|
||||
)
|
||||
|
||||
@@ -5,7 +5,8 @@ import suwayomi.tachidesk.manga.model.dataclass.CategoryDataClass
|
||||
import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass
|
||||
|
||||
enum class CategoryUpdateStatus {
|
||||
UPDATING, SKIPPED
|
||||
UPDATING,
|
||||
SKIPPED,
|
||||
}
|
||||
|
||||
data class UpdateStatus(
|
||||
@@ -13,16 +14,21 @@ data class UpdateStatus(
|
||||
val mangaStatusMap: Map<JobStatus, List<MangaDataClass>> = emptyMap(),
|
||||
val running: Boolean = false,
|
||||
@JsonIgnore
|
||||
val numberOfJobs: Int = 0
|
||||
val numberOfJobs: Int = 0,
|
||||
) {
|
||||
|
||||
constructor(categories: Map<CategoryUpdateStatus, List<CategoryDataClass>>, jobs: List<UpdateJob>, skippedMangas: List<MangaDataClass>, running: Boolean) : this(
|
||||
constructor(
|
||||
categories: Map<CategoryUpdateStatus, List<CategoryDataClass>>,
|
||||
jobs: List<UpdateJob>,
|
||||
skippedMangas: List<MangaDataClass>,
|
||||
running: Boolean,
|
||||
) : this(
|
||||
categories,
|
||||
mangaStatusMap = jobs.groupBy { it.status }
|
||||
.mapValues { entry ->
|
||||
entry.value.map { it.manga }
|
||||
}.plus(Pair(JobStatus.SKIPPED, skippedMangas)),
|
||||
mangaStatusMap =
|
||||
jobs.groupBy { it.status }
|
||||
.mapValues { entry ->
|
||||
entry.value.map { it.manga }
|
||||
}.plus(Pair(JobStatus.SKIPPED, skippedMangas)),
|
||||
running = running,
|
||||
numberOfJobs = jobs.size
|
||||
numberOfJobs = jobs.size,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ class Updater : IUpdater {
|
||||
}
|
||||
}
|
||||
},
|
||||
ignoreInitialValue = false
|
||||
ignoreInitialValue = false,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -87,7 +87,11 @@ class Updater : IUpdater {
|
||||
return
|
||||
}
|
||||
|
||||
logger.info { "Trigger global update (interval= ${serverConfig.globalUpdateInterval.value}h, lastAutomatedUpdate= ${Date(lastAutomatedUpdate)})" }
|
||||
logger.info {
|
||||
"Trigger global update (interval= ${serverConfig.globalUpdateInterval.value}h, lastAutomatedUpdate= ${Date(
|
||||
lastAutomatedUpdate,
|
||||
)})"
|
||||
}
|
||||
addCategoriesToUpdateQueue(Category.getCategoryList(), clear = true, forceAll = false)
|
||||
}
|
||||
|
||||
@@ -103,7 +107,10 @@ class Updater : IUpdater {
|
||||
val lastAutomatedUpdate = preferences.getLong(lastAutomatedUpdateKey, 0)
|
||||
val timeToNextExecution = (updateInterval - (System.currentTimeMillis() - lastAutomatedUpdate)).mod(updateInterval)
|
||||
|
||||
val wasPreviousUpdateTriggered = System.currentTimeMillis() - (if (lastAutomatedUpdate > 0) lastAutomatedUpdate else System.currentTimeMillis()) < updateInterval
|
||||
val wasPreviousUpdateTriggered =
|
||||
System.currentTimeMillis() - (
|
||||
if (lastAutomatedUpdate > 0) lastAutomatedUpdate else System.currentTimeMillis()
|
||||
) < updateInterval
|
||||
if (!wasPreviousUpdateTriggered) {
|
||||
autoUpdateTask()
|
||||
}
|
||||
@@ -114,7 +121,12 @@ class Updater : IUpdater {
|
||||
/**
|
||||
* Updates the status and sustains the "skippedMangas"
|
||||
*/
|
||||
private fun updateStatus(jobs: List<UpdateJob>, running: Boolean, categories: Map<CategoryUpdateStatus, List<CategoryDataClass>>? = null, skippedMangas: List<MangaDataClass>? = null) {
|
||||
private fun updateStatus(
|
||||
jobs: List<UpdateJob>,
|
||||
running: Boolean,
|
||||
categories: Map<CategoryUpdateStatus, List<CategoryDataClass>>? = null,
|
||||
skippedMangas: List<MangaDataClass>? = null,
|
||||
) {
|
||||
val updateStatusCategories = categories ?: _status.value.categoryStatusMap
|
||||
val tmpSkippedMangas = skippedMangas ?: _status.value.mangaStatusMap[JobStatus.SKIPPED] ?: emptyList()
|
||||
_status.update { UpdateStatus(updateStatusCategories, jobs, tmpSkippedMangas, running) }
|
||||
@@ -136,7 +148,7 @@ class Updater : IUpdater {
|
||||
process(job),
|
||||
tracker.any { (_, job) ->
|
||||
job.status == JobStatus.PENDING || job.status == JobStatus.RUNNING
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -148,19 +160,24 @@ class Updater : IUpdater {
|
||||
private suspend fun process(job: UpdateJob): List<UpdateJob> {
|
||||
tracker[job.manga.id] = job.copy(status = JobStatus.RUNNING)
|
||||
updateStatus(tracker.values.toList(), true)
|
||||
tracker[job.manga.id] = try {
|
||||
logger.info { "Updating \"${job.manga.title}\" (source: ${job.manga.sourceId})" }
|
||||
Chapter.getChapterList(job.manga.id, true)
|
||||
job.copy(status = JobStatus.COMPLETE)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
logger.error(e) { "Error while updating ${job.manga.title}" }
|
||||
job.copy(status = JobStatus.FAILED)
|
||||
}
|
||||
tracker[job.manga.id] =
|
||||
try {
|
||||
logger.info { "Updating \"${job.manga.title}\" (source: ${job.manga.sourceId})" }
|
||||
Chapter.getChapterList(job.manga.id, true)
|
||||
job.copy(status = JobStatus.COMPLETE)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
logger.error(e) { "Error while updating ${job.manga.title}" }
|
||||
job.copy(status = JobStatus.FAILED)
|
||||
}
|
||||
return tracker.values.toList()
|
||||
}
|
||||
|
||||
override fun addCategoriesToUpdateQueue(categories: List<CategoryDataClass>, clear: Boolean?, forceAll: Boolean) {
|
||||
override fun addCategoriesToUpdateQueue(
|
||||
categories: List<CategoryDataClass>,
|
||||
clear: Boolean?,
|
||||
forceAll: Boolean,
|
||||
) {
|
||||
preferences.putLong(lastUpdateKey, System.currentTimeMillis())
|
||||
|
||||
if (clear == true) {
|
||||
@@ -171,31 +188,53 @@ class Updater : IUpdater {
|
||||
val excludedCategories = includeInUpdateStatusToCategoryMap[IncludeInUpdate.EXCLUDE].orEmpty()
|
||||
val includedCategories = includeInUpdateStatusToCategoryMap[IncludeInUpdate.INCLUDE].orEmpty()
|
||||
val unsetCategories = includeInUpdateStatusToCategoryMap[IncludeInUpdate.UNSET].orEmpty()
|
||||
val categoriesToUpdate = if (forceAll) {
|
||||
categories
|
||||
} else {
|
||||
includedCategories.ifEmpty { unsetCategories }
|
||||
}
|
||||
val categoriesToUpdate =
|
||||
if (forceAll) {
|
||||
categories
|
||||
} else {
|
||||
includedCategories.ifEmpty { unsetCategories }
|
||||
}
|
||||
val skippedCategories = categories.subtract(categoriesToUpdate.toSet()).toList()
|
||||
val updateStatusCategories = mapOf(
|
||||
Pair(CategoryUpdateStatus.UPDATING, categoriesToUpdate),
|
||||
Pair(CategoryUpdateStatus.SKIPPED, skippedCategories)
|
||||
)
|
||||
val updateStatusCategories =
|
||||
mapOf(
|
||||
Pair(CategoryUpdateStatus.UPDATING, categoriesToUpdate),
|
||||
Pair(CategoryUpdateStatus.SKIPPED, skippedCategories),
|
||||
)
|
||||
|
||||
logger.debug { "Updating categories: '${categoriesToUpdate.joinToString("', '") { it.name }}'" }
|
||||
|
||||
val categoriesToUpdateMangas = categoriesToUpdate
|
||||
.flatMap { CategoryManga.getCategoryMangaList(it.id) }
|
||||
.distinctBy { it.id }
|
||||
val categoriesToUpdateMangas =
|
||||
categoriesToUpdate
|
||||
.flatMap { CategoryManga.getCategoryMangaList(it.id) }
|
||||
.distinctBy { it.id }
|
||||
val mangasToCategoriesMap = CategoryManga.getMangasCategories(categoriesToUpdateMangas.map { it.id })
|
||||
val mangasToUpdate = categoriesToUpdateMangas
|
||||
.asSequence()
|
||||
.filter { it.updateStrategy == UpdateStrategy.ALWAYS_UPDATE }
|
||||
.filter { if (serverConfig.excludeUnreadChapters.value) { (it.unreadCount ?: 0L) == 0L } else true }
|
||||
.filter { if (serverConfig.excludeNotStarted.value) { it.lastReadAt != null } else true }
|
||||
.filter { if (serverConfig.excludeCompleted.value) { it.status != MangaStatus.COMPLETED.name } else true }
|
||||
.filter { forceAll || !excludedCategories.any { category -> mangasToCategoriesMap[it.id]?.contains(category) == true } }
|
||||
.toList()
|
||||
val mangasToUpdate =
|
||||
categoriesToUpdateMangas
|
||||
.asSequence()
|
||||
.filter { it.updateStrategy == UpdateStrategy.ALWAYS_UPDATE }
|
||||
.filter {
|
||||
if (serverConfig.excludeUnreadChapters.value) {
|
||||
(it.unreadCount ?: 0L) == 0L
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
.filter {
|
||||
if (serverConfig.excludeNotStarted.value) {
|
||||
it.lastReadAt != null
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
.filter {
|
||||
if (serverConfig.excludeCompleted.value) {
|
||||
it.status != MangaStatus.COMPLETED.name
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
.filter { forceAll || !excludedCategories.any { category -> mangasToCategoriesMap[it.id]?.contains(category) == true } }
|
||||
.toList()
|
||||
val skippedMangas = categoriesToUpdateMangas.subtract(mangasToUpdate.toSet()).toList()
|
||||
|
||||
// In case no manga gets updated and no update job was running before, the client would never receive an info about its update request
|
||||
@@ -207,7 +246,7 @@ class Updater : IUpdater {
|
||||
|
||||
addMangasToQueue(
|
||||
mangasToUpdate
|
||||
.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER, MangaDataClass::title))
|
||||
.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER, MangaDataClass::title)),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -19,22 +19,26 @@ object UpdaterSocket : Websocket<UpdateStatus>() {
|
||||
private val updater by DI.global.instance<IUpdater>()
|
||||
private var job: Job? = null
|
||||
|
||||
override fun notifyClient(ctx: WsContext, value: UpdateStatus?) {
|
||||
override fun notifyClient(
|
||||
ctx: WsContext,
|
||||
value: UpdateStatus?,
|
||||
) {
|
||||
ctx.send(value ?: updater.status.value)
|
||||
}
|
||||
|
||||
override fun handleRequest(ctx: WsMessageContext) {
|
||||
when (ctx.message()) {
|
||||
"STATUS" -> notifyClient(ctx, updater.status.value)
|
||||
else -> ctx.send(
|
||||
"""
|
||||
else ->
|
||||
ctx.send(
|
||||
"""
|
||||
|Invalid command.
|
||||
|Supported commands are:
|
||||
| - STATUS
|
||||
| sends the current update status
|
||||
|
|
||||
""".trimMargin()
|
||||
)
|
||||
""".trimMargin(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,16 +6,24 @@ import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
abstract class Websocket<T> {
|
||||
protected val clients = ConcurrentHashMap<String, WsContext>()
|
||||
|
||||
open fun addClient(ctx: WsContext) {
|
||||
clients[ctx.sessionId] = ctx
|
||||
notifyClient(ctx, null)
|
||||
}
|
||||
|
||||
open fun removeClient(ctx: WsContext) {
|
||||
clients.remove(ctx.sessionId)
|
||||
}
|
||||
|
||||
open fun notifyAllClients(value: T) {
|
||||
clients.values.forEach { notifyClient(it, value) }
|
||||
}
|
||||
abstract fun notifyClient(ctx: WsContext, value: T?)
|
||||
|
||||
abstract fun notifyClient(
|
||||
ctx: WsContext,
|
||||
value: T?,
|
||||
)
|
||||
|
||||
abstract fun handleRequest(ctx: WsMessageContext)
|
||||
}
|
||||
|
||||
@@ -55,13 +55,14 @@ object BytecodeEditor {
|
||||
// Invalid class size
|
||||
return null
|
||||
}
|
||||
val cafebabe = String.format(
|
||||
"%02X%02X%02X%02X",
|
||||
bytes[0],
|
||||
bytes[1],
|
||||
bytes[2],
|
||||
bytes[3]
|
||||
)
|
||||
val cafebabe =
|
||||
String.format(
|
||||
"%02X%02X%02X%02X",
|
||||
bytes[0],
|
||||
bytes[1],
|
||||
bytes[2],
|
||||
bytes[3],
|
||||
)
|
||||
if (cafebabe.lowercase() != "cafebabe") {
|
||||
// Corrupted class
|
||||
return null
|
||||
@@ -80,14 +81,15 @@ object BytecodeEditor {
|
||||
/**
|
||||
* The path where replacement classes will reside
|
||||
*/
|
||||
private const val replacementPath = "xyz/nulldev/androidcompat/replace"
|
||||
private const val REPLACEMENT_PATH = "xyz/nulldev/androidcompat/replace"
|
||||
|
||||
/**
|
||||
* List of classes that will be replaced
|
||||
*/
|
||||
private val classesToReplace = listOf(
|
||||
"java/text/SimpleDateFormat"
|
||||
)
|
||||
private val classesToReplace =
|
||||
listOf(
|
||||
"java/text/SimpleDateFormat",
|
||||
)
|
||||
|
||||
/**
|
||||
* Replace direct references to the class, used on places
|
||||
@@ -95,11 +97,12 @@ object BytecodeEditor {
|
||||
*
|
||||
* @return [String] of class or null if [String] was null
|
||||
*/
|
||||
private fun String?.replaceDirectly() = when (this) {
|
||||
null -> this
|
||||
in classesToReplace -> "$replacementPath/$this"
|
||||
else -> this
|
||||
}
|
||||
private fun String?.replaceDirectly() =
|
||||
when (this) {
|
||||
null -> this
|
||||
in classesToReplace -> "$REPLACEMENT_PATH/$this"
|
||||
else -> this
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace references to the class, used in places that have
|
||||
@@ -112,7 +115,7 @@ object BytecodeEditor {
|
||||
var classReference = this
|
||||
if (classReference != null) {
|
||||
classesToReplace.forEach {
|
||||
classReference = classReference?.replace(it, "$replacementPath/$it")
|
||||
classReference = classReference?.replace(it, "$REPLACEMENT_PATH/$it")
|
||||
}
|
||||
}
|
||||
return classReference
|
||||
@@ -142,7 +145,7 @@ object BytecodeEditor {
|
||||
name: String?,
|
||||
desc: String?,
|
||||
signature: String?,
|
||||
cst: Any?
|
||||
cst: Any?,
|
||||
): FieldVisitor? {
|
||||
logger.trace { "CLass Field" to "${desc.replaceIndirectly()}: ${cst?.let { it::class.java.simpleName }}: $cst" }
|
||||
return super.visitField(access, name, desc.replaceIndirectly(), signature, cst)
|
||||
@@ -154,7 +157,7 @@ object BytecodeEditor {
|
||||
name: String?,
|
||||
signature: String?,
|
||||
superName: String?,
|
||||
interfaces: Array<out String>?
|
||||
interfaces: Array<out String>?,
|
||||
) {
|
||||
logger.trace { "Visiting $name: $signature: $superName" }
|
||||
super.visit(version, access, name, signature, superName, interfaces)
|
||||
@@ -171,16 +174,17 @@ object BytecodeEditor {
|
||||
name: String,
|
||||
desc: String,
|
||||
signature: String?,
|
||||
exceptions: Array<String?>?
|
||||
exceptions: Array<String?>?,
|
||||
): MethodVisitor {
|
||||
logger.trace { "Processing method $name: ${desc.replaceIndirectly()}: $signature" }
|
||||
val mv: MethodVisitor? = super.visitMethod(
|
||||
access,
|
||||
name,
|
||||
desc.replaceIndirectly(),
|
||||
signature,
|
||||
exceptions
|
||||
)
|
||||
val mv: MethodVisitor? =
|
||||
super.visitMethod(
|
||||
access,
|
||||
name,
|
||||
desc.replaceIndirectly(),
|
||||
signature,
|
||||
exceptions,
|
||||
)
|
||||
return object : MethodVisitor(Opcodes.ASM5, mv) {
|
||||
override fun visitLdcInsn(cst: Any?) {
|
||||
logger.trace { "Ldc" to "${cst?.let { "${it::class.java.simpleName}: $it" }}" }
|
||||
@@ -192,13 +196,16 @@ object BytecodeEditor {
|
||||
// fun fetchChapterList() {
|
||||
// if (format is SimpleDateFormat)
|
||||
// }
|
||||
override fun visitTypeInsn(opcode: Int, type: String?) {
|
||||
override fun visitTypeInsn(
|
||||
opcode: Int,
|
||||
type: String?,
|
||||
) {
|
||||
logger.trace {
|
||||
"Type" to "$opcode: ${type.replaceDirectly()}"
|
||||
}
|
||||
super.visitTypeInsn(
|
||||
opcode,
|
||||
type.replaceDirectly()
|
||||
type.replaceDirectly(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -211,7 +218,7 @@ object BytecodeEditor {
|
||||
owner: String?,
|
||||
name: String?,
|
||||
desc: String?,
|
||||
itf: Boolean
|
||||
itf: Boolean,
|
||||
) {
|
||||
logger.trace {
|
||||
"Method" to "$opcode: ${owner.replaceDirectly()}: $name: ${desc.replaceIndirectly()}"
|
||||
@@ -221,7 +228,7 @@ object BytecodeEditor {
|
||||
owner.replaceDirectly(),
|
||||
name,
|
||||
desc.replaceIndirectly(),
|
||||
itf
|
||||
itf,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -234,7 +241,7 @@ object BytecodeEditor {
|
||||
opcode: Int,
|
||||
owner: String?,
|
||||
name: String?,
|
||||
desc: String?
|
||||
desc: String?,
|
||||
) {
|
||||
logger.trace { "Field" to "$opcode: $owner: $name: ${desc.replaceIndirectly()}" }
|
||||
super.visitFieldInsn(opcode, owner, name, desc.replaceIndirectly())
|
||||
@@ -244,7 +251,7 @@ object BytecodeEditor {
|
||||
name: String?,
|
||||
desc: String?,
|
||||
bsm: Handle?,
|
||||
vararg bsmArgs: Any?
|
||||
vararg bsmArgs: Any?,
|
||||
) {
|
||||
logger.trace { "InvokeDynamic" to "$name: $desc" }
|
||||
super.visitInvokeDynamicInsn(name, desc, bsm, *bsmArgs)
|
||||
@@ -252,7 +259,7 @@ object BytecodeEditor {
|
||||
}
|
||||
}
|
||||
},
|
||||
0
|
||||
0,
|
||||
)
|
||||
return pair.first to cw.toByteArray()
|
||||
}
|
||||
@@ -262,7 +269,7 @@ object BytecodeEditor {
|
||||
pair.first,
|
||||
pair.second,
|
||||
StandardOpenOption.CREATE,
|
||||
StandardOpenOption.TRUNCATE_EXISTING
|
||||
StandardOpenOption.TRUNCATE_EXISTING,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,15 +31,19 @@ private fun getMangaDir(mangaId: Int): String {
|
||||
return "$sourceDir/$mangaDir"
|
||||
}
|
||||
|
||||
private fun getChapterDir(mangaId: Int, chapterId: Int): String {
|
||||
private fun getChapterDir(
|
||||
mangaId: Int,
|
||||
chapterId: Int,
|
||||
): String {
|
||||
val chapterEntry = transaction { ChapterTable.select { ChapterTable.id eq chapterId }.first() }
|
||||
|
||||
val chapterDir = SafePath.buildValidFilename(
|
||||
when {
|
||||
chapterEntry[ChapterTable.scanlator] != null -> "${chapterEntry[ChapterTable.scanlator]}_${chapterEntry[ChapterTable.name]}"
|
||||
else -> chapterEntry[ChapterTable.name]
|
||||
}
|
||||
)
|
||||
val chapterDir =
|
||||
SafePath.buildValidFilename(
|
||||
when {
|
||||
chapterEntry[ChapterTable.scanlator] != null -> "${chapterEntry[ChapterTable.scanlator]}_${chapterEntry[ChapterTable.name]}"
|
||||
else -> chapterEntry[ChapterTable.name]
|
||||
},
|
||||
)
|
||||
|
||||
return getMangaDir(mangaId) + "/$chapterDir"
|
||||
}
|
||||
@@ -52,20 +56,32 @@ fun getMangaDownloadDir(mangaId: Int): String {
|
||||
return applicationDirs.mangaDownloadsRoot + "/" + getMangaDir(mangaId)
|
||||
}
|
||||
|
||||
fun getChapterDownloadPath(mangaId: Int, chapterId: Int): String {
|
||||
fun getChapterDownloadPath(
|
||||
mangaId: Int,
|
||||
chapterId: Int,
|
||||
): String {
|
||||
return applicationDirs.mangaDownloadsRoot + "/" + getChapterDir(mangaId, chapterId)
|
||||
}
|
||||
|
||||
fun getChapterCbzPath(mangaId: Int, chapterId: Int): String {
|
||||
fun getChapterCbzPath(
|
||||
mangaId: Int,
|
||||
chapterId: Int,
|
||||
): String {
|
||||
return getChapterDownloadPath(mangaId, chapterId) + ".cbz"
|
||||
}
|
||||
|
||||
fun getChapterCachePath(mangaId: Int, chapterId: Int): String {
|
||||
fun getChapterCachePath(
|
||||
mangaId: Int,
|
||||
chapterId: Int,
|
||||
): String {
|
||||
return applicationDirs.tempMangaCacheRoot + "/" + getChapterDir(mangaId, chapterId)
|
||||
}
|
||||
|
||||
/** return value says if rename/move was successful */
|
||||
fun updateMangaDownloadDir(mangaId: Int, newTitle: String): Boolean {
|
||||
fun updateMangaDownloadDir(
|
||||
mangaId: Int,
|
||||
newTitle: String,
|
||||
): Boolean {
|
||||
val mangaEntry = getMangaEntry(mangaId)
|
||||
val source = GetCatalogueSource.getCatalogueSourceOrStub(mangaEntry[MangaTable.sourceReference])
|
||||
|
||||
|
||||
@@ -43,14 +43,18 @@ object PackageTools {
|
||||
const val LIB_VERSION_MIN = 1.3
|
||||
const val LIB_VERSION_MAX = 1.5
|
||||
|
||||
private const val officialSignature = "7ce04da7773d41b489f4693a366c36bcd0a11fc39b547168553c285bd7348e23" // inorichi's key
|
||||
private const val unofficialSignature = "64feb21075ba97ebc9cc981243645b331595c111cef1b0d084236a0403b00581" // ArMor's key
|
||||
val trustedSignatures = mutableSetOf<String>() + officialSignature + unofficialSignature
|
||||
private const val OFFICIAL_SIGNATURE = "7ce04da7773d41b489f4693a366c36bcd0a11fc39b547168553c285bd7348e23" // inorichi's key
|
||||
private const val UNOFFICIAL_SIGNATURE = "64feb21075ba97ebc9cc981243645b331595c111cef1b0d084236a0403b00581" // ArMor's key
|
||||
val trustedSignatures = setOf(OFFICIAL_SIGNATURE, UNOFFICIAL_SIGNATURE)
|
||||
|
||||
/**
|
||||
* Convert dex to jar, a wrapper for the dex2jar library
|
||||
*/
|
||||
fun dex2jar(dexFile: String, jarFile: String, fileNameWithoutType: String) {
|
||||
fun dex2jar(
|
||||
dexFile: String,
|
||||
jarFile: String,
|
||||
fileNameWithoutType: String,
|
||||
) {
|
||||
// adopted from com.googlecode.dex2jar.tools.Dex2jarCmd.doCommandLine
|
||||
// source at: https://github.com/DexPatcher/dex2jar/tree/v2.1-20190905-lanchon/dex-tools/src/main/java/com/googlecode/dex2jar/tools/Dex2jarCmd.java
|
||||
|
||||
@@ -78,7 +82,7 @@ object PackageTools {
|
||||
https://bitbucket.org/pxb1988/dex2jar/issues
|
||||
https://github.com/pxb1988/dex2jar/issues
|
||||
dex2jar@googlegroups.com
|
||||
""".trimIndent()
|
||||
""".trimIndent(),
|
||||
)
|
||||
handler.dump(errorFile, emptyArray<String>())
|
||||
} else {
|
||||
@@ -93,37 +97,40 @@ object PackageTools {
|
||||
val parsed = ApkFile(apk)
|
||||
val dbFactory = DocumentBuilderFactory.newInstance()
|
||||
val dBuilder = dbFactory.newDocumentBuilder()
|
||||
val doc = parsed.manifestXml.byteInputStream().use {
|
||||
dBuilder.parse(it)
|
||||
}
|
||||
val doc =
|
||||
parsed.manifestXml.byteInputStream().use {
|
||||
dBuilder.parse(it)
|
||||
}
|
||||
|
||||
logger.trace(parsed.manifestXml)
|
||||
|
||||
applicationInfo.metaData = Bundle().apply {
|
||||
val appTag = doc.getElementsByTagName("application").item(0)
|
||||
applicationInfo.metaData =
|
||||
Bundle().apply {
|
||||
val appTag = doc.getElementsByTagName("application").item(0)
|
||||
|
||||
appTag?.childNodes?.toList()
|
||||
.orEmpty()
|
||||
.asSequence()
|
||||
.filter {
|
||||
it.nodeType == Node.ELEMENT_NODE
|
||||
}.map {
|
||||
it as Element
|
||||
}.filter {
|
||||
it.tagName == "meta-data"
|
||||
}.forEach {
|
||||
putString(
|
||||
it.attributes.getNamedItem("android:name").nodeValue,
|
||||
it.attributes.getNamedItem("android:value").nodeValue
|
||||
)
|
||||
}
|
||||
}
|
||||
appTag?.childNodes?.toList()
|
||||
.orEmpty()
|
||||
.asSequence()
|
||||
.filter {
|
||||
it.nodeType == Node.ELEMENT_NODE
|
||||
}.map {
|
||||
it as Element
|
||||
}.filter {
|
||||
it.tagName == "meta-data"
|
||||
}.forEach {
|
||||
putString(
|
||||
it.attributes.getNamedItem("android:name").nodeValue,
|
||||
it.attributes.getNamedItem("android:value").nodeValue,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
signatures = (
|
||||
parsed.apkSingers.flatMap { it.certificateMetas }
|
||||
/*+ parsed.apkV2Singers.flatMap { it.certificateMetas }*/
|
||||
signatures =
|
||||
(
|
||||
parsed.apkSingers.flatMap { it.certificateMetas }
|
||||
// + parsed.apkV2Singers.flatMap { it.certificateMetas }
|
||||
) // Blocked by: https://github.com/hsiafan/apk-parser/issues/72
|
||||
.map { Signature(it.data) }.toTypedArray()
|
||||
.map { Signature(it.data) }.toTypedArray()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,7 +149,10 @@ object PackageTools {
|
||||
* loads the extension main class called [className] from the jar located at [jarPath]
|
||||
* It may return an instance of HttpSource or SourceFactory depending on the extension.
|
||||
*/
|
||||
fun loadExtensionSources(jarPath: String, className: String): Any {
|
||||
fun loadExtensionSources(
|
||||
jarPath: String,
|
||||
className: String,
|
||||
): Any {
|
||||
try {
|
||||
logger.debug { "loading jar with path: $jarPath" }
|
||||
val classLoader = jarLoaderMap[jarPath] ?: URLClassLoader(arrayOf<URL>(URL("file:$jarPath")))
|
||||
|
||||
@@ -17,50 +17,48 @@ import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
|
||||
// source: https://github.com/jobobby04/TachiyomiSY/blob/9320221a4e8b118ef68deb60d8c4c32bcbb9e06f/app/src/main/java/eu/kanade/tachiyomi/util/lang/RxCoroutineBridge.kt
|
||||
/*
|
||||
* Util functions for bridging RxJava and coroutines. Taken from TachiyomiEH/SY.
|
||||
*/
|
||||
// Util functions for bridging RxJava and coroutines. Taken from TachiyomiEH/SY.
|
||||
|
||||
suspend fun <T> Observable<T>.awaitSingle(): T = single().awaitOne()
|
||||
|
||||
@OptIn(InternalCoroutinesApi::class)
|
||||
private suspend fun <T> Observable<T>.awaitOne(): T = suspendCancellableCoroutine { cont ->
|
||||
cont.unsubscribeOnCancellation(
|
||||
subscribe(
|
||||
object : Subscriber<T>() {
|
||||
override fun onStart() {
|
||||
request(1)
|
||||
}
|
||||
private suspend fun <T> Observable<T>.awaitOne(): T =
|
||||
suspendCancellableCoroutine { cont ->
|
||||
cont.unsubscribeOnCancellation(
|
||||
subscribe(
|
||||
object : Subscriber<T>() {
|
||||
override fun onStart() {
|
||||
request(1)
|
||||
}
|
||||
|
||||
override fun onNext(t: T) {
|
||||
cont.resume(t)
|
||||
}
|
||||
override fun onNext(t: T) {
|
||||
cont.resume(t)
|
||||
}
|
||||
|
||||
override fun onCompleted() {
|
||||
if (cont.isActive) {
|
||||
cont.resumeWithException(
|
||||
IllegalStateException(
|
||||
"Should have invoked onNext"
|
||||
override fun onCompleted() {
|
||||
if (cont.isActive) {
|
||||
cont.resumeWithException(
|
||||
IllegalStateException(
|
||||
"Should have invoked onNext",
|
||||
),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onError(e: Throwable) {
|
||||
override fun onError(e: Throwable) {
|
||||
/*
|
||||
* Rx1 observable throws NoSuchElementException if cancellation happened before
|
||||
* element emission. To mitigate this we try to atomically resume continuation with exception:
|
||||
* if resume failed, then we know that continuation successfully cancelled itself
|
||||
*/
|
||||
val token = cont.tryResumeWithException(e)
|
||||
if (token != null) {
|
||||
cont.completeResume(token)
|
||||
* Rx1 observable throws NoSuchElementException if cancellation happened before
|
||||
* element emission. To mitigate this we try to atomically resume continuation with exception:
|
||||
* if resume failed, then we know that continuation successfully cancelled itself
|
||||
*/
|
||||
val token = cont.tryResumeWithException(e)
|
||||
if (token != null) {
|
||||
cont.completeResume(token)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T> CancellableContinuation<T>.unsubscribeOnCancellation(sub: Subscription) =
|
||||
invokeOnCancellation { sub.unsubscribe() }
|
||||
private fun <T> CancellableContinuation<T>.unsubscribeOnCancellation(sub: Subscription) = invokeOnCancellation { sub.unsubscribe() }
|
||||
|
||||
@@ -20,7 +20,10 @@ suspend fun Call.await(): Response {
|
||||
return suspendCancellableCoroutine { continuation ->
|
||||
enqueue(
|
||||
object : Callback {
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
override fun onResponse(
|
||||
call: Call,
|
||||
response: Response,
|
||||
) {
|
||||
if (!response.isSuccessful) {
|
||||
continuation.resumeWithException(Exception("HTTP error ${response.code}"))
|
||||
return
|
||||
@@ -31,12 +34,15 @@ suspend fun Call.await(): Response {
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
override fun onFailure(
|
||||
call: Call,
|
||||
e: IOException,
|
||||
) {
|
||||
// Don't bother with resuming the continuation if it is already cancelled.
|
||||
if (continuation.isCancelled) return
|
||||
continuation.resumeWithException(e)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
continuation.invokeOnCancellation {
|
||||
|
||||
@@ -35,14 +35,16 @@ object GetCatalogueSource {
|
||||
return cachedResult
|
||||
}
|
||||
|
||||
val sourceRecord = transaction {
|
||||
SourceTable.select { SourceTable.id eq sourceId }.firstOrNull()
|
||||
} ?: return null
|
||||
val sourceRecord =
|
||||
transaction {
|
||||
SourceTable.select { SourceTable.id eq sourceId }.firstOrNull()
|
||||
} ?: return null
|
||||
|
||||
val extensionId = sourceRecord[SourceTable.extension]
|
||||
val extensionRecord = transaction {
|
||||
ExtensionTable.select { ExtensionTable.id eq extensionId }.first()
|
||||
}
|
||||
val extensionRecord =
|
||||
transaction {
|
||||
ExtensionTable.select { ExtensionTable.id eq extensionId }.first()
|
||||
}
|
||||
|
||||
val apkName = extensionRecord[ExtensionTable.apkName]
|
||||
val className = extensionRecord[ExtensionTable.classFQName]
|
||||
|
||||
@@ -27,7 +27,11 @@ open class StubSource(override val id: Long) : CatalogueSource {
|
||||
}
|
||||
|
||||
@Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getSearchManga"))
|
||||
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
|
||||
override fun fetchSearchManga(
|
||||
page: Int,
|
||||
query: String,
|
||||
filters: FilterList,
|
||||
): Observable<MangasPage> {
|
||||
return Observable.error(getSourceNotInstalledException())
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,10 @@ object ImageResponse {
|
||||
}
|
||||
|
||||
/** find file with name when file extension is not known */
|
||||
fun findFileNameStartingWith(directoryPath: String, fileName: String): String? {
|
||||
fun findFileNameStartingWith(
|
||||
directoryPath: String,
|
||||
fileName: String,
|
||||
): String? {
|
||||
val target = "$fileName."
|
||||
File(directoryPath).listFiles().orEmpty().forEach { file ->
|
||||
if (file.name.startsWith(target)) {
|
||||
@@ -30,11 +33,14 @@ object ImageResponse {
|
||||
return null
|
||||
}
|
||||
|
||||
fun getCachedImageResponse(cachedFile: String, filePath: String): Pair<InputStream, String> {
|
||||
fun getCachedImageResponse(
|
||||
cachedFile: String,
|
||||
filePath: String,
|
||||
): Pair<InputStream, String> {
|
||||
val fileType = cachedFile.substringAfter("$filePath.")
|
||||
return Pair(
|
||||
pathToInputStream(cachedFile),
|
||||
"image/$fileType"
|
||||
"image/$fileType",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -49,7 +55,7 @@ object ImageResponse {
|
||||
suspend fun getImageResponse(
|
||||
saveDir: String,
|
||||
fileName: String,
|
||||
fetcher: suspend () -> Response
|
||||
fetcher: suspend () -> Response,
|
||||
): Pair<InputStream, String> {
|
||||
File(saveDir).mkdirs()
|
||||
|
||||
@@ -80,14 +86,18 @@ object ImageResponse {
|
||||
}
|
||||
|
||||
/** Save image safely */
|
||||
fun saveImage(filePath: String, image: InputStream): Pair<String, String> {
|
||||
fun saveImage(
|
||||
filePath: String,
|
||||
image: InputStream,
|
||||
): Pair<String, String> {
|
||||
val tmpSavePath = "$filePath.tmp"
|
||||
val tmpSaveFile = File(tmpSavePath)
|
||||
image.use { input -> tmpSaveFile.outputStream().use { output -> input.copyTo(output) } }
|
||||
|
||||
// find image type
|
||||
val imageType = ImageUtil.findImageType { tmpSaveFile.inputStream() }?.mime
|
||||
?: "image/jpeg"
|
||||
val imageType =
|
||||
ImageUtil.findImageType { tmpSaveFile.inputStream() }?.mime
|
||||
?: "image/jpeg"
|
||||
|
||||
val actualSavePath = "$filePath.${imageType.substringAfter("/")}"
|
||||
|
||||
@@ -95,7 +105,10 @@ object ImageResponse {
|
||||
return Pair(actualSavePath, imageType)
|
||||
}
|
||||
|
||||
fun clearCachedImage(saveDir: String, fileName: String) {
|
||||
fun clearCachedImage(
|
||||
saveDir: String,
|
||||
fileName: String,
|
||||
) {
|
||||
val cachedFile = findFileNameStartingWith(saveDir, fileName)
|
||||
cachedFile?.also {
|
||||
File(it).delete()
|
||||
|
||||
@@ -19,12 +19,16 @@ import java.net.URLConnection
|
||||
|
||||
// adopted from: eu.kanade.tachiyomi.util.system.ImageUtil
|
||||
object ImageUtil {
|
||||
fun isImage(name: String, openStream: (() -> InputStream)? = null): Boolean {
|
||||
val contentType = try {
|
||||
URLConnection.guessContentTypeFromName(name)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
} ?: openStream?.let { findImageType(it)?.mime }
|
||||
fun isImage(
|
||||
name: String,
|
||||
openStream: (() -> InputStream)? = null,
|
||||
): Boolean {
|
||||
val contentType =
|
||||
try {
|
||||
URLConnection.guessContentTypeFromName(name)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
} ?: openStream?.let { findImageType(it)?.mime }
|
||||
return contentType?.startsWith("image/") ?: false
|
||||
}
|
||||
|
||||
@@ -36,12 +40,13 @@ object ImageUtil {
|
||||
try {
|
||||
val bytes = ByteArray(12)
|
||||
|
||||
val length = if (stream.markSupported()) {
|
||||
stream.mark(bytes.size)
|
||||
stream.read(bytes, 0, bytes.size).also { stream.reset() }
|
||||
} else {
|
||||
stream.read(bytes, 0, bytes.size)
|
||||
}
|
||||
val length =
|
||||
if (stream.markSupported()) {
|
||||
stream.mark(bytes.size)
|
||||
stream.read(bytes, 0, bytes.size).also { stream.reset() }
|
||||
} else {
|
||||
stream.read(bytes, 0, bytes.size)
|
||||
}
|
||||
|
||||
if (length == -1) {
|
||||
return null
|
||||
@@ -160,6 +165,6 @@ object ImageUtil {
|
||||
JPEG("image/jpeg", "jpg"),
|
||||
JXL("image/jxl", "jxl"),
|
||||
PNG("image/png", "png"),
|
||||
WEBP("image/webp", "webp")
|
||||
WEBP("image/webp", "webp"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,10 @@ package suwayomi.tachidesk.manga.impl.util.storage
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipInputStream
|
||||
|
||||
fun ZipEntry.use(stream: ZipInputStream, block: (ZipEntry) -> Unit) {
|
||||
fun ZipEntry.use(
|
||||
stream: ZipInputStream,
|
||||
block: (ZipEntry) -> Unit,
|
||||
) {
|
||||
var exception: Throwable? = null
|
||||
try {
|
||||
return block(this)
|
||||
|
||||
@@ -14,6 +14,7 @@ import java.io.File
|
||||
import java.io.OutputStream
|
||||
|
||||
// adopted from: https://github.com/tachiyomiorg/tachiyomi/blob/ff369010074b058bb734ce24c66508300e6e9ac6/app/src/main/java/eu/kanade/tachiyomi/util/storage/OkioExtensions.kt
|
||||
|
||||
/**
|
||||
* Saves the given source to a file and closes it. Directories will be created if needed.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user