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

@@ -17,154 +17,163 @@ import suwayomi.tachidesk.server.util.withOperation
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
object BackupController {
/** expects a Tachiyomi protobuf backup in the body */
val protobufImport = handler(
documentWith = {
withOperation {
summary("Restore a backup")
description("Expects a Tachiyomi protobuf backup in the body")
}
},
behaviorOf = { ctx ->
ctx.future(
future {
ProtoBackupImport.performRestore(ctx.bodyAsInputStream())
val protobufImport =
handler(
documentWith = {
withOperation {
summary("Restore a backup")
description("Expects a Tachiyomi protobuf backup in the body")
}
)
},
withResults = {
httpCode(HttpCode.OK)
}
)
},
behaviorOf = { ctx ->
ctx.future(
future {
ProtoBackupImport.performRestore(ctx.bodyAsInputStream())
},
)
},
withResults = {
httpCode(HttpCode.OK)
},
)
/** expects a Tachiyomi protobuf backup as a file upload, the file must be named "backup.proto.gz" */
val protobufImportFile = handler(
documentWith = {
withOperation {
summary("Restore a backup file")
description("Expects a Tachiyomi protobuf backup as a file upload, the file must be named \"backup.proto.gz\"")
}
uploadedFile("backup.proto.gz") {
it.description("Protobuf backup")
it.required(true)
}
},
behaviorOf = { ctx ->
// TODO: rewrite this with ctx.uploadedFiles(), don't call the multipart field "backup.proto.gz"
ctx.future(
future {
ProtoBackupImport.performRestore(ctx.uploadedFile("backup.proto.gz")!!.content)
val protobufImportFile =
handler(
documentWith = {
withOperation {
summary("Restore a backup file")
description("Expects a Tachiyomi protobuf backup as a file upload, the file must be named \"backup.proto.gz\"")
}
)
},
withResults = {
httpCode(HttpCode.OK)
httpCode(HttpCode.NOT_FOUND)
}
)
uploadedFile("backup.proto.gz") {
it.description("Protobuf backup")
it.required(true)
}
},
behaviorOf = { ctx ->
// TODO: rewrite this with ctx.uploadedFiles(), don't call the multipart field "backup.proto.gz"
ctx.future(
future {
ProtoBackupImport.performRestore(ctx.uploadedFile("backup.proto.gz")!!.content)
},
)
},
withResults = {
httpCode(HttpCode.OK)
httpCode(HttpCode.NOT_FOUND)
},
)
/** returns a Tachiyomi protobuf backup created from the current database as a body */
val protobufExport = handler(
documentWith = {
withOperation {
summary("Create a backup")
description("Returns a Tachiyomi protobuf backup created from the current database as a body")
}
},
behaviorOf = { ctx ->
ctx.contentType("application/octet-stream")
ctx.future(
future {
ProtoBackupExport.createBackup(
BackupFlags(
includeManga = true,
includeCategories = true,
includeChapters = true,
includeTracking = true,
includeHistory = true
)
)
val protobufExport =
handler(
documentWith = {
withOperation {
summary("Create a backup")
description("Returns a Tachiyomi protobuf backup created from the current database as a body")
}
)
},
withResults = {
stream(HttpCode.OK)
}
)
},
behaviorOf = { ctx ->
ctx.contentType("application/octet-stream")
ctx.future(
future {
ProtoBackupExport.createBackup(
BackupFlags(
includeManga = true,
includeCategories = true,
includeChapters = true,
includeTracking = true,
includeHistory = true,
),
)
},
)
},
withResults = {
stream(HttpCode.OK)
},
)
/** returns a Tachiyomi protobuf backup created from the current database as a file */
val protobufExportFile = handler(
documentWith = {
withOperation {
summary("Create a backup file")
description("Returns a Tachiyomi protobuf backup created from the current database as a file")
}
},
behaviorOf = { ctx ->
ctx.contentType("application/octet-stream")
ctx.header("Content-Disposition", """attachment; filename="${ProtoBackupExport.getBackupFilename()}"""")
ctx.future(
future {
ProtoBackupExport.createBackup(
BackupFlags(
includeManga = true,
includeCategories = true,
includeChapters = true,
includeTracking = true,
includeHistory = true
)
)
val protobufExportFile =
handler(
documentWith = {
withOperation {
summary("Create a backup file")
description("Returns a Tachiyomi protobuf backup created from the current database as a file")
}
)
},
withResults = {
stream(HttpCode.OK)
}
)
},
behaviorOf = { ctx ->
ctx.contentType("application/octet-stream")
ctx.header("Content-Disposition", """attachment; filename="${ProtoBackupExport.getBackupFilename()}"""")
ctx.future(
future {
ProtoBackupExport.createBackup(
BackupFlags(
includeManga = true,
includeCategories = true,
includeChapters = true,
includeTracking = true,
includeHistory = true,
),
)
},
)
},
withResults = {
stream(HttpCode.OK)
},
)
/** Reports missing sources and trackers, expects a Tachiyomi protobuf backup in the body */
val protobufValidate = handler(
documentWith = {
withOperation {
summary("Validate a backup")
description("Reports missing sources and trackers, expects a Tachiyomi protobuf backup in the body")
}
},
behaviorOf = { ctx ->
ctx.future(
future {
ProtoBackupValidator.validate(ctx.bodyAsInputStream())
val protobufValidate =
handler(
documentWith = {
withOperation {
summary("Validate a backup")
description("Reports missing sources and trackers, expects a Tachiyomi protobuf backup in the body")
}
)
},
withResults = {
json<ProtoBackupValidator.ValidationResult>(HttpCode.OK)
}
)
},
behaviorOf = { ctx ->
ctx.future(
future {
ProtoBackupValidator.validate(ctx.bodyAsInputStream())
},
)
},
withResults = {
json<ProtoBackupValidator.ValidationResult>(HttpCode.OK)
},
)
/** Reports missing sources and trackers, expects a Tachiyomi protobuf backup as a file upload, the file must be named "backup.proto.gz" */
val protobufValidateFile = handler(
documentWith = {
withOperation {
summary("Validate a backup file")
description("Reports missing sources and trackers, expects a Tachiyomi protobuf backup as a file upload, the file must be named \"backup.proto.gz\"")
}
uploadedFile("backup.proto.gz") {
it.description("Protobuf backup")
it.required(true)
}
},
behaviorOf = { ctx ->
ctx.future(
future {
ProtoBackupValidator.validate(ctx.uploadedFile("backup.proto.gz")!!.content)
val protobufValidateFile =
handler(
documentWith = {
withOperation {
summary("Validate a backup file")
description(
"Reports missing sources and trackers, " +
"expects a Tachiyomi protobuf backup as a file upload, " +
"the file must be named \"backup.proto.gz\"",
)
}
)
},
withResults = {
json<ProtoBackupValidator.ValidationResult>(HttpCode.OK)
}
)
uploadedFile("backup.proto.gz") {
it.description("Protobuf backup")
it.required(true)
}
},
behaviorOf = { ctx ->
ctx.future(
future {
ProtoBackupValidator.validate(ctx.uploadedFile("backup.proto.gz")!!.content)
},
)
},
withResults = {
json<ProtoBackupValidator.ValidationResult>(HttpCode.OK)
},
)
}

View File

@@ -19,136 +19,143 @@ import suwayomi.tachidesk.server.util.withOperation
object CategoryController {
/** category list */
val categoryList = handler(
documentWith = {
withOperation {
summary("Category list")
description("get a list of categories")
}
},
behaviorOf = { ctx ->
ctx.json(Category.getCategoryList())
},
withResults = {
json<Array<CategoryDataClass>>(HttpCode.OK)
}
)
val categoryList =
handler(
documentWith = {
withOperation {
summary("Category list")
description("get a list of categories")
}
},
behaviorOf = { ctx ->
ctx.json(Category.getCategoryList())
},
withResults = {
json<Array<CategoryDataClass>>(HttpCode.OK)
},
)
/** category create */
val categoryCreate = handler(
formParam<String>("name"),
documentWith = {
withOperation {
summary("Category create")
description("Create a category")
}
},
behaviorOf = { ctx, name ->
if (Category.createCategory(name) != -1) {
ctx.status(200)
} else {
ctx.status(HttpCode.BAD_REQUEST)
}
},
withResults = {
httpCode(HttpCode.OK)
httpCode(HttpCode.BAD_REQUEST)
}
)
val categoryCreate =
handler(
formParam<String>("name"),
documentWith = {
withOperation {
summary("Category create")
description("Create a category")
}
},
behaviorOf = { ctx, name ->
if (Category.createCategory(name) != -1) {
ctx.status(200)
} else {
ctx.status(HttpCode.BAD_REQUEST)
}
},
withResults = {
httpCode(HttpCode.OK)
httpCode(HttpCode.BAD_REQUEST)
},
)
/** category modification */
val categoryModify = handler(
pathParam<Int>("categoryId"),
formParam<String?>("name"),
formParam<Boolean?>("default"),
formParam<Int?>("includeInUpdate"),
documentWith = {
withOperation {
summary("Category modify")
description("Modify a category")
}
},
behaviorOf = { ctx, categoryId, name, isDefault, includeInUpdate ->
Category.updateCategory(categoryId, name, isDefault, includeInUpdate)
ctx.status(200)
},
withResults = {
httpCode(HttpCode.OK)
}
)
val categoryModify =
handler(
pathParam<Int>("categoryId"),
formParam<String?>("name"),
formParam<Boolean?>("default"),
formParam<Int?>("includeInUpdate"),
documentWith = {
withOperation {
summary("Category modify")
description("Modify a category")
}
},
behaviorOf = { ctx, categoryId, name, isDefault, includeInUpdate ->
Category.updateCategory(categoryId, name, isDefault, includeInUpdate)
ctx.status(200)
},
withResults = {
httpCode(HttpCode.OK)
},
)
/** category delete */
val categoryDelete = handler(
pathParam<Int>("categoryId"),
documentWith = {
withOperation {
summary("Category delete")
description("Delete a category")
}
},
behaviorOf = { ctx, categoryId ->
Category.removeCategory(categoryId)
ctx.status(200)
},
withResults = {
httpCode(HttpCode.OK)
}
)
val categoryDelete =
handler(
pathParam<Int>("categoryId"),
documentWith = {
withOperation {
summary("Category delete")
description("Delete a category")
}
},
behaviorOf = { ctx, categoryId ->
Category.removeCategory(categoryId)
ctx.status(200)
},
withResults = {
httpCode(HttpCode.OK)
},
)
/** returns the manga list associated with a category */
val categoryMangas = handler(
pathParam<Int>("categoryId"),
documentWith = {
withOperation {
summary("Category manga")
description("Returns the manga list associated with a category")
}
},
behaviorOf = { ctx, categoryId ->
ctx.json(CategoryManga.getCategoryMangaList(categoryId))
},
withResults = {
json<Array<MangaDataClass>>(HttpCode.OK)
}
)
val categoryMangas =
handler(
pathParam<Int>("categoryId"),
documentWith = {
withOperation {
summary("Category manga")
description("Returns the manga list associated with a category")
}
},
behaviorOf = { ctx, categoryId ->
ctx.json(CategoryManga.getCategoryMangaList(categoryId))
},
withResults = {
json<Array<MangaDataClass>>(HttpCode.OK)
},
)
/** category re-ordering */
val categoryReorder = handler(
formParam<Int>("from"),
formParam<Int>("to"),
documentWith = {
withOperation {
summary("Category re-ordering")
description("Re-order a category")
}
},
behaviorOf = { ctx, from, to ->
Category.reorderCategory(from, to)
ctx.status(200)
},
withResults = {
httpCode(HttpCode.OK)
}
)
val categoryReorder =
handler(
formParam<Int>("from"),
formParam<Int>("to"),
documentWith = {
withOperation {
summary("Category re-ordering")
description("Re-order a category")
}
},
behaviorOf = { ctx, from, to ->
Category.reorderCategory(from, to)
ctx.status(200)
},
withResults = {
httpCode(HttpCode.OK)
},
)
/** used to modify a category's meta parameters */
val meta = handler(
pathParam<Int>("categoryId"),
formParam<String>("key"),
formParam<String>("value"),
documentWith = {
withOperation {
summary("Add meta data to category")
description("A simple Key-Value storage in the manga object, you can set values for whatever you want inside it.")
}
},
behaviorOf = { ctx, categoryId, key, value ->
Category.modifyMeta(categoryId, key, value)
ctx.status(200)
},
withResults = {
httpCode(HttpCode.OK)
httpCode(HttpCode.NOT_FOUND)
}
)
val meta =
handler(
pathParam<Int>("categoryId"),
formParam<String>("key"),
formParam<String>("value"),
documentWith = {
withOperation {
summary("Add meta data to category")
description("A simple Key-Value storage in the manga object, you can set values for whatever you want inside it.")
}
},
behaviorOf = { ctx, categoryId, key, value ->
Category.modifyMeta(categoryId, key, value)
ctx.status(200)
},
withResults = {
httpCode(HttpCode.OK)
httpCode(HttpCode.NOT_FOUND)
},
)
}

View File

@@ -39,159 +39,167 @@ object DownloadController {
}
/** Start the downloader */
val start = handler(
documentWith = {
withOperation {
summary("Downloader start")
description("Start the downloader")
}
},
behaviorOf = {
DownloadManager.start()
},
withResults = {
httpCode(HttpCode.OK)
}
)
val start =
handler(
documentWith = {
withOperation {
summary("Downloader start")
description("Start the downloader")
}
},
behaviorOf = {
DownloadManager.start()
},
withResults = {
httpCode(HttpCode.OK)
},
)
/** Stop the downloader */
val stop = handler(
documentWith = {
withOperation {
summary("Downloader stop")
description("Stop the downloader")
}
},
behaviorOf = { ctx ->
ctx.future(
future { DownloadManager.stop() }
)
},
withResults = {
httpCode(HttpCode.OK)
}
)
val stop =
handler(
documentWith = {
withOperation {
summary("Downloader stop")
description("Stop the downloader")
}
},
behaviorOf = { ctx ->
ctx.future(
future { DownloadManager.stop() },
)
},
withResults = {
httpCode(HttpCode.OK)
},
)
/** clear download queue */
val clear = handler(
documentWith = {
withOperation {
summary("Downloader clear")
description("Clear download queue")
}
},
behaviorOf = { ctx ->
ctx.future(
future { DownloadManager.clear() }
)
},
withResults = {
httpCode(HttpCode.OK)
}
)
val clear =
handler(
documentWith = {
withOperation {
summary("Downloader clear")
description("Clear download queue")
}
},
behaviorOf = { ctx ->
ctx.future(
future { DownloadManager.clear() },
)
},
withResults = {
httpCode(HttpCode.OK)
},
)
/** Queue single chapter for download */
val queueChapter = handler(
pathParam<Int>("chapterIndex"),
pathParam<Int>("mangaId"),
documentWith = {
withOperation {
summary("Downloader add single chapter")
description("Queue single chapter for download")
}
},
behaviorOf = { ctx, chapterIndex, mangaId ->
ctx.future(
future {
DownloadManager.enqueueWithChapterIndex(mangaId, chapterIndex)
val queueChapter =
handler(
pathParam<Int>("chapterIndex"),
pathParam<Int>("mangaId"),
documentWith = {
withOperation {
summary("Downloader add single chapter")
description("Queue single chapter for download")
}
)
},
withResults = {
httpCode(HttpCode.OK)
httpCode(HttpCode.NOT_FOUND)
}
)
},
behaviorOf = { ctx, chapterIndex, mangaId ->
ctx.future(
future {
DownloadManager.enqueueWithChapterIndex(mangaId, chapterIndex)
},
)
},
withResults = {
httpCode(HttpCode.OK)
httpCode(HttpCode.NOT_FOUND)
},
)
val queueChapters = handler(
documentWith = {
withOperation {
summary("Downloader add multiple chapters")
description("Queue multiple chapters for download")
}
body<EnqueueInput>()
},
behaviorOf = { ctx ->
val inputs = json.decodeFromString<EnqueueInput>(ctx.body())
ctx.future(
future {
DownloadManager.enqueue(inputs)
val queueChapters =
handler(
documentWith = {
withOperation {
summary("Downloader add multiple chapters")
description("Queue multiple chapters for download")
}
)
},
withResults = {
httpCode(HttpCode.OK)
}
)
body<EnqueueInput>()
},
behaviorOf = { ctx ->
val inputs = json.decodeFromString<EnqueueInput>(ctx.body())
ctx.future(
future {
DownloadManager.enqueue(inputs)
},
)
},
withResults = {
httpCode(HttpCode.OK)
},
)
/** delete multiple chapters from download queue */
val unqueueChapters = handler(
documentWith = {
withOperation {
summary("Downloader remove multiple downloads")
description("Remove multiple chapters downloads from queue")
}
body<EnqueueInput>()
},
behaviorOf = { ctx ->
val input = json.decodeFromString<EnqueueInput>(ctx.body())
ctx.future(
future {
DownloadManager.dequeue(input)
val unqueueChapters =
handler(
documentWith = {
withOperation {
summary("Downloader remove multiple downloads")
description("Remove multiple chapters downloads from queue")
}
)
},
withResults = {
httpCode(HttpCode.OK)
}
)
body<EnqueueInput>()
},
behaviorOf = { ctx ->
val input = json.decodeFromString<EnqueueInput>(ctx.body())
ctx.future(
future {
DownloadManager.dequeue(input)
},
)
},
withResults = {
httpCode(HttpCode.OK)
},
)
/** delete chapter from download queue */
val unqueueChapter = handler(
pathParam<Int>("chapterIndex"),
pathParam<Int>("mangaId"),
documentWith = {
withOperation {
summary("Downloader remove chapter")
description("Delete chapter from download queue")
}
},
behaviorOf = { ctx, chapterIndex, mangaId ->
DownloadManager.dequeue(chapterIndex, mangaId)
val unqueueChapter =
handler(
pathParam<Int>("chapterIndex"),
pathParam<Int>("mangaId"),
documentWith = {
withOperation {
summary("Downloader remove chapter")
description("Delete chapter from download queue")
}
},
behaviorOf = { ctx, chapterIndex, mangaId ->
DownloadManager.dequeue(chapterIndex, mangaId)
ctx.status(200)
},
withResults = {
httpCode(HttpCode.OK)
}
)
ctx.status(200)
},
withResults = {
httpCode(HttpCode.OK)
},
)
/** clear download queue */
val reorderChapter = handler(
pathParam<Int>("chapterIndex"),
pathParam<Int>("mangaId"),
pathParam<Int>("to"),
documentWith = {
withOperation {
summary("Downloader reorder chapter")
description("Reorder chapter in download queue")
}
},
behaviorOf = { _, chapterIndex, mangaId, to ->
DownloadManager.reorder(chapterIndex, mangaId, to)
},
withResults = {
httpCode(HttpCode.OK)
}
)
val reorderChapter =
handler(
pathParam<Int>("chapterIndex"),
pathParam<Int>("mangaId"),
pathParam<Int>("to"),
documentWith = {
withOperation {
summary("Downloader reorder chapter")
description("Reorder chapter in download queue")
}
},
behaviorOf = { _, chapterIndex, mangaId, to ->
DownloadManager.reorder(chapterIndex, mangaId, to)
},
withResults = {
httpCode(HttpCode.OK)
},
)
}

View File

@@ -21,143 +21,149 @@ object ExtensionController {
private val logger = KotlinLogging.logger {}
/** list all extensions */
val list = handler(
documentWith = {
withOperation {
summary("Extension list")
description("List all extensions")
}
},
behaviorOf = { ctx ->
ctx.future(
future {
ExtensionsList.getExtensionList()
val list =
handler(
documentWith = {
withOperation {
summary("Extension list")
description("List all extensions")
}
)
},
withResults = {
json<Array<ExtensionDataClass>>(HttpCode.OK)
}
)
},
behaviorOf = { ctx ->
ctx.future(
future {
ExtensionsList.getExtensionList()
},
)
},
withResults = {
json<Array<ExtensionDataClass>>(HttpCode.OK)
},
)
/** install extension identified with "pkgName" */
val install = handler(
pathParam<String>("pkgName"),
documentWith = {
withOperation {
summary("Extension install")
description("install extension identified with \"pkgName\"")
}
},
behaviorOf = { ctx, pkgName ->
ctx.future(
future {
Extension.installExtension(pkgName)
val install =
handler(
pathParam<String>("pkgName"),
documentWith = {
withOperation {
summary("Extension install")
description("install extension identified with \"pkgName\"")
}
)
},
withResults = {
httpCode(HttpCode.CREATED)
httpCode(HttpCode.FOUND)
httpCode(HttpCode.INTERNAL_SERVER_ERROR)
}
)
},
behaviorOf = { ctx, pkgName ->
ctx.future(
future {
Extension.installExtension(pkgName)
},
)
},
withResults = {
httpCode(HttpCode.CREATED)
httpCode(HttpCode.FOUND)
httpCode(HttpCode.INTERNAL_SERVER_ERROR)
},
)
/** install the uploaded apk file */
val installFile = handler(
documentWith = {
withOperation {
summary("Extension install apk")
description("Install the uploaded apk file")
}
uploadedFile("file") {
it.description("Extension apk")
it.required(true)
}
},
behaviorOf = { ctx ->
val uploadedFile = ctx.uploadedFile("file")!!
logger.debug { "Uploaded extension file name: " + uploadedFile.filename }
ctx.future(
future {
Extension.installExternalExtension(uploadedFile.content, uploadedFile.filename)
val installFile =
handler(
documentWith = {
withOperation {
summary("Extension install apk")
description("Install the uploaded apk file")
}
)
},
withResults = {
httpCode(HttpCode.CREATED)
httpCode(HttpCode.FOUND)
httpCode(HttpCode.INTERNAL_SERVER_ERROR)
}
)
uploadedFile("file") {
it.description("Extension apk")
it.required(true)
}
},
behaviorOf = { ctx ->
val uploadedFile = ctx.uploadedFile("file")!!
logger.debug { "Uploaded extension file name: " + uploadedFile.filename }
ctx.future(
future {
Extension.installExternalExtension(uploadedFile.content, uploadedFile.filename)
},
)
},
withResults = {
httpCode(HttpCode.CREATED)
httpCode(HttpCode.FOUND)
httpCode(HttpCode.INTERNAL_SERVER_ERROR)
},
)
/** update extension identified with "pkgName" */
val update = handler(
pathParam<String>("pkgName"),
documentWith = {
withOperation {
summary("Extension update")
description("Update extension identified with \"pkgName\"")
}
},
behaviorOf = { ctx, pkgName ->
ctx.future(
future {
Extension.updateExtension(pkgName)
val update =
handler(
pathParam<String>("pkgName"),
documentWith = {
withOperation {
summary("Extension update")
description("Update extension identified with \"pkgName\"")
}
)
},
withResults = {
httpCode(HttpCode.CREATED)
httpCode(HttpCode.FOUND)
httpCode(HttpCode.NOT_FOUND)
httpCode(HttpCode.INTERNAL_SERVER_ERROR)
}
)
},
behaviorOf = { ctx, pkgName ->
ctx.future(
future {
Extension.updateExtension(pkgName)
},
)
},
withResults = {
httpCode(HttpCode.CREATED)
httpCode(HttpCode.FOUND)
httpCode(HttpCode.NOT_FOUND)
httpCode(HttpCode.INTERNAL_SERVER_ERROR)
},
)
/** uninstall extension identified with "pkgName" */
val uninstall = handler(
pathParam<String>("pkgName"),
documentWith = {
withOperation {
summary("Extension uninstall")
description("Uninstall extension identified with \"pkgName\"")
}
},
behaviorOf = { ctx, pkgName ->
Extension.uninstallExtension(pkgName)
ctx.status(200)
},
withResults = {
httpCode(HttpCode.CREATED)
httpCode(HttpCode.FOUND)
httpCode(HttpCode.NOT_FOUND)
httpCode(HttpCode.INTERNAL_SERVER_ERROR)
}
)
val uninstall =
handler(
pathParam<String>("pkgName"),
documentWith = {
withOperation {
summary("Extension uninstall")
description("Uninstall extension identified with \"pkgName\"")
}
},
behaviorOf = { ctx, pkgName ->
Extension.uninstallExtension(pkgName)
ctx.status(200)
},
withResults = {
httpCode(HttpCode.CREATED)
httpCode(HttpCode.FOUND)
httpCode(HttpCode.NOT_FOUND)
httpCode(HttpCode.INTERNAL_SERVER_ERROR)
},
)
/** icon for extension named `apkName` */
val icon = handler(
pathParam<String>("apkName"),
documentWith = {
withOperation {
summary("Extension icon")
description("Icon for extension named `apkName`")
}
},
behaviorOf = { ctx, apkName ->
ctx.future(
future { Extension.getExtensionIcon(apkName) }
.thenApply {
ctx.header("content-type", it.second)
it.first
}
)
},
withResults = {
image(HttpCode.OK)
httpCode(HttpCode.NOT_FOUND)
}
)
val icon =
handler(
pathParam<String>("apkName"),
documentWith = {
withOperation {
summary("Extension icon")
description("Icon for extension named `apkName`")
}
},
behaviorOf = { ctx, apkName ->
ctx.future(
future { Extension.getExtensionIcon(apkName) }
.thenApply {
ctx.header("content-type", it.second)
it.first
},
)
},
withResults = {
image(HttpCode.OK)
httpCode(HttpCode.NOT_FOUND)
},
)
}

View File

@@ -32,367 +32,390 @@ import kotlin.time.Duration.Companion.days
object MangaController {
private val json by DI.global.instance<Json>()
val retrieve = handler(
pathParam<Int>("mangaId"),
queryParam("onlineFetch", false),
documentWith = {
withOperation {
summary("Get manga info")
description("Get a manga from the database using a specific id.")
}
},
behaviorOf = { ctx, mangaId, onlineFetch ->
ctx.future(
future {
Manga.getManga(mangaId, onlineFetch)
val retrieve =
handler(
pathParam<Int>("mangaId"),
queryParam("onlineFetch", false),
documentWith = {
withOperation {
summary("Get manga info")
description("Get a manga from the database using a specific id.")
}
)
},
withResults = {
json<MangaDataClass>(HttpCode.OK)
httpCode(HttpCode.NOT_FOUND)
}
)
},
behaviorOf = { ctx, mangaId, onlineFetch ->
ctx.future(
future {
Manga.getManga(mangaId, onlineFetch)
},
)
},
withResults = {
json<MangaDataClass>(HttpCode.OK)
httpCode(HttpCode.NOT_FOUND)
},
)
/** get manga info with all data filled in */
val retrieveFull = handler(
pathParam<Int>("mangaId"),
queryParam("onlineFetch", false),
documentWith = {
withOperation {
summary("Get manga info with all data filled in")
description("Get a manga from the database using a specific id.")
}
},
behaviorOf = { ctx, mangaId, onlineFetch ->
ctx.future(
future {
Manga.getMangaFull(mangaId, onlineFetch)
val retrieveFull =
handler(
pathParam<Int>("mangaId"),
queryParam("onlineFetch", false),
documentWith = {
withOperation {
summary("Get manga info with all data filled in")
description("Get a manga from the database using a specific id.")
}
)
},
withResults = {
json<MangaDataClass>(HttpCode.OK)
httpCode(HttpCode.NOT_FOUND)
}
)
},
behaviorOf = { ctx, mangaId, onlineFetch ->
ctx.future(
future {
Manga.getMangaFull(mangaId, onlineFetch)
},
)
},
withResults = {
json<MangaDataClass>(HttpCode.OK)
httpCode(HttpCode.NOT_FOUND)
},
)
/** manga thumbnail */
val thumbnail = handler(
pathParam<Int>("mangaId"),
documentWith = {
withOperation {
summary("Get a manga thumbnail")
description("Get a manga thumbnail from the source or the cache.")
}
},
behaviorOf = { ctx, mangaId ->
ctx.future(
future { Manga.getMangaThumbnail(mangaId) }
.thenApply {
ctx.header("content-type", it.second)
val httpCacheSeconds = 1.days.inWholeSeconds
ctx.header("cache-control", "max-age=$httpCacheSeconds")
it.first
}
)
},
withResults = {
image(HttpCode.OK)
httpCode(HttpCode.NOT_FOUND)
}
)
val thumbnail =
handler(
pathParam<Int>("mangaId"),
documentWith = {
withOperation {
summary("Get a manga thumbnail")
description("Get a manga thumbnail from the source or the cache.")
}
},
behaviorOf = { ctx, mangaId ->
ctx.future(
future { Manga.getMangaThumbnail(mangaId) }
.thenApply {
ctx.header("content-type", it.second)
val httpCacheSeconds = 1.days.inWholeSeconds
ctx.header("cache-control", "max-age=$httpCacheSeconds")
it.first
},
)
},
withResults = {
image(HttpCode.OK)
httpCode(HttpCode.NOT_FOUND)
},
)
/** adds the manga to library */
val addToLibrary = handler(
pathParam<Int>("mangaId"),
documentWith = {
withOperation {
summary("Add manga to library")
description("Use a manga id to add the manga to your library.\nWill do nothing if manga is already in your library.")
}
},
behaviorOf = { ctx, mangaId ->
ctx.future(
future { Library.addMangaToLibrary(mangaId) }
)
},
withResults = {
httpCode(HttpCode.OK)
httpCode(HttpCode.NOT_FOUND)
}
)
val addToLibrary =
handler(
pathParam<Int>("mangaId"),
documentWith = {
withOperation {
summary("Add manga to library")
description("Use a manga id to add the manga to your library.\nWill do nothing if manga is already in your library.")
}
},
behaviorOf = { ctx, mangaId ->
ctx.future(
future { Library.addMangaToLibrary(mangaId) },
)
},
withResults = {
httpCode(HttpCode.OK)
httpCode(HttpCode.NOT_FOUND)
},
)
/** removes the manga from the library */
val removeFromLibrary = handler(
pathParam<Int>("mangaId"),
documentWith = {
withOperation {
summary("Remove manga to library")
description("Use a manga id to remove the manga to your library.\nWill do nothing if manga not in your library.")
}
},
behaviorOf = { ctx, mangaId ->
ctx.future(
future { Library.removeMangaFromLibrary(mangaId) }
)
},
withResults = {
httpCode(HttpCode.OK)
httpCode(HttpCode.NOT_FOUND)
}
)
val removeFromLibrary =
handler(
pathParam<Int>("mangaId"),
documentWith = {
withOperation {
summary("Remove manga to library")
description("Use a manga id to remove the manga to your library.\nWill do nothing if manga not in your library.")
}
},
behaviorOf = { ctx, mangaId ->
ctx.future(
future { Library.removeMangaFromLibrary(mangaId) },
)
},
withResults = {
httpCode(HttpCode.OK)
httpCode(HttpCode.NOT_FOUND)
},
)
/** list manga's categories */
val categoryList = handler(
pathParam<Int>("mangaId"),
documentWith = {
withOperation {
summary("Get a manga's categories")
description("Get the list of categories for this manga")
}
},
behaviorOf = { ctx, mangaId ->
ctx.json(CategoryManga.getMangaCategories(mangaId))
},
withResults = {
json<Array<CategoryDataClass>>(HttpCode.OK)
}
)
val categoryList =
handler(
pathParam<Int>("mangaId"),
documentWith = {
withOperation {
summary("Get a manga's categories")
description("Get the list of categories for this manga")
}
},
behaviorOf = { ctx, mangaId ->
ctx.json(CategoryManga.getMangaCategories(mangaId))
},
withResults = {
json<Array<CategoryDataClass>>(HttpCode.OK)
},
)
/** adds the manga to category */
val addToCategory = handler(
pathParam<Int>("mangaId"),
pathParam<Int>("categoryId"),
documentWith = {
withOperation {
summary("Add manga to category")
description("Add a manga to a category using their ids.")
}
},
behaviorOf = { ctx, mangaId, categoryId ->
CategoryManga.addMangaToCategory(mangaId, categoryId)
ctx.status(200)
},
withResults = {
httpCode(HttpCode.OK)
}
)
val addToCategory =
handler(
pathParam<Int>("mangaId"),
pathParam<Int>("categoryId"),
documentWith = {
withOperation {
summary("Add manga to category")
description("Add a manga to a category using their ids.")
}
},
behaviorOf = { ctx, mangaId, categoryId ->
CategoryManga.addMangaToCategory(mangaId, categoryId)
ctx.status(200)
},
withResults = {
httpCode(HttpCode.OK)
},
)
/** removes the manga from the category */
val removeFromCategory = handler(
pathParam<Int>("mangaId"),
pathParam<Int>("categoryId"),
documentWith = {
withOperation {
summary("Remove manga from category")
description("Remove a manga from a category using their ids.")
}
},
behaviorOf = { ctx, mangaId, categoryId ->
CategoryManga.removeMangaFromCategory(mangaId, categoryId)
ctx.status(200)
},
withResults = {
httpCode(HttpCode.OK)
}
)
val removeFromCategory =
handler(
pathParam<Int>("mangaId"),
pathParam<Int>("categoryId"),
documentWith = {
withOperation {
summary("Remove manga from category")
description("Remove a manga from a category using their ids.")
}
},
behaviorOf = { ctx, mangaId, categoryId ->
CategoryManga.removeMangaFromCategory(mangaId, categoryId)
ctx.status(200)
},
withResults = {
httpCode(HttpCode.OK)
},
)
/** used to modify a manga's meta parameters */
val meta = handler(
pathParam<Int>("mangaId"),
formParam<String>("key"),
formParam<String>("value"),
documentWith = {
withOperation {
summary("Add data to manga")
description("A simple Key-Value storage in the manga object, you can set values for whatever you want inside it.")
}
},
behaviorOf = { ctx, mangaId, key, value ->
Manga.modifyMangaMeta(mangaId, key, value)
ctx.status(200)
},
withResults = {
httpCode(HttpCode.OK)
httpCode(HttpCode.NOT_FOUND)
}
)
val meta =
handler(
pathParam<Int>("mangaId"),
formParam<String>("key"),
formParam<String>("value"),
documentWith = {
withOperation {
summary("Add data to manga")
description("A simple Key-Value storage in the manga object, you can set values for whatever you want inside it.")
}
},
behaviorOf = { ctx, mangaId, key, value ->
Manga.modifyMangaMeta(mangaId, key, value)
ctx.status(200)
},
withResults = {
httpCode(HttpCode.OK)
httpCode(HttpCode.NOT_FOUND)
},
)
/** get chapter list when showing a manga */
val chapterList = handler(
pathParam<Int>("mangaId"),
queryParam("onlineFetch", false),
documentWith = {
withOperation {
summary("Get manga chapter list")
description("Get the manga chapter list from the database or online. If there is no chapters in the database it fetches the chapters online. Use onlineFetch to update chapter list.")
}
},
behaviorOf = { ctx, mangaId, onlineFetch ->
ctx.future(future { Chapter.getChapterList(mangaId, onlineFetch) })
},
withResults = {
json<Array<ChapterDataClass>>(HttpCode.OK)
httpCode(HttpCode.NOT_FOUND)
}
)
val chapterList =
handler(
pathParam<Int>("mangaId"),
queryParam("onlineFetch", false),
documentWith = {
withOperation {
summary("Get manga chapter list")
description(
"Get the manga chapter list from the database or online. " +
"If there is no chapters in the database it fetches the chapters online. " +
"Use onlineFetch to update chapter list.",
)
}
},
behaviorOf = { ctx, mangaId, onlineFetch ->
ctx.future(future { Chapter.getChapterList(mangaId, onlineFetch) })
},
withResults = {
json<Array<ChapterDataClass>>(HttpCode.OK)
httpCode(HttpCode.NOT_FOUND)
},
)
/** batch edit chapters of single manga */
val chapterBatch = handler(
pathParam<Int>("mangaId"),
documentWith = {
withOperation {
summary("Chapters update multiple")
description("Update multiple chapters of single manga. For batch marking as read, or bookmarking")
}
body<Chapter.MangaChapterBatchEditInput>()
},
behaviorOf = { ctx, mangaId ->
val input = json.decodeFromString<Chapter.MangaChapterBatchEditInput>(ctx.body())
Chapter.modifyChapters(input, mangaId)
},
withResults = {
httpCode(HttpCode.OK)
}
)
val chapterBatch =
handler(
pathParam<Int>("mangaId"),
documentWith = {
withOperation {
summary("Chapters update multiple")
description("Update multiple chapters of single manga. For batch marking as read, or bookmarking")
}
body<Chapter.MangaChapterBatchEditInput>()
},
behaviorOf = { ctx, mangaId ->
val input = json.decodeFromString<Chapter.MangaChapterBatchEditInput>(ctx.body())
Chapter.modifyChapters(input, mangaId)
},
withResults = {
httpCode(HttpCode.OK)
},
)
/** batch edit chapters from multiple manga */
val anyChapterBatch = handler(
documentWith = {
withOperation {
summary("Chapters update multiple")
description("Update multiple chapters on any manga. For batch marking as read, or bookmarking")
}
body<Chapter.ChapterBatchEditInput>()
},
behaviorOf = { ctx ->
val input = json.decodeFromString<Chapter.ChapterBatchEditInput>(ctx.body())
Chapter.modifyChapters(
Chapter.MangaChapterBatchEditInput(
input.chapterIds,
null,
input.change
val anyChapterBatch =
handler(
documentWith = {
withOperation {
summary("Chapters update multiple")
description("Update multiple chapters on any manga. For batch marking as read, or bookmarking")
}
body<Chapter.ChapterBatchEditInput>()
},
behaviorOf = { ctx ->
val input = json.decodeFromString<Chapter.ChapterBatchEditInput>(ctx.body())
Chapter.modifyChapters(
Chapter.MangaChapterBatchEditInput(
input.chapterIds,
null,
input.change,
),
)
)
},
withResults = {
httpCode(HttpCode.OK)
}
)
},
withResults = {
httpCode(HttpCode.OK)
},
)
/** used to display a chapter, get a chapter in order to show its pages */
val chapterRetrieve = handler(
pathParam<Int>("mangaId"),
pathParam<Int>("chapterIndex"),
documentWith = {
withOperation {
summary("Get a chapter")
description("Get the chapter from the manga id and chapter index. It will also retrieve the pages for this chapter.")
}
},
behaviorOf = { ctx, mangaId, chapterIndex ->
ctx.future(future { getChapterDownloadReadyByIndex(chapterIndex, mangaId) })
},
withResults = {
json<ChapterDataClass>(HttpCode.OK)
httpCode(HttpCode.NOT_FOUND)
}
)
val chapterRetrieve =
handler(
pathParam<Int>("mangaId"),
pathParam<Int>("chapterIndex"),
documentWith = {
withOperation {
summary("Get a chapter")
description("Get the chapter from the manga id and chapter index. It will also retrieve the pages for this chapter.")
}
},
behaviorOf = { ctx, mangaId, chapterIndex ->
ctx.future(future { getChapterDownloadReadyByIndex(chapterIndex, mangaId) })
},
withResults = {
json<ChapterDataClass>(HttpCode.OK)
httpCode(HttpCode.NOT_FOUND)
},
)
/** used to modify a chapter's parameters */
val chapterModify = handler(
pathParam<Int>("mangaId"),
pathParam<Int>("chapterIndex"),
formParam<Boolean?>("read"),
formParam<Boolean?>("bookmarked"),
formParam<Boolean?>("markPrevRead"),
formParam<Int?>("lastPageRead"),
documentWith = {
withOperation {
summary("Modify a chapter")
description("Update user info for a given chapter, such as read status, bookmarked, and more.")
}
},
behaviorOf = { ctx, mangaId, chapterIndex, read, bookmarked, markPrevRead, lastPageRead ->
Chapter.modifyChapter(mangaId, chapterIndex, read, bookmarked, markPrevRead, lastPageRead)
val chapterModify =
handler(
pathParam<Int>("mangaId"),
pathParam<Int>("chapterIndex"),
formParam<Boolean?>("read"),
formParam<Boolean?>("bookmarked"),
formParam<Boolean?>("markPrevRead"),
formParam<Int?>("lastPageRead"),
documentWith = {
withOperation {
summary("Modify a chapter")
description("Update user info for a given chapter, such as read status, bookmarked, and more.")
}
},
behaviorOf = { ctx, mangaId, chapterIndex, read, bookmarked, markPrevRead, lastPageRead ->
Chapter.modifyChapter(mangaId, chapterIndex, read, bookmarked, markPrevRead, lastPageRead)
ctx.status(200)
},
withResults = {
httpCode(HttpCode.OK)
}
)
ctx.status(200)
},
withResults = {
httpCode(HttpCode.OK)
},
)
/** delete a downloaded chapter */
val chapterDelete = handler(
pathParam<Int>("mangaId"),
pathParam<Int>("chapterIndex"),
documentWith = {
withOperation {
summary("Delete a chapter download")
description("Delete the downloaded chapter and its files.")
}
},
behaviorOf = { ctx, mangaId, chapterIndex ->
Chapter.deleteChapter(mangaId, chapterIndex)
val chapterDelete =
handler(
pathParam<Int>("mangaId"),
pathParam<Int>("chapterIndex"),
documentWith = {
withOperation {
summary("Delete a chapter download")
description("Delete the downloaded chapter and its files.")
}
},
behaviorOf = { ctx, mangaId, chapterIndex ->
Chapter.deleteChapter(mangaId, chapterIndex)
ctx.status(200)
},
withResults = {
httpCode(HttpCode.OK)
httpCode(HttpCode.NOT_FOUND)
}
)
ctx.status(200)
},
withResults = {
httpCode(HttpCode.OK)
httpCode(HttpCode.NOT_FOUND)
},
)
/** used to modify a chapter's meta parameters */
val chapterMeta = handler(
pathParam<Int>("mangaId"),
pathParam<Int>("chapterIndex"),
formParam<String>("key"),
formParam<String>("value"),
documentWith = {
withOperation {
summary("Add data to chapter")
description("A simple Key-Value storage in the chapter object, you can set values for whatever you want inside it.")
}
},
behaviorOf = { ctx, mangaId, chapterIndex, key, value ->
Chapter.modifyChapterMeta(mangaId, chapterIndex, key, value)
val chapterMeta =
handler(
pathParam<Int>("mangaId"),
pathParam<Int>("chapterIndex"),
formParam<String>("key"),
formParam<String>("value"),
documentWith = {
withOperation {
summary("Add data to chapter")
description("A simple Key-Value storage in the chapter object, you can set values for whatever you want inside it.")
}
},
behaviorOf = { ctx, mangaId, chapterIndex, key, value ->
Chapter.modifyChapterMeta(mangaId, chapterIndex, key, value)
ctx.status(200)
},
withResults = {
httpCode(HttpCode.OK)
httpCode(HttpCode.NOT_FOUND)
}
)
ctx.status(200)
},
withResults = {
httpCode(HttpCode.OK)
httpCode(HttpCode.NOT_FOUND)
},
)
/** get page at index "index" */
val pageRetrieve = handler(
pathParam<Int>("mangaId"),
pathParam<Int>("chapterIndex"),
pathParam<Int>("index"),
documentWith = {
withOperation {
summary("Get a chapter page")
description("Get a chapter page for a given index. Cache use can be disabled so it only retrieves it directly from the source.")
}
},
behaviorOf = { ctx, mangaId, chapterIndex, index ->
ctx.future(
future { Page.getPageImage(mangaId, chapterIndex, index) }
.thenApply {
ctx.header("content-type", it.second)
val httpCacheSeconds = 1.days.inWholeSeconds
ctx.header("cache-control", "max-age=$httpCacheSeconds")
it.first
}
)
},
withResults = {
image(HttpCode.OK)
httpCode(HttpCode.NOT_FOUND)
}
)
val pageRetrieve =
handler(
pathParam<Int>("mangaId"),
pathParam<Int>("chapterIndex"),
pathParam<Int>("index"),
documentWith = {
withOperation {
summary("Get a chapter page")
description(
"Get a chapter page for a given index. Cache use can be disabled so it only retrieves it directly from the source.",
)
}
},
behaviorOf = { ctx, mangaId, chapterIndex, index ->
ctx.future(
future { Page.getPageImage(mangaId, chapterIndex, index) }
.thenApply {
ctx.header("content-type", it.second)
val httpCacheSeconds = 1.days.inWholeSeconds
ctx.header("cache-control", "max-age=$httpCacheSeconds")
it.first
},
)
},
withResults = {
image(HttpCode.OK)
httpCode(HttpCode.NOT_FOUND)
},
)
}

View File

@@ -29,217 +29,229 @@ import suwayomi.tachidesk.server.util.withOperation
object SourceController {
/** list of sources */
val list = handler(
documentWith = {
withOperation {
summary("Sources list")
description("List of sources")
}
},
behaviorOf = { ctx ->
ctx.json(Source.getSourceList())
},
withResults = {
json<Array<SourceDataClass>>(HttpCode.OK)
}
)
val list =
handler(
documentWith = {
withOperation {
summary("Sources list")
description("List of sources")
}
},
behaviorOf = { ctx ->
ctx.json(Source.getSourceList())
},
withResults = {
json<Array<SourceDataClass>>(HttpCode.OK)
},
)
/** fetch source with id `sourceId` */
val retrieve = handler(
pathParam<Long>("sourceId"),
documentWith = {
withOperation {
summary("Source fetch")
description("Fetch source with id `sourceId`")
}
},
behaviorOf = { ctx, sourceId ->
ctx.json(Source.getSource(sourceId)!!)
},
withResults = {
json<SourceDataClass>(HttpCode.OK)
httpCode(HttpCode.NOT_FOUND)
}
)
val retrieve =
handler(
pathParam<Long>("sourceId"),
documentWith = {
withOperation {
summary("Source fetch")
description("Fetch source with id `sourceId`")
}
},
behaviorOf = { ctx, sourceId ->
ctx.json(Source.getSource(sourceId)!!)
},
withResults = {
json<SourceDataClass>(HttpCode.OK)
httpCode(HttpCode.NOT_FOUND)
},
)
/** popular mangas from source with id `sourceId` */
val popular = handler(
pathParam<Long>("sourceId"),
pathParam<Int>("pageNum"),
documentWith = {
withOperation {
summary("Source popular manga")
description("Popular mangas from source with id `sourceId`")
}
},
behaviorOf = { ctx, sourceId, pageNum ->
ctx.future(
future {
MangaList.getMangaList(sourceId, pageNum, popular = true)
val popular =
handler(
pathParam<Long>("sourceId"),
pathParam<Int>("pageNum"),
documentWith = {
withOperation {
summary("Source popular manga")
description("Popular mangas from source with id `sourceId`")
}
)
},
withResults = {
json<PagedMangaListDataClass>(HttpCode.OK)
}
)
},
behaviorOf = { ctx, sourceId, pageNum ->
ctx.future(
future {
MangaList.getMangaList(sourceId, pageNum, popular = true)
},
)
},
withResults = {
json<PagedMangaListDataClass>(HttpCode.OK)
},
)
/** latest mangas from source with id `sourceId` */
val latest = handler(
pathParam<Long>("sourceId"),
pathParam<Int>("pageNum"),
documentWith = {
withOperation {
summary("Source latest manga")
description("Latest mangas from source with id `sourceId`")
}
},
behaviorOf = { ctx, sourceId, pageNum ->
ctx.future(
future {
MangaList.getMangaList(sourceId, pageNum, popular = false)
val latest =
handler(
pathParam<Long>("sourceId"),
pathParam<Int>("pageNum"),
documentWith = {
withOperation {
summary("Source latest manga")
description("Latest mangas from source with id `sourceId`")
}
)
},
withResults = {
json<PagedMangaListDataClass>(HttpCode.OK)
}
)
},
behaviorOf = { ctx, sourceId, pageNum ->
ctx.future(
future {
MangaList.getMangaList(sourceId, pageNum, popular = false)
},
)
},
withResults = {
json<PagedMangaListDataClass>(HttpCode.OK)
},
)
/** fetch preferences of source with id `sourceId` */
val getPreferences = handler(
pathParam<Long>("sourceId"),
documentWith = {
withOperation {
summary("Source preferences")
description("Fetch preferences of source with id `sourceId`")
}
},
behaviorOf = { ctx, sourceId ->
ctx.json(Source.getSourcePreferences(sourceId))
},
withResults = {
json<Array<Source.PreferenceObject>>(HttpCode.OK)
}
)
val getPreferences =
handler(
pathParam<Long>("sourceId"),
documentWith = {
withOperation {
summary("Source preferences")
description("Fetch preferences of source with id `sourceId`")
}
},
behaviorOf = { ctx, sourceId ->
ctx.json(Source.getSourcePreferences(sourceId))
},
withResults = {
json<Array<Source.PreferenceObject>>(HttpCode.OK)
},
)
/** set one preference of source with id `sourceId` */
val setPreference = handler(
pathParam<Long>("sourceId"),
documentWith = {
withOperation {
summary("Source preference set")
description("Set one preference of source with id `sourceId`")
}
body<SourcePreferenceChange>()
},
behaviorOf = { ctx, sourceId ->
val preferenceChange = ctx.bodyAsClass(SourcePreferenceChange::class.java)
ctx.json(Source.setSourcePreference(sourceId, preferenceChange.position, preferenceChange.value))
},
withResults = {
httpCode(HttpCode.OK)
}
)
val setPreference =
handler(
pathParam<Long>("sourceId"),
documentWith = {
withOperation {
summary("Source preference set")
description("Set one preference of source with id `sourceId`")
}
body<SourcePreferenceChange>()
},
behaviorOf = { ctx, sourceId ->
val preferenceChange = ctx.bodyAsClass(SourcePreferenceChange::class.java)
ctx.json(Source.setSourcePreference(sourceId, preferenceChange.position, preferenceChange.value))
},
withResults = {
httpCode(HttpCode.OK)
},
)
/** fetch filters of source with id `sourceId` */
val getFilters = handler(
pathParam<Long>("sourceId"),
queryParam("reset", false),
documentWith = {
withOperation {
summary("Source filters")
description("Fetch filters of source with id `sourceId`")
}
},
behaviorOf = { ctx, sourceId, reset ->
ctx.json(Search.getFilterList(sourceId, reset))
},
withResults = {
json<Array<Search.FilterObject>>(HttpCode.OK)
}
)
val getFilters =
handler(
pathParam<Long>("sourceId"),
queryParam("reset", false),
documentWith = {
withOperation {
summary("Source filters")
description("Fetch filters of source with id `sourceId`")
}
},
behaviorOf = { ctx, sourceId, reset ->
ctx.json(Search.getFilterList(sourceId, reset))
},
withResults = {
json<Array<Search.FilterObject>>(HttpCode.OK)
},
)
private val json by DI.global.instance<Json>()
/** change filters of source with id `sourceId` */
val setFilters = handler(
pathParam<Long>("sourceId"),
documentWith = {
withOperation {
summary("Source filters set")
description("Change filters of source with id `sourceId`")
}
body<FilterChange>()
body<Array<FilterChange>>()
},
behaviorOf = { ctx, sourceId ->
val filterChange = try {
json.decodeFromString<List<FilterChange>>(ctx.body())
} catch (e: Exception) {
listOf(json.decodeFromString<FilterChange>(ctx.body()))
}
val setFilters =
handler(
pathParam<Long>("sourceId"),
documentWith = {
withOperation {
summary("Source filters set")
description("Change filters of source with id `sourceId`")
}
body<FilterChange>()
body<Array<FilterChange>>()
},
behaviorOf = { ctx, sourceId ->
val filterChange =
try {
json.decodeFromString<List<FilterChange>>(ctx.body())
} catch (e: Exception) {
listOf(json.decodeFromString<FilterChange>(ctx.body()))
}
ctx.json(Search.setFilter(sourceId, filterChange))
},
withResults = {
httpCode(HttpCode.OK)
}
)
ctx.json(Search.setFilter(sourceId, filterChange))
},
withResults = {
httpCode(HttpCode.OK)
},
)
/** single source search */
val searchSingle = handler(
pathParam<Long>("sourceId"),
queryParam("searchTerm", ""),
queryParam("pageNum", 1),
documentWith = {
withOperation {
summary("Source search")
description("Single source search")
}
},
behaviorOf = { ctx, sourceId, searchTerm, pageNum ->
ctx.future(future { Search.sourceSearch(sourceId, searchTerm, pageNum) })
},
withResults = {
json<PagedMangaListDataClass>(HttpCode.OK)
}
)
val searchSingle =
handler(
pathParam<Long>("sourceId"),
queryParam("searchTerm", ""),
queryParam("pageNum", 1),
documentWith = {
withOperation {
summary("Source search")
description("Single source search")
}
},
behaviorOf = { ctx, sourceId, searchTerm, pageNum ->
ctx.future(future { Search.sourceSearch(sourceId, searchTerm, pageNum) })
},
withResults = {
json<PagedMangaListDataClass>(HttpCode.OK)
},
)
/** quick search single source filter */
val quickSearchSingle = handler(
pathParam<Long>("sourceId"),
queryParam("pageNum", 1),
documentWith = {
withOperation {
summary("Source manga quick search")
description("Returns list of manga from source matching posted searchTerm and filter")
}
body<FilterData>()
},
behaviorOf = { ctx, sourceId, pageNum ->
val filter = json.decodeFromString<FilterData>(ctx.body())
ctx.future(future { Search.sourceFilter(sourceId, pageNum, filter) })
},
withResults = {
json<PagedMangaListDataClass>(HttpCode.OK)
}
)
val quickSearchSingle =
handler(
pathParam<Long>("sourceId"),
queryParam("pageNum", 1),
documentWith = {
withOperation {
summary("Source manga quick search")
description("Returns list of manga from source matching posted searchTerm and filter")
}
body<FilterData>()
},
behaviorOf = { ctx, sourceId, pageNum ->
val filter = json.decodeFromString<FilterData>(ctx.body())
ctx.future(future { Search.sourceFilter(sourceId, pageNum, filter) })
},
withResults = {
json<PagedMangaListDataClass>(HttpCode.OK)
},
)
/** all source search */
val searchAll = handler(
pathParam<String>("searchTerm"),
documentWith = {
withOperation {
summary("Source global search")
description("All source search")
}
},
behaviorOf = { ctx, searchTerm -> // TODO
ctx.json(Search.sourceGlobalSearch(searchTerm))
},
withResults = {
httpCode(HttpCode.OK)
}
)
val searchAll =
handler(
pathParam<String>("searchTerm"),
documentWith = {
withOperation {
summary("Source global search")
description("All source search")
}
},
behaviorOf = { ctx, searchTerm -> // TODO
ctx.json(Search.sourceGlobalSearch(searchTerm))
},
withResults = {
httpCode(HttpCode.OK)
},
)
}

View File

@@ -30,25 +30,26 @@ object UpdateController {
private val logger = KotlinLogging.logger { }
/** get recently updated manga chapters */
val recentChapters = handler(
pathParam<Int>("pageNum"),
documentWith = {
withOperation {
summary("Updates fetch")
description("Get recently updated manga chapters")
}
},
behaviorOf = { ctx, pageNum ->
ctx.future(
future {
Chapter.getRecentChapters(pageNum)
val recentChapters =
handler(
pathParam<Int>("pageNum"),
documentWith = {
withOperation {
summary("Updates fetch")
description("Get recently updated manga chapters")
}
)
},
withResults = {
json<PagedMangaChapterListDataClass>(HttpCode.OK)
}
)
},
behaviorOf = { ctx, pageNum ->
ctx.future(
future {
Chapter.getRecentChapters(pageNum)
},
)
},
withResults = {
json<PagedMangaChapterListDataClass>(HttpCode.OK)
},
)
/**
* Class made for handling return type in the documentation for [recentChapters],
@@ -56,42 +57,43 @@ object UpdateController {
*/
private class PagedMangaChapterListDataClass : PaginatedList<MangaChapterDataClass>(emptyList(), false)
val categoryUpdate = handler(
formParam<Int?>("categoryId"),
documentWith = {
withOperation {
summary("Updater start")
description("Starts the updater")
}
},
behaviorOf = { ctx, categoryId ->
val updater by DI.global.instance<IUpdater>()
if (categoryId == null) {
logger.info { "Adding Library to Update Queue" }
updater.addCategoriesToUpdateQueue(
Category.getCategoryList(),
clear = true,
forceAll = false
)
} else {
val category = Category.getCategoryById(categoryId)
if (category != null) {
val categoryUpdate =
handler(
formParam<Int?>("categoryId"),
documentWith = {
withOperation {
summary("Updater start")
description("Starts the updater")
}
},
behaviorOf = { ctx, categoryId ->
val updater by DI.global.instance<IUpdater>()
if (categoryId == null) {
logger.info { "Adding Library to Update Queue" }
updater.addCategoriesToUpdateQueue(
listOf(category),
Category.getCategoryList(),
clear = true,
forceAll = true
forceAll = false,
)
} else {
logger.info { "No Category found" }
ctx.status(HttpCode.BAD_REQUEST)
val category = Category.getCategoryById(categoryId)
if (category != null) {
updater.addCategoriesToUpdateQueue(
listOf(category),
clear = true,
forceAll = true,
)
} else {
logger.info { "No Category found" }
ctx.status(HttpCode.BAD_REQUEST)
}
}
}
},
withResults = {
httpCode(HttpCode.OK)
httpCode(HttpCode.BAD_REQUEST)
}
)
},
withResults = {
httpCode(HttpCode.OK)
httpCode(HttpCode.BAD_REQUEST)
},
)
fun categoryUpdateWS(ws: WsConfig) {
ws.onConnect { ctx ->
@@ -105,42 +107,44 @@ object UpdateController {
}
}
val updateSummary = handler(
documentWith = {
withOperation {
summary("Updater summary")
description("Gets the latest updater summary")
}
},
behaviorOf = { ctx ->
val updater by DI.global.instance<IUpdater>()
ctx.json(updater.status.value)
},
withResults = {
json<UpdateStatus>(HttpCode.OK)
}
)
val reset = handler(
documentWith = {
withOperation {
summary("Updater reset")
description("Stops and resets the Updater")
}
},
behaviorOf = { ctx ->
val updater by DI.global.instance<IUpdater>()
logger.info { "Resetting Updater" }
ctx.future(
future {
updater.reset()
}.thenApply {
ctx.status(HttpCode.OK)
val updateSummary =
handler(
documentWith = {
withOperation {
summary("Updater summary")
description("Gets the latest updater summary")
}
)
},
withResults = {
httpCode(HttpCode.OK)
}
)
},
behaviorOf = { ctx ->
val updater by DI.global.instance<IUpdater>()
ctx.json(updater.status.value)
},
withResults = {
json<UpdateStatus>(HttpCode.OK)
},
)
val reset =
handler(
documentWith = {
withOperation {
summary("Updater reset")
description("Stops and resets the Updater")
}
},
behaviorOf = { ctx ->
val updater by DI.global.instance<IUpdater>()
logger.info { "Resetting Updater" }
ctx.future(
future {
updater.reset()
}.thenApply {
ctx.status(HttpCode.OK)
},
)
},
withResults = {
httpCode(HttpCode.OK)
},
)
}

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

View File

@@ -9,8 +9,13 @@ import com.fasterxml.jackson.annotation.JsonValue
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
enum class IncludeInUpdate(@JsonValue val value: Int) {
EXCLUDE(0), INCLUDE(1), UNSET(-1);
enum class IncludeInUpdate(
@JsonValue val value: Int,
) {
EXCLUDE(0),
INCLUDE(1),
UNSET(-1),
;
companion object {
fun fromValue(value: Int) = IncludeInUpdate.values().find { it.value == value } ?: UNSET
@@ -24,5 +29,5 @@ data class CategoryDataClass(
val default: Boolean,
val size: Int,
val includeInUpdate: IncludeInUpdate,
val meta: Map<String, String> = emptyMap()
val meta: Map<String, String> = emptyMap(),
)

View File

@@ -15,38 +15,27 @@ data class ChapterDataClass(
val chapterNumber: Float,
val scanlator: String?,
val mangaId: Int,
/** chapter is read */
val read: Boolean,
/** chapter is bookmarked */
val bookmarked: Boolean,
/** last read page, zero means not read/no data */
val lastPageRead: Int,
/** last read page, zero means not read/no data */
val lastReadAt: Long,
// TODO(v0.6.0): rename to sourceOrder
/** this chapter's index, starts with 1 */
val index: Int,
/** the date we fist saw this chapter*/
val fetchedAt: Long,
/** the website url of this chapter*/
val realUrl: String? = null,
/** is chapter downloaded */
val downloaded: Boolean,
/** used to construct pages in the front-end */
val pageCount: Int = -1,
/** total chapter count, used to calculate if there's a next and prev chapter */
val chapterCount: Int? = null,
/** used to store client specific values */
val meta: Map<String, String> = emptyMap()
val meta: Map<String, String> = emptyMap(),
)

View File

@@ -10,15 +10,13 @@ package suwayomi.tachidesk.manga.model.dataclass
data class ExtensionDataClass(
val apkName: String,
val iconUrl: String,
val name: String,
val pkgName: String,
val versionName: String,
val versionCode: Int,
val lang: String,
val isNsfw: Boolean,
val installed: Boolean,
val hasUpdate: Boolean,
val obsolete: Boolean
val obsolete: Boolean,
)

View File

@@ -9,5 +9,5 @@ package suwayomi.tachidesk.manga.model.dataclass
data class MangaChapterDataClass(
val manga: MangaDataClass,
val chapter: ChapterDataClass
val chapter: ChapterDataClass,
)

View File

@@ -15,14 +15,11 @@ import java.time.Instant
data class MangaDataClass(
val id: Int,
val sourceId: String,
val url: String,
val title: String,
val thumbnailUrl: String? = null,
val thumbnailUrlLastFetched: Long = 0,
val initialized: Boolean = false,
val artist: String? = null,
val author: String? = null,
val description: String? = null,
@@ -31,30 +28,25 @@ data class MangaDataClass(
val inLibrary: Boolean = false,
val inLibraryAt: Long = 0,
val source: SourceDataClass? = null,
/** meta data for clients */
val meta: Map<String, String> = emptyMap(),
val realUrl: String? = null,
var lastFetchedAt: Long? = 0,
var chaptersLastFetchedAt: Long? = 0,
var updateStrategy: UpdateStrategy = UpdateStrategy.ALWAYS_UPDATE,
val freshData: Boolean = false,
var unreadCount: Long? = null,
var downloadCount: Long? = null,
var chapterCount: Long? = null,
var lastReadAt: Long? = null,
var lastChapterRead: ChapterDataClass? = null,
val age: Long? = if (lastFetchedAt == null) 0 else Instant.now().epochSecond.minus(lastFetchedAt),
val chaptersAge: Long? = if (chaptersLastFetchedAt == null) null else Instant.now().epochSecond.minus(chaptersLastFetchedAt)
val chaptersAge: Long? = if (chaptersLastFetchedAt == null) null else Instant.now().epochSecond.minus(chaptersLastFetchedAt),
)
data class PagedMangaListDataClass(
val mangaList: List<MangaDataClass>,
val hasNextPage: Boolean
val hasNextPage: Boolean,
)
internal fun String?.toGenreList() = this?.split(",")?.trimAll().orEmpty()

View File

@@ -9,5 +9,5 @@ package suwayomi.tachidesk.manga.model.dataclass
data class PageDataClass(
val index: Int,
var imageUrl: String
var imageUrl: String,
)

View File

@@ -11,15 +11,15 @@ import kotlin.math.min
open class PaginatedList<T>(
val page: List<T>,
val hasNextPage: Boolean
val hasNextPage: Boolean,
)
const val PaginationFactor = 50
const val PAGINATION_FACTOR = 50
fun <T> paginatedFrom(
pageNum: Int,
paginationFactor: Int = PaginationFactor,
lister: () -> List<T>
paginationFactor: Int = PAGINATION_FACTOR,
lister: () -> List<T>,
): PaginatedList<T> {
val list = lister()
val lastIndex = list.size - 1
@@ -35,6 +35,6 @@ fun <T> paginatedFrom(
return PaginatedList(
sliced,
higherIndex < lastIndex
higherIndex < lastIndex,
)
}

View File

@@ -14,16 +14,12 @@ data class SourceDataClass(
val name: String,
val lang: String,
val iconUrl: String,
/** The Source provides a latest listing */
val supportsLatest: Boolean,
/** The Source implements [ConfigurableSource] */
val isConfigurable: Boolean,
/** The Source class has a @Nsfw annotation */
val isNsfw: Boolean,
/** A nicer version of [name] */
val displayName: String
val displayName: String,
)

View File

@@ -20,12 +20,13 @@ object CategoryTable : IntIdTable() {
val includeInUpdate = integer("include_in_update").default(IncludeInUpdate.UNSET.value)
}
fun CategoryTable.toDataClass(categoryEntry: ResultRow) = CategoryDataClass(
categoryEntry[id].value,
categoryEntry[order],
categoryEntry[name],
categoryEntry[isDefault],
Category.getCategorySize(categoryEntry[id].value),
IncludeInUpdate.fromValue(categoryEntry[includeInUpdate]),
Category.getCategoryMetaMap(categoryEntry[id].value)
)
fun CategoryTable.toDataClass(categoryEntry: ResultRow) =
CategoryDataClass(
categoryEntry[id].value,
categoryEntry[order],
categoryEntry[name],
categoryEntry[isDefault],
Category.getCategorySize(categoryEntry[id].value),
IncludeInUpdate.fromValue(categoryEntry[includeInUpdate]),
Category.getCategoryMetaMap(categoryEntry[id].value),
)

View File

@@ -60,5 +60,5 @@ fun ChapterTable.toDataClass(chapterEntry: ResultRow) =
downloaded = chapterEntry[isDownloaded],
pageCount = chapterEntry[pageCount],
chapterCount = transaction { ChapterTable.select { manga eq chapterEntry[manga].value }.count().toInt() },
meta = getChapterMetaMap(chapterEntry[id])
meta = getChapterMetaMap(chapterEntry[id]),
)

View File

@@ -13,8 +13,12 @@ object ExtensionTable : IntIdTable() {
val apkName = varchar("apk_name", 1024)
// default is the local source icon from tachiyomi
val iconUrl = varchar("icon_url", 2048)
.default("https://raw.githubusercontent.com/tachiyomiorg/tachiyomi/64ba127e7d43b1d7e6d58a6f5c9b2bd5fe0543f7/app/src/main/res/mipmap-xxxhdpi/ic_local_source.webp")
@Suppress("ktlint:standard:max-line-length")
val iconUrl =
varchar("icon_url", 2048)
.default(
"https://raw.githubusercontent.com/tachiyomiorg/tachiyomi/64ba127e7d43b1d7e6d58a6f5c9b2bd5fe0543f7/app/src/main/res/mipmap-xxxhdpi/ic_local_source.webp",
)
val name = varchar("name", 128)
val pkgName = varchar("pkg_name", 128)

View File

@@ -50,14 +50,11 @@ fun MangaTable.toDataClass(mangaEntry: ResultRow) =
MangaDataClass(
id = mangaEntry[this.id].value,
sourceId = mangaEntry[sourceReference].toString(),
url = mangaEntry[url],
title = mangaEntry[title],
thumbnailUrl = proxyThumbnailUrl(mangaEntry[this.id].value),
thumbnailUrlLastFetched = mangaEntry[thumbnailUrlLastFetched],
initialized = mangaEntry[initialized],
artist = mangaEntry[artist],
author = mangaEntry[author],
description = mangaEntry[description],
@@ -69,7 +66,7 @@ fun MangaTable.toDataClass(mangaEntry: ResultRow) =
realUrl = mangaEntry[realUrl],
lastFetchedAt = mangaEntry[lastFetchedAt],
chaptersLastFetchedAt = mangaEntry[chaptersLastFetchedAt],
updateStrategy = UpdateStrategy.valueOf(mangaEntry[updateStrategy])
updateStrategy = UpdateStrategy.valueOf(mangaEntry[updateStrategy]),
)
enum class MangaStatus(val value: Int) {
@@ -79,7 +76,8 @@ enum class MangaStatus(val value: Int) {
LICENSED(3),
PUBLISHING_FINISHED(4),
CANCELLED(5),
ON_HIATUS(6);
ON_HIATUS(6),
;
companion object {
fun valueOf(value: Int): MangaStatus = values().find { it.value == value } ?: UNKNOWN