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:
Mitchell Syer
2023-10-06 23:38:39 -04:00
committed by GitHub
parent 3cd3cb0186
commit 849acfca3d
277 changed files with 6709 additions and 5090 deletions

View File

@@ -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 {

View File

@@ -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)
}
}

View File

@@ -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),
)
}
}

View File

@@ -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)

View File

@@ -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) {

View File

@@ -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(", ")}]" }

View File

@@ -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,
)
}
}

View File

@@ -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

View File

@@ -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")

View File

@@ -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]

View File

@@ -12,5 +12,5 @@ data class BackupFlags(
val includeCategories: Boolean,
val includeChapters: Boolean,
val includeTracking: Boolean,
val includeHistory: Boolean
val includeHistory: Boolean,
)

View File

@@ -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 }
}
}

View File

@@ -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()
}
}

View File

@@ -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
}
}
}

View File

@@ -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

View File

@@ -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!!
}
}
}

View File

@@ -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
}

View File

@@ -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
}
}
}

View File

@@ -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
}
}
}

View File

@@ -1,3 +0,0 @@
package suwayomi.tachidesk.manga.impl.backup.models
class MangaChapter(val manga: Manga, val chapter: Chapter)

View File

@@ -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)

View File

@@ -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

View File

@@ -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
}
}
}

View File

@@ -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

View File

@@ -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()

View File

@@ -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

View File

@@ -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(),
)
}

View File

@@ -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)

View File

@@ -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,
)

View File

@@ -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,
)
}
}

View File

@@ -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,
)

View File

@@ -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,
)
}
}

View File

@@ -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,
)
}
}

View File

@@ -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,
)
}
}

View File

@@ -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
}
}

View File

@@ -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),
}

View File

@@ -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
}
}

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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
}
}

View File

@@ -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"

View File

@@ -11,5 +11,5 @@ enum class DownloadState(val state: Int) {
Queued(0),
Downloading(1),
Finished(2),
Error(3)
Error(3),
}

View File

@@ -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>,
)

View File

@@ -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()
}
}

View File

@@ -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 {

View File

@@ -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,
)
}
}

View File

@@ -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,
)

View File

@@ -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()
}

View File

@@ -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,
)

View File

@@ -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,
)
}

View File

@@ -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)),
)
}

View File

@@ -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(),
)
}
}

View File

@@ -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)
}

View File

@@ -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,
)
}
}

View File

@@ -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])

View File

@@ -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")))

View File

@@ -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() }

View File

@@ -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 {

View File

@@ -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]

View File

@@ -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())
}

View File

@@ -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()

View File

@@ -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"),
}
}

View File

@@ -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)

View File

@@ -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.
*