mirror of
https://github.com/gotson/komga.git
synced 2025-01-08 11:47:47 +08:00
feat(sse): publish server-sent events
This commit is contained in:
parent
b7c2c09ff4
commit
691c7f0071
@ -21,3 +21,8 @@ ERR_1014 | No match for book number within series
|
||||
ERR_1015 | Error while deserializing ComicRack ReadingList
|
||||
ERR_1016 | Directory not accessible or not a directory
|
||||
ERR_1017 | Cannot scan folder that is part of an existing library
|
||||
ERR_1018 | File not found
|
||||
ERR_1019 | Cannot import file that is part of an existing library
|
||||
ERR_1020 | Book to upgrade does not belong to provided series
|
||||
ERR_1021 | Destination file already exists
|
||||
ERR_1022 | Newly imported book could not be scanned
|
||||
|
@ -0,0 +1,26 @@
|
||||
package org.gotson.komga.application.events
|
||||
|
||||
import org.gotson.komga.domain.model.DomainEvent
|
||||
import org.gotson.komga.infrastructure.jms.QUEUE_SSE
|
||||
import org.gotson.komga.infrastructure.jms.QUEUE_SSE_TYPE
|
||||
import org.gotson.komga.infrastructure.jms.QUEUE_TYPE
|
||||
import org.springframework.jms.core.JmsTemplate
|
||||
import org.springframework.stereotype.Service
|
||||
import javax.jms.ConnectionFactory
|
||||
|
||||
@Service
|
||||
class EventPublisher(
|
||||
connectionFactory: ConnectionFactory,
|
||||
) {
|
||||
private val jmsTemplate = JmsTemplate(connectionFactory).apply {
|
||||
isPubSubDomain = true
|
||||
}
|
||||
|
||||
fun publishEvent(event: DomainEvent) {
|
||||
jmsTemplate.convertAndSend(QUEUE_SSE, event) {
|
||||
it.apply {
|
||||
setStringProperty(QUEUE_TYPE, QUEUE_SSE_TYPE)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -2,6 +2,7 @@ package org.gotson.komga.domain.model
|
||||
|
||||
import com.github.f4b6a3.tsid.TsidCreator
|
||||
import com.jakewharton.byteunits.BinaryByteUnit
|
||||
import java.io.Serializable
|
||||
import java.net.URL
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.Paths
|
||||
@ -20,8 +21,11 @@ data class Book(
|
||||
|
||||
override val createdDate: LocalDateTime = LocalDateTime.now(),
|
||||
override val lastModifiedDate: LocalDateTime = LocalDateTime.now()
|
||||
) : Auditable() {
|
||||
) : Auditable(), Serializable {
|
||||
|
||||
@delegate:Transient
|
||||
val path: Path by lazy { Paths.get(this.url.toURI()) }
|
||||
|
||||
@delegate:Transient
|
||||
val fileSizeHumanReadable: String by lazy { BinaryByteUnit.format(fileSize) }
|
||||
}
|
||||
|
@ -0,0 +1,34 @@
|
||||
package org.gotson.komga.domain.model
|
||||
|
||||
import java.io.Serializable
|
||||
import java.net.URL
|
||||
|
||||
sealed class DomainEvent : Serializable {
|
||||
|
||||
data class LibraryAdded(val library: Library) : DomainEvent()
|
||||
data class LibraryUpdated(val library: Library) : DomainEvent()
|
||||
data class LibraryDeleted(val library: Library) : DomainEvent()
|
||||
|
||||
data class SeriesAdded(val series: Series) : DomainEvent()
|
||||
data class SeriesUpdated(val series: Series) : DomainEvent()
|
||||
data class SeriesDeleted(val series: Series) : DomainEvent()
|
||||
|
||||
data class BookAdded(val book: Book) : DomainEvent()
|
||||
data class BookUpdated(val book: Book) : DomainEvent()
|
||||
data class BookDeleted(val book: Book) : DomainEvent()
|
||||
data class BookImported(val book: Book?, val sourceFile: URL, val success: Boolean, val message: String? = null) : DomainEvent()
|
||||
|
||||
data class CollectionAdded(val collection: SeriesCollection) : DomainEvent()
|
||||
data class CollectionUpdated(val collection: SeriesCollection) : DomainEvent()
|
||||
data class CollectionDeleted(val collection: SeriesCollection) : DomainEvent()
|
||||
|
||||
data class ReadListAdded(val readList: ReadList) : DomainEvent()
|
||||
data class ReadListUpdated(val readList: ReadList) : DomainEvent()
|
||||
data class ReadListDeleted(val readList: ReadList) : DomainEvent()
|
||||
|
||||
data class ReadProgressChanged(val progress: ReadProgress) : DomainEvent()
|
||||
data class ReadProgressDeleted(val progress: ReadProgress) : DomainEvent()
|
||||
|
||||
data class ThumbnailBookAdded(val thumbnail: ThumbnailBook) : DomainEvent()
|
||||
data class ThumbnailSeriesAdded(val thumbnail: ThumbnailSeries) : DomainEvent()
|
||||
}
|
@ -1,6 +1,18 @@
|
||||
package org.gotson.komga.domain.model
|
||||
|
||||
open class CodedException(message: String, val code: String) : Exception(message)
|
||||
open class CodedException : Exception {
|
||||
val code: String
|
||||
|
||||
constructor(cause: Throwable, code: String) : super(cause) {
|
||||
this.code = code
|
||||
}
|
||||
|
||||
constructor(message: String, code: String) : super(message) {
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
fun Exception.withCode(code: String) = CodedException(this, code)
|
||||
|
||||
class MediaNotReadyException : Exception()
|
||||
class MediaUnsupportedException(message: String, code: String = "") : CodedException(message, code)
|
||||
class ImageConversionException(message: String, code: String = "") : CodedException(message, code)
|
||||
|
@ -1,6 +1,7 @@
|
||||
package org.gotson.komga.domain.model
|
||||
|
||||
import com.github.f4b6a3.tsid.TsidCreator
|
||||
import java.io.Serializable
|
||||
import java.time.LocalDateTime
|
||||
import javax.validation.constraints.Email
|
||||
import javax.validation.constraints.NotBlank
|
||||
@ -24,7 +25,7 @@ data class KomgaUser(
|
||||
val id: String = TsidCreator.getTsid256().toString(),
|
||||
override val createdDate: LocalDateTime = LocalDateTime.now(),
|
||||
override val lastModifiedDate: LocalDateTime = LocalDateTime.now()
|
||||
) : Auditable() {
|
||||
) : Auditable(), Serializable {
|
||||
|
||||
fun roles(): Set<String> {
|
||||
val roles = mutableSetOf(ROLE_USER)
|
||||
|
@ -1,6 +1,7 @@
|
||||
package org.gotson.komga.domain.model
|
||||
|
||||
import com.github.f4b6a3.tsid.TsidCreator
|
||||
import java.io.Serializable
|
||||
import java.net.URL
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.Paths
|
||||
@ -26,7 +27,8 @@ data class Library(
|
||||
|
||||
override val createdDate: LocalDateTime = LocalDateTime.now(),
|
||||
override val lastModifiedDate: LocalDateTime = LocalDateTime.now()
|
||||
) : Auditable() {
|
||||
) : Auditable(), Serializable {
|
||||
|
||||
@delegate:Transient
|
||||
val path: Path by lazy { Paths.get(this.root.toURI()) }
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
package org.gotson.komga.domain.model
|
||||
|
||||
import com.github.f4b6a3.tsid.TsidCreator
|
||||
import java.io.Serializable
|
||||
import java.time.LocalDateTime
|
||||
import java.util.SortedMap
|
||||
|
||||
@ -18,4 +19,4 @@ data class ReadList(
|
||||
* Indicates that the bookIds have been filtered and is not exhaustive.
|
||||
*/
|
||||
val filtered: Boolean = false
|
||||
) : Auditable()
|
||||
) : Auditable(), Serializable
|
||||
|
@ -1,5 +1,6 @@
|
||||
package org.gotson.komga.domain.model
|
||||
|
||||
import java.io.Serializable
|
||||
import java.time.LocalDateTime
|
||||
|
||||
data class ReadProgress(
|
||||
@ -10,4 +11,4 @@ data class ReadProgress(
|
||||
|
||||
override val createdDate: LocalDateTime = LocalDateTime.now(),
|
||||
override val lastModifiedDate: LocalDateTime = LocalDateTime.now()
|
||||
) : Auditable()
|
||||
) : Auditable(), Serializable
|
||||
|
@ -1,6 +1,7 @@
|
||||
package org.gotson.komga.domain.model
|
||||
|
||||
import com.github.f4b6a3.tsid.TsidCreator
|
||||
import java.io.Serializable
|
||||
import java.net.URL
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.Paths
|
||||
@ -17,7 +18,8 @@ data class Series(
|
||||
|
||||
override val createdDate: LocalDateTime = LocalDateTime.now(),
|
||||
override val lastModifiedDate: LocalDateTime = LocalDateTime.now()
|
||||
) : Auditable() {
|
||||
) : Auditable(), Serializable {
|
||||
|
||||
@delegate:Transient
|
||||
val path: Path by lazy { Paths.get(this.url.toURI()) }
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
package org.gotson.komga.domain.model
|
||||
|
||||
import com.github.f4b6a3.tsid.TsidCreator
|
||||
import java.io.Serializable
|
||||
import java.time.LocalDateTime
|
||||
|
||||
data class SeriesCollection(
|
||||
@ -18,4 +19,4 @@ data class SeriesCollection(
|
||||
* Indicates that the seriesIds have been filtered and is not exhaustive.
|
||||
*/
|
||||
val filtered: Boolean = false
|
||||
) : Auditable()
|
||||
) : Auditable(), Serializable
|
||||
|
@ -1,6 +1,7 @@
|
||||
package org.gotson.komga.domain.model
|
||||
|
||||
import com.github.f4b6a3.tsid.TsidCreator
|
||||
import java.io.Serializable
|
||||
import java.net.URL
|
||||
import java.time.LocalDateTime
|
||||
|
||||
@ -15,7 +16,7 @@ data class ThumbnailBook(
|
||||
|
||||
override val createdDate: LocalDateTime = LocalDateTime.now(),
|
||||
override val lastModifiedDate: LocalDateTime = LocalDateTime.now()
|
||||
) : Auditable() {
|
||||
) : Auditable(), Serializable {
|
||||
enum class Type {
|
||||
GENERATED, SIDECAR
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
package org.gotson.komga.domain.model
|
||||
|
||||
import com.github.f4b6a3.tsid.TsidCreator
|
||||
import java.io.Serializable
|
||||
import java.net.URL
|
||||
import java.time.LocalDateTime
|
||||
|
||||
@ -13,4 +14,4 @@ data class ThumbnailSeries(
|
||||
|
||||
override val createdDate: LocalDateTime = LocalDateTime.now(),
|
||||
override val lastModifiedDate: LocalDateTime = LocalDateTime.now()
|
||||
) : Auditable()
|
||||
) : Auditable(), Serializable
|
||||
|
@ -13,10 +13,12 @@ interface BookRepository {
|
||||
|
||||
fun findAll(): Collection<Book>
|
||||
fun findAllBySeriesId(seriesId: String): Collection<Book>
|
||||
fun findAllBySeriesIds(seriesIds: Collection<String>): Collection<Book>
|
||||
fun findAll(bookSearch: BookSearch): Collection<Book>
|
||||
fun findAll(bookSearch: BookSearch, pageable: Pageable): Page<Book>
|
||||
|
||||
fun getLibraryIdOrNull(bookId: String): String?
|
||||
fun getSeriesIdOrNull(bookId: String): String?
|
||||
fun findFirstIdInSeriesOrNull(seriesId: String): String?
|
||||
|
||||
fun findAllIdsBySeriesId(seriesId: String): Collection<String>
|
||||
|
@ -8,6 +8,7 @@ interface ReadProgressRepository {
|
||||
fun findAll(): Collection<ReadProgress>
|
||||
fun findAllByUserId(userId: String): Collection<ReadProgress>
|
||||
fun findAllByBookId(bookId: String): Collection<ReadProgress>
|
||||
fun findAllByBookIdsAndUserId(bookIds: Collection<String>, userId: String): Collection<ReadProgress>
|
||||
|
||||
fun save(readProgress: ReadProgress)
|
||||
fun save(readProgresses: Collection<ReadProgress>)
|
||||
|
@ -1,11 +1,15 @@
|
||||
package org.gotson.komga.domain.service
|
||||
|
||||
import mu.KotlinLogging
|
||||
import org.gotson.komga.application.events.EventPublisher
|
||||
import org.gotson.komga.domain.model.Book
|
||||
import org.gotson.komga.domain.model.CodedException
|
||||
import org.gotson.komga.domain.model.CopyMode
|
||||
import org.gotson.komga.domain.model.DomainEvent
|
||||
import org.gotson.komga.domain.model.Media
|
||||
import org.gotson.komga.domain.model.PathContainedInPath
|
||||
import org.gotson.komga.domain.model.Series
|
||||
import org.gotson.komga.domain.model.withCode
|
||||
import org.gotson.komga.domain.persistence.BookMetadataRepository
|
||||
import org.gotson.komga.domain.persistence.BookRepository
|
||||
import org.gotson.komga.domain.persistence.LibraryRepository
|
||||
@ -41,111 +45,120 @@ class BookImporter(
|
||||
private val readProgressRepository: ReadProgressRepository,
|
||||
private val readListRepository: ReadListRepository,
|
||||
private val libraryRepository: LibraryRepository,
|
||||
private val eventPublisher: EventPublisher,
|
||||
) {
|
||||
|
||||
fun importBook(sourceFile: Path, series: Series, copyMode: CopyMode, destinationName: String? = null, upgradeBookId: String? = null): Book {
|
||||
if (sourceFile.notExists()) throw FileNotFoundException("File not found: $sourceFile")
|
||||
try {
|
||||
if (sourceFile.notExists()) throw FileNotFoundException("File not found: $sourceFile").withCode("ERR_1018")
|
||||
|
||||
libraryRepository.findAll().forEach { library ->
|
||||
if (sourceFile.startsWith(library.path)) throw PathContainedInPath("Cannot import file that is part of an existing library")
|
||||
}
|
||||
libraryRepository.findAll().forEach { library ->
|
||||
if (sourceFile.startsWith(library.path)) throw PathContainedInPath("Cannot import file that is part of an existing library", "ERR_1019")
|
||||
}
|
||||
|
||||
val destFile = series.path.resolve(
|
||||
if (destinationName != null) Paths.get("$destinationName.${sourceFile.extension}").fileName.toString()
|
||||
else sourceFile.fileName.toString()
|
||||
)
|
||||
val destFile = series.path.resolve(
|
||||
if (destinationName != null) Paths.get("$destinationName.${sourceFile.extension}").fileName.toString()
|
||||
else sourceFile.fileName.toString()
|
||||
)
|
||||
|
||||
val upgradedBookId =
|
||||
if (upgradeBookId != null) {
|
||||
bookRepository.findByIdOrNull(upgradeBookId)?.let {
|
||||
if (it.seriesId != series.id) throw IllegalArgumentException("Book to upgrade ($upgradeBookId) does not belong to series: $series")
|
||||
it.id
|
||||
val upgradedBook =
|
||||
if (upgradeBookId != null) {
|
||||
bookRepository.findByIdOrNull(upgradeBookId)?.let {
|
||||
if (it.seriesId != series.id) throw IllegalArgumentException("Book to upgrade ($upgradeBookId) does not belong to series: $series").withCode("ERR_1020")
|
||||
it
|
||||
}
|
||||
} else null
|
||||
val upgradedBookPath =
|
||||
if (upgradedBook != null)
|
||||
bookRepository.findByIdOrNull(upgradedBook.id)?.path
|
||||
else null
|
||||
|
||||
var deletedUpgradedFile = false
|
||||
when {
|
||||
upgradedBookPath != null && destFile == upgradedBookPath -> {
|
||||
logger.info { "Deleting existing file: $upgradedBookPath" }
|
||||
try {
|
||||
upgradedBookPath.deleteExisting()
|
||||
deletedUpgradedFile = true
|
||||
} catch (e: NoSuchFileException) {
|
||||
logger.warn { "Could not delete upgraded book: $upgradedBookPath" }
|
||||
}
|
||||
}
|
||||
} else null
|
||||
val upgradedBookPath =
|
||||
if (upgradedBookId != null)
|
||||
bookRepository.findByIdOrNull(upgradedBookId)?.path
|
||||
else null
|
||||
destFile.exists() -> throw FileAlreadyExistsException("Destination file already exists: $destFile").withCode("ERR_1021")
|
||||
}
|
||||
|
||||
var deletedUpgradedFile = false
|
||||
when {
|
||||
upgradedBookPath != null && destFile == upgradedBookPath -> {
|
||||
logger.info { "Deleting existing file: $upgradedBookPath" }
|
||||
try {
|
||||
upgradedBookPath.deleteExisting()
|
||||
deletedUpgradedFile = true
|
||||
} catch (e: NoSuchFileException) {
|
||||
logger.warn { "Could not delete upgraded book: $upgradedBookPath" }
|
||||
when (copyMode) {
|
||||
CopyMode.MOVE -> {
|
||||
logger.info { "Moving file $sourceFile to $destFile" }
|
||||
sourceFile.moveTo(destFile)
|
||||
}
|
||||
CopyMode.COPY -> {
|
||||
logger.info { "Copying file $sourceFile to $destFile" }
|
||||
sourceFile.copyTo(destFile)
|
||||
}
|
||||
CopyMode.HARDLINK -> try {
|
||||
logger.info { "Hardlink file $sourceFile to $destFile" }
|
||||
Files.createLink(destFile, sourceFile)
|
||||
} catch (e: Exception) {
|
||||
logger.warn(e) { "Filesystem does not support hardlinks, copying instead" }
|
||||
sourceFile.copyTo(destFile)
|
||||
}
|
||||
}
|
||||
destFile.exists() -> throw FileAlreadyExistsException("Destination file already exists: $destFile")
|
||||
}
|
||||
|
||||
when (copyMode) {
|
||||
CopyMode.MOVE -> {
|
||||
logger.info { "Moving file $sourceFile to $destFile" }
|
||||
sourceFile.moveTo(destFile)
|
||||
}
|
||||
CopyMode.COPY -> {
|
||||
logger.info { "Copying file $sourceFile to $destFile" }
|
||||
sourceFile.copyTo(destFile)
|
||||
}
|
||||
CopyMode.HARDLINK -> try {
|
||||
logger.info { "Hardlink file $sourceFile to $destFile" }
|
||||
Files.createLink(destFile, sourceFile)
|
||||
} catch (e: Exception) {
|
||||
logger.warn(e) { "Filesystem does not support hardlinks, copying instead" }
|
||||
sourceFile.copyTo(destFile)
|
||||
}
|
||||
}
|
||||
val importedBook = fileSystemScanner.scanFile(destFile)
|
||||
?.copy(libraryId = series.libraryId)
|
||||
?: throw IllegalStateException("Newly imported book could not be scanned: $destFile").withCode("ERR_1022")
|
||||
|
||||
val importedBook = fileSystemScanner.scanFile(destFile)
|
||||
?.copy(libraryId = series.libraryId)
|
||||
?: throw IllegalStateException("Newly imported book could not be scanned: $destFile")
|
||||
seriesLifecycle.addBooks(series, listOf(importedBook))
|
||||
|
||||
seriesLifecycle.addBooks(series, listOf(importedBook))
|
||||
|
||||
if (upgradedBookId != null) {
|
||||
// copy media and mark it as outdated
|
||||
mediaRepository.findById(upgradedBookId).let {
|
||||
mediaRepository.update(
|
||||
it.copy(
|
||||
bookId = importedBook.id,
|
||||
status = Media.Status.OUTDATED,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// copy metadata
|
||||
metadataRepository.findById(upgradedBookId).let {
|
||||
metadataRepository.update(it.copy(bookId = importedBook.id))
|
||||
}
|
||||
|
||||
// copy read progress
|
||||
readProgressRepository.findAllByBookId(upgradedBookId)
|
||||
.map { it.copy(bookId = importedBook.id) }
|
||||
.forEach { readProgressRepository.save(it) }
|
||||
|
||||
// replace upgraded book by imported book in read lists
|
||||
readListRepository.findAllContainingBookId(upgradedBookId, filterOnLibraryIds = null)
|
||||
.forEach { rl ->
|
||||
readListRepository.update(
|
||||
rl.copy(
|
||||
bookIds = rl.bookIds.values.map { if (it == upgradedBookId) importedBook.id else it }.toIndexedMap()
|
||||
if (upgradedBook != null) {
|
||||
// copy media and mark it as outdated
|
||||
mediaRepository.findById(upgradedBook.id).let {
|
||||
mediaRepository.update(
|
||||
it.copy(
|
||||
bookId = importedBook.id,
|
||||
status = Media.Status.OUTDATED,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// delete upgraded book file on disk if it has not been replaced earlier
|
||||
if (upgradedBookPath != null && !deletedUpgradedFile && upgradedBookPath.deleteIfExists())
|
||||
logger.info { "Deleted existing file: $upgradedBookPath" }
|
||||
// copy metadata
|
||||
metadataRepository.findById(upgradedBook.id).let {
|
||||
metadataRepository.update(it.copy(bookId = importedBook.id))
|
||||
}
|
||||
|
||||
// delete upgraded book
|
||||
bookLifecycle.deleteOne(upgradedBookId)
|
||||
// copy read progress
|
||||
readProgressRepository.findAllByBookId(upgradedBook.id)
|
||||
.map { it.copy(bookId = importedBook.id) }
|
||||
.forEach { readProgressRepository.save(it) }
|
||||
|
||||
// replace upgraded book by imported book in read lists
|
||||
readListRepository.findAllContainingBookId(upgradedBook.id, filterOnLibraryIds = null)
|
||||
.forEach { rl ->
|
||||
readListRepository.update(
|
||||
rl.copy(
|
||||
bookIds = rl.bookIds.values.map { if (it == upgradedBook.id) importedBook.id else it }.toIndexedMap()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// delete upgraded book file on disk if it has not been replaced earlier
|
||||
if (upgradedBookPath != null && !deletedUpgradedFile && upgradedBookPath.deleteIfExists())
|
||||
logger.info { "Deleted existing file: $upgradedBookPath" }
|
||||
|
||||
// delete upgraded book
|
||||
bookLifecycle.deleteOne(upgradedBook)
|
||||
}
|
||||
|
||||
seriesLifecycle.sortBooks(series)
|
||||
|
||||
eventPublisher.publishEvent(DomainEvent.BookImported(importedBook, sourceFile.toUri().toURL(), success = true))
|
||||
|
||||
return importedBook
|
||||
} catch (e: Exception) {
|
||||
val msg = if (e is CodedException) e.code else e.message
|
||||
eventPublisher.publishEvent(DomainEvent.BookImported(null, sourceFile.toUri().toURL(), success = false, msg))
|
||||
throw e
|
||||
}
|
||||
|
||||
seriesLifecycle.sortBooks(series)
|
||||
|
||||
return importedBook
|
||||
}
|
||||
}
|
||||
|
@ -1,9 +1,11 @@
|
||||
package org.gotson.komga.domain.service
|
||||
|
||||
import mu.KotlinLogging
|
||||
import org.gotson.komga.application.events.EventPublisher
|
||||
import org.gotson.komga.domain.model.Book
|
||||
import org.gotson.komga.domain.model.BookPageContent
|
||||
import org.gotson.komga.domain.model.BookWithMedia
|
||||
import org.gotson.komga.domain.model.DomainEvent
|
||||
import org.gotson.komga.domain.model.ImageConversionException
|
||||
import org.gotson.komga.domain.model.KomgaUser
|
||||
import org.gotson.komga.domain.model.Media
|
||||
@ -34,7 +36,8 @@ class BookLifecycle(
|
||||
private val thumbnailBookRepository: ThumbnailBookRepository,
|
||||
private val readListRepository: ReadListRepository,
|
||||
private val bookAnalyzer: BookAnalyzer,
|
||||
private val imageConverter: ImageConverter
|
||||
private val imageConverter: ImageConverter,
|
||||
private val eventPublisher: EventPublisher,
|
||||
) {
|
||||
|
||||
fun analyzeAndPersist(book: Book): Boolean {
|
||||
@ -49,6 +52,9 @@ class BookLifecycle(
|
||||
}
|
||||
|
||||
mediaRepository.update(media)
|
||||
|
||||
eventPublisher.publishEvent(DomainEvent.BookUpdated(book))
|
||||
|
||||
return media.status == Media.Status.READY
|
||||
}
|
||||
|
||||
@ -79,6 +85,8 @@ class BookLifecycle(
|
||||
}
|
||||
}
|
||||
|
||||
eventPublisher.publishEvent(DomainEvent.ThumbnailBookAdded(thumbnail))
|
||||
|
||||
if (thumbnail.selected)
|
||||
thumbnailBookRepository.markSelected(thumbnail)
|
||||
else
|
||||
@ -187,21 +195,24 @@ class BookLifecycle(
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteOne(bookId: String) {
|
||||
logger.info { "Delete book id: $bookId" }
|
||||
fun deleteOne(book: Book) {
|
||||
logger.info { "Delete book id: ${book.id}" }
|
||||
|
||||
readProgressRepository.deleteByBookId(bookId)
|
||||
readListRepository.removeBookFromAll(bookId)
|
||||
readProgressRepository.deleteByBookId(book.id)
|
||||
readListRepository.removeBookFromAll(book.id)
|
||||
|
||||
mediaRepository.delete(bookId)
|
||||
thumbnailBookRepository.deleteByBookId(bookId)
|
||||
bookMetadataRepository.delete(bookId)
|
||||
mediaRepository.delete(book.id)
|
||||
thumbnailBookRepository.deleteByBookId(book.id)
|
||||
bookMetadataRepository.delete(book.id)
|
||||
|
||||
bookRepository.delete(bookId)
|
||||
bookRepository.delete(book.id)
|
||||
|
||||
eventPublisher.publishEvent(DomainEvent.BookDeleted(book))
|
||||
}
|
||||
|
||||
fun deleteMany(bookIds: Collection<String>) {
|
||||
logger.info { "Delete all books: $bookIds" }
|
||||
fun deleteMany(books: Collection<Book>) {
|
||||
val bookIds = books.map { it.id }
|
||||
logger.info { "Delete book ids: $bookIds" }
|
||||
|
||||
readProgressRepository.deleteByBookIds(bookIds)
|
||||
readListRepository.removeBooksFromAll(bookIds)
|
||||
@ -211,22 +222,31 @@ class BookLifecycle(
|
||||
bookMetadataRepository.delete(bookIds)
|
||||
|
||||
bookRepository.delete(bookIds)
|
||||
|
||||
books.forEach { eventPublisher.publishEvent(DomainEvent.BookDeleted(it)) }
|
||||
}
|
||||
|
||||
fun markReadProgress(book: Book, user: KomgaUser, page: Int) {
|
||||
val pages = mediaRepository.getPagesSize(book.id)
|
||||
require(page in 1..pages) { "Page argument ($page) must be within 1 and book page count ($pages)" }
|
||||
|
||||
readProgressRepository.save(ReadProgress(book.id, user.id, page, page == pages))
|
||||
val progress = ReadProgress(book.id, user.id, page, page == pages)
|
||||
readProgressRepository.save(progress)
|
||||
eventPublisher.publishEvent(DomainEvent.ReadProgressChanged(progress))
|
||||
}
|
||||
|
||||
fun markReadProgressCompleted(bookId: String, user: KomgaUser) {
|
||||
val media = mediaRepository.findById(bookId)
|
||||
|
||||
readProgressRepository.save(ReadProgress(bookId, user.id, media.pages.size, true))
|
||||
val progress = ReadProgress(bookId, user.id, media.pages.size, true)
|
||||
readProgressRepository.save(progress)
|
||||
eventPublisher.publishEvent(DomainEvent.ReadProgressChanged(progress))
|
||||
}
|
||||
|
||||
fun deleteReadProgress(bookId: String, user: KomgaUser) {
|
||||
readProgressRepository.delete(bookId, user.id)
|
||||
fun deleteReadProgress(book: Book, user: KomgaUser) {
|
||||
readProgressRepository.findByBookIdAndUserIdOrNull(book.id, user.id)?.let { progress ->
|
||||
readProgressRepository.delete(book.id, user.id)
|
||||
eventPublisher.publishEvent(DomainEvent.ReadProgressDeleted(progress))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -48,14 +48,14 @@ class LibraryContentLifecycle(
|
||||
// delete series that don't exist anymore
|
||||
if (scannedSeries.isEmpty()) {
|
||||
logger.info { "Scan returned no series, deleting all existing series" }
|
||||
val seriesIds = seriesRepository.findAllByLibraryId(library.id).map { it.id }
|
||||
seriesLifecycle.deleteMany(seriesIds)
|
||||
val series = seriesRepository.findAllByLibraryId(library.id)
|
||||
seriesLifecycle.deleteMany(series)
|
||||
} else {
|
||||
scannedSeries.keys.map { it.url }.let { urls ->
|
||||
val series = seriesRepository.findAllByLibraryIdAndUrlNotIn(library.id, urls)
|
||||
if (series.isNotEmpty()) {
|
||||
logger.info { "Deleting series not on disk anymore: $series" }
|
||||
seriesLifecycle.deleteMany(series.map { it.id })
|
||||
seriesLifecycle.deleteMany(series)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -106,7 +106,7 @@ class LibraryContentLifecycle(
|
||||
.filterNot { existingBook -> newBooksUrls.contains(existingBook.url) }
|
||||
.let { books ->
|
||||
logger.info { "Deleting books not on disk anymore: $books" }
|
||||
bookLifecycle.deleteMany(books.map { it.id })
|
||||
bookLifecycle.deleteMany(books)
|
||||
books.map { it.seriesId }.distinct().forEach { taskReceiver.refreshSeriesMetadata(it) }
|
||||
}
|
||||
|
||||
|
@ -1,8 +1,10 @@
|
||||
package org.gotson.komga.domain.service
|
||||
|
||||
import mu.KotlinLogging
|
||||
import org.gotson.komga.application.events.EventPublisher
|
||||
import org.gotson.komga.application.tasks.TaskReceiver
|
||||
import org.gotson.komga.domain.model.DirectoryNotFoundException
|
||||
import org.gotson.komga.domain.model.DomainEvent
|
||||
import org.gotson.komga.domain.model.DuplicateNameException
|
||||
import org.gotson.komga.domain.model.Library
|
||||
import org.gotson.komga.domain.model.PathContainedInPath
|
||||
@ -21,7 +23,8 @@ class LibraryLifecycle(
|
||||
private val seriesLifecycle: SeriesLifecycle,
|
||||
private val seriesRepository: SeriesRepository,
|
||||
private val sidecarRepository: SidecarRepository,
|
||||
private val taskReceiver: TaskReceiver
|
||||
private val taskReceiver: TaskReceiver,
|
||||
private val eventPublisher: EventPublisher,
|
||||
) {
|
||||
|
||||
@Throws(
|
||||
@ -39,6 +42,8 @@ class LibraryLifecycle(
|
||||
libraryRepository.insert(library)
|
||||
taskReceiver.scanLibrary(library.id)
|
||||
|
||||
eventPublisher.publishEvent(DomainEvent.LibraryAdded(library))
|
||||
|
||||
return libraryRepository.findById(library.id)
|
||||
}
|
||||
|
||||
@ -50,6 +55,8 @@ class LibraryLifecycle(
|
||||
|
||||
libraryRepository.update(toUpdate)
|
||||
taskReceiver.scanLibrary(toUpdate.id)
|
||||
|
||||
eventPublisher.publishEvent(DomainEvent.LibraryUpdated(toUpdate))
|
||||
}
|
||||
|
||||
private fun checkLibraryValidity(library: Library, existing: Collection<Library>) {
|
||||
@ -73,10 +80,12 @@ class LibraryLifecycle(
|
||||
fun deleteLibrary(library: Library) {
|
||||
logger.info { "Deleting library: $library" }
|
||||
|
||||
val seriesIds = seriesRepository.findAllByLibraryId(library.id).map { it.id }
|
||||
seriesLifecycle.deleteMany(seriesIds)
|
||||
val series = seriesRepository.findAllByLibraryId(library.id)
|
||||
seriesLifecycle.deleteMany(series)
|
||||
sidecarRepository.deleteByLibraryId(library.id)
|
||||
|
||||
libraryRepository.delete(library.id)
|
||||
|
||||
eventPublisher.publishEvent(DomainEvent.LibraryDeleted(library))
|
||||
}
|
||||
}
|
||||
|
@ -1,10 +1,12 @@
|
||||
package org.gotson.komga.domain.service
|
||||
|
||||
import mu.KotlinLogging
|
||||
import org.gotson.komga.application.events.EventPublisher
|
||||
import org.gotson.komga.domain.model.Book
|
||||
import org.gotson.komga.domain.model.BookMetadataPatch
|
||||
import org.gotson.komga.domain.model.BookMetadataPatchCapability
|
||||
import org.gotson.komga.domain.model.BookWithMedia
|
||||
import org.gotson.komga.domain.model.DomainEvent
|
||||
import org.gotson.komga.domain.model.ReadList
|
||||
import org.gotson.komga.domain.model.Series
|
||||
import org.gotson.komga.domain.model.SeriesCollection
|
||||
@ -42,6 +44,7 @@ class MetadataLifecycle(
|
||||
private val collectionLifecycle: SeriesCollectionLifecycle,
|
||||
private val readListRepository: ReadListRepository,
|
||||
private val readListLifecycle: ReadListLifecycle,
|
||||
private val eventPublisher: EventPublisher,
|
||||
) {
|
||||
|
||||
fun refreshMetadata(book: Book, capabilities: List<BookMetadataPatchCapability>) {
|
||||
@ -49,6 +52,7 @@ class MetadataLifecycle(
|
||||
val media = mediaRepository.findById(book.id)
|
||||
|
||||
val library = libraryRepository.findById(book.libraryId)
|
||||
var changed = false
|
||||
|
||||
bookMetadataProviders.forEach { provider ->
|
||||
when {
|
||||
@ -70,6 +74,7 @@ class MetadataLifecycle(
|
||||
(provider is IsbnBarcodeProvider && library.importBarcodeIsbn)
|
||||
) {
|
||||
handlePatchForBookMetadata(patch, book)
|
||||
changed = true
|
||||
}
|
||||
|
||||
if (provider is ComicInfoProvider && library.importComicInfoReadList) {
|
||||
@ -78,6 +83,8 @@ class MetadataLifecycle(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) eventPublisher.publishEvent(DomainEvent.BookUpdated(book))
|
||||
}
|
||||
|
||||
private fun handlePatchForReadLists(
|
||||
@ -138,6 +145,7 @@ class MetadataLifecycle(
|
||||
logger.info { "Refresh metadata for series: $series" }
|
||||
|
||||
val library = libraryRepository.findById(series.libraryId)
|
||||
var changed = false
|
||||
|
||||
seriesMetadataProviders.forEach { provider ->
|
||||
when {
|
||||
@ -153,6 +161,7 @@ class MetadataLifecycle(
|
||||
(provider is EpubMetadataProvider && library.importEpubSeries)
|
||||
) {
|
||||
handlePatchForSeriesMetadata(patches, series)
|
||||
changed = true
|
||||
}
|
||||
|
||||
if (provider is ComicInfoProvider && library.importComicInfoCollection) {
|
||||
@ -161,6 +170,8 @@ class MetadataLifecycle(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) eventPublisher.publishEvent(DomainEvent.SeriesUpdated(series))
|
||||
}
|
||||
|
||||
private fun handlePatchForCollections(
|
||||
@ -226,6 +237,8 @@ class MetadataLifecycle(
|
||||
val aggregation = metadataAggregator.aggregate(metadatas).copy(seriesId = series.id)
|
||||
|
||||
bookMetadataAggregationRepository.update(aggregation)
|
||||
|
||||
eventPublisher.publishEvent(DomainEvent.SeriesUpdated(series))
|
||||
}
|
||||
|
||||
private fun <T, R : Any> Iterable<T>.mostFrequent(transform: (T) -> R?): R? {
|
||||
|
@ -1,6 +1,8 @@
|
||||
package org.gotson.komga.domain.service
|
||||
|
||||
import mu.KotlinLogging
|
||||
import org.gotson.komga.application.events.EventPublisher
|
||||
import org.gotson.komga.domain.model.DomainEvent
|
||||
import org.gotson.komga.domain.model.DuplicateNameException
|
||||
import org.gotson.komga.domain.model.ReadList
|
||||
import org.gotson.komga.domain.model.ReadListRequestResult
|
||||
@ -18,6 +20,7 @@ class ReadListLifecycle(
|
||||
private val mosaicGenerator: MosaicGenerator,
|
||||
private val readListMatcher: ReadListMatcher,
|
||||
private val readListProvider: ReadListProvider,
|
||||
private val eventPublisher: EventPublisher,
|
||||
) {
|
||||
|
||||
@Throws(
|
||||
@ -31,6 +34,8 @@ class ReadListLifecycle(
|
||||
|
||||
readListRepository.insert(readList)
|
||||
|
||||
eventPublisher.publishEvent(DomainEvent.ReadListAdded(readList))
|
||||
|
||||
return readListRepository.findByIdOrNull(readList.id)!!
|
||||
}
|
||||
|
||||
@ -43,10 +48,14 @@ class ReadListLifecycle(
|
||||
throw DuplicateNameException("Read list name already exists")
|
||||
|
||||
readListRepository.update(toUpdate)
|
||||
|
||||
eventPublisher.publishEvent(DomainEvent.ReadListUpdated(toUpdate))
|
||||
}
|
||||
|
||||
fun deleteReadList(readListId: String) {
|
||||
readListRepository.delete(readListId)
|
||||
fun deleteReadList(readList: ReadList) {
|
||||
readListRepository.delete(readList.id)
|
||||
|
||||
eventPublisher.publishEvent(DomainEvent.ReadListDeleted(readList))
|
||||
}
|
||||
|
||||
fun getThumbnailBytes(readList: ReadList): ByteArray {
|
||||
|
@ -1,6 +1,8 @@
|
||||
package org.gotson.komga.domain.service
|
||||
|
||||
import mu.KotlinLogging
|
||||
import org.gotson.komga.application.events.EventPublisher
|
||||
import org.gotson.komga.domain.model.DomainEvent
|
||||
import org.gotson.komga.domain.model.DuplicateNameException
|
||||
import org.gotson.komga.domain.model.SeriesCollection
|
||||
import org.gotson.komga.domain.persistence.SeriesCollectionRepository
|
||||
@ -13,7 +15,8 @@ private val logger = KotlinLogging.logger {}
|
||||
class SeriesCollectionLifecycle(
|
||||
private val collectionRepository: SeriesCollectionRepository,
|
||||
private val seriesLifecycle: SeriesLifecycle,
|
||||
private val mosaicGenerator: MosaicGenerator
|
||||
private val mosaicGenerator: MosaicGenerator,
|
||||
private val eventPublisher: EventPublisher,
|
||||
) {
|
||||
|
||||
@Throws(
|
||||
@ -27,6 +30,8 @@ class SeriesCollectionLifecycle(
|
||||
|
||||
collectionRepository.insert(collection)
|
||||
|
||||
eventPublisher.publishEvent(DomainEvent.CollectionAdded(collection))
|
||||
|
||||
return collectionRepository.findByIdOrNull(collection.id)!!
|
||||
}
|
||||
|
||||
@ -40,10 +45,13 @@ class SeriesCollectionLifecycle(
|
||||
throw DuplicateNameException("Collection name already exists")
|
||||
|
||||
collectionRepository.update(toUpdate)
|
||||
|
||||
eventPublisher.publishEvent(DomainEvent.CollectionUpdated(toUpdate))
|
||||
}
|
||||
|
||||
fun deleteCollection(collectionId: String) {
|
||||
collectionRepository.delete(collectionId)
|
||||
fun deleteCollection(collection: SeriesCollection) {
|
||||
collectionRepository.delete(collection.id)
|
||||
eventPublisher.publishEvent(DomainEvent.CollectionDeleted(collection))
|
||||
}
|
||||
|
||||
fun getThumbnailBytes(collection: SeriesCollection): ByteArray {
|
||||
|
@ -3,11 +3,13 @@ package org.gotson.komga.domain.service
|
||||
import mu.KotlinLogging
|
||||
import net.greypanther.natsort.CaseInsensitiveSimpleNaturalComparator
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.gotson.komga.application.events.EventPublisher
|
||||
import org.gotson.komga.application.tasks.TaskReceiver
|
||||
import org.gotson.komga.domain.model.Book
|
||||
import org.gotson.komga.domain.model.BookMetadata
|
||||
import org.gotson.komga.domain.model.BookMetadataAggregation
|
||||
import org.gotson.komga.domain.model.BookMetadataPatchCapability
|
||||
import org.gotson.komga.domain.model.DomainEvent
|
||||
import org.gotson.komga.domain.model.KomgaUser
|
||||
import org.gotson.komga.domain.model.Media
|
||||
import org.gotson.komga.domain.model.ReadProgress
|
||||
@ -43,7 +45,8 @@ class SeriesLifecycle(
|
||||
private val bookMetadataAggregationRepository: BookMetadataAggregationRepository,
|
||||
private val collectionRepository: SeriesCollectionRepository,
|
||||
private val readProgressRepository: ReadProgressRepository,
|
||||
private val taskReceiver: TaskReceiver
|
||||
private val taskReceiver: TaskReceiver,
|
||||
private val eventPublisher: EventPublisher,
|
||||
) {
|
||||
|
||||
fun sortBooks(series: Series) {
|
||||
@ -90,16 +93,15 @@ class SeriesLifecycle(
|
||||
booksToAdd.forEach {
|
||||
check(it.libraryId == series.libraryId) { "Cannot add book to series if they don't share the same libraryId" }
|
||||
}
|
||||
val toAdd = booksToAdd.map { it.copy(seriesId = series.id) }
|
||||
|
||||
bookRepository.insert(
|
||||
booksToAdd.map { it.copy(seriesId = series.id) }
|
||||
)
|
||||
bookRepository.insert(toAdd)
|
||||
|
||||
// create associated media
|
||||
mediaRepository.insert(booksToAdd.map { Media(bookId = it.id) })
|
||||
mediaRepository.insert(toAdd.map { Media(bookId = it.id) })
|
||||
|
||||
// create associated metadata
|
||||
booksToAdd.map {
|
||||
toAdd.map {
|
||||
BookMetadata(
|
||||
title = it.name,
|
||||
number = it.number.toString(),
|
||||
@ -107,6 +109,8 @@ class SeriesLifecycle(
|
||||
bookId = it.id
|
||||
)
|
||||
}.let { bookMetadataRepository.insert(it) }
|
||||
|
||||
toAdd.forEach { eventPublisher.publishEvent(DomainEvent.BookAdded(it)) }
|
||||
}
|
||||
|
||||
fun createSeries(series: Series): Series {
|
||||
@ -124,28 +128,17 @@ class SeriesLifecycle(
|
||||
BookMetadataAggregation(seriesId = series.id)
|
||||
)
|
||||
|
||||
eventPublisher.publishEvent(DomainEvent.SeriesAdded(series))
|
||||
|
||||
return seriesRepository.findByIdOrNull(series.id)!!
|
||||
}
|
||||
|
||||
fun deleteOne(seriesId: String) {
|
||||
logger.info { "Delete series id: $seriesId" }
|
||||
|
||||
val bookIds = bookRepository.findAllIdsBySeriesId(seriesId)
|
||||
bookLifecycle.deleteMany(bookIds)
|
||||
|
||||
collectionRepository.removeSeriesFromAll(seriesId)
|
||||
thumbnailsSeriesRepository.deleteBySeriesId(seriesId)
|
||||
seriesMetadataRepository.delete(seriesId)
|
||||
bookMetadataAggregationRepository.delete(seriesId)
|
||||
|
||||
seriesRepository.delete(seriesId)
|
||||
}
|
||||
|
||||
fun deleteMany(seriesIds: Collection<String>) {
|
||||
fun deleteMany(series: Collection<Series>) {
|
||||
val seriesIds = series.map { it.id }
|
||||
logger.info { "Delete series ids: $seriesIds" }
|
||||
|
||||
val bookIds = bookRepository.findAllIdsBySeriesIds(seriesIds)
|
||||
bookLifecycle.deleteMany(bookIds)
|
||||
val books = bookRepository.findAllBySeriesIds(seriesIds)
|
||||
bookLifecycle.deleteMany(books)
|
||||
|
||||
collectionRepository.removeSeriesFromAll(seriesIds)
|
||||
thumbnailsSeriesRepository.deleteBySeriesIds(seriesIds)
|
||||
@ -153,6 +146,8 @@ class SeriesLifecycle(
|
||||
bookMetadataAggregationRepository.delete(seriesIds)
|
||||
|
||||
seriesRepository.delete(seriesIds)
|
||||
|
||||
series.forEach { eventPublisher.publishEvent(DomainEvent.SeriesDeleted(it)) }
|
||||
}
|
||||
|
||||
fun markReadProgressCompleted(seriesId: String, user: KomgaUser) {
|
||||
@ -160,10 +155,15 @@ class SeriesLifecycle(
|
||||
.map { (bookId, pageSize) -> ReadProgress(bookId, user.id, pageSize, true) }
|
||||
|
||||
readProgressRepository.save(progresses)
|
||||
progresses.forEach { eventPublisher.publishEvent(DomainEvent.ReadProgressChanged(it)) }
|
||||
}
|
||||
|
||||
fun deleteReadProgress(seriesId: String, user: KomgaUser) {
|
||||
readProgressRepository.deleteByBookIdsAndUserId(bookRepository.findAllIdsBySeriesId(seriesId), user.id)
|
||||
val bookIds = bookRepository.findAllIdsBySeriesId(seriesId)
|
||||
val progresses = readProgressRepository.findAllByBookIdsAndUserId(bookIds, user.id)
|
||||
readProgressRepository.deleteByBookIdsAndUserId(bookIds, user.id)
|
||||
|
||||
progresses.forEach { eventPublisher.publishEvent(DomainEvent.ReadProgressDeleted(it)) }
|
||||
}
|
||||
|
||||
fun getThumbnail(seriesId: String): ThumbnailSeries? {
|
||||
@ -197,6 +197,8 @@ class SeriesLifecycle(
|
||||
}
|
||||
thumbnailsSeriesRepository.insert(thumbnail)
|
||||
|
||||
eventPublisher.publishEvent(DomainEvent.ThumbnailSeriesAdded(thumbnail))
|
||||
|
||||
if (thumbnail.selected)
|
||||
thumbnailsSeriesRepository.markSelected(thumbnail)
|
||||
}
|
||||
@ -224,5 +226,6 @@ class SeriesLifecycle(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ThumbnailSeries.exists(): Boolean = Files.exists(Paths.get(url.toURI()))
|
||||
}
|
||||
|
@ -3,16 +3,27 @@ package org.gotson.komga.infrastructure.jms
|
||||
import org.apache.activemq.artemis.api.core.QueueConfiguration
|
||||
import org.apache.activemq.artemis.api.core.RoutingType
|
||||
import org.apache.activemq.artemis.core.settings.impl.AddressSettings
|
||||
import org.springframework.boot.autoconfigure.jms.DefaultJmsListenerContainerFactoryConfigurer
|
||||
import org.springframework.boot.autoconfigure.jms.artemis.ArtemisConfigurationCustomizer
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import org.springframework.jms.config.DefaultJmsListenerContainerFactory
|
||||
import javax.jms.ConnectionFactory
|
||||
import org.apache.activemq.artemis.core.config.Configuration as ArtemisConfiguration
|
||||
|
||||
const val QUEUE_UNIQUE_ID = "unique_id"
|
||||
const val QUEUE_TYPE = "type"
|
||||
|
||||
const val QUEUE_TASKS = "tasks.background"
|
||||
const val QUEUE_TASKS_TYPE = "task"
|
||||
const val QUEUE_TASKS_SELECTOR = "$QUEUE_TYPE = '$QUEUE_TASKS_TYPE'"
|
||||
|
||||
const val QUEUE_SSE = "sse"
|
||||
const val QUEUE_SSE_TYPE = "sse"
|
||||
const val QUEUE_SSE_SELECTOR = "$QUEUE_TYPE = '$QUEUE_SSE_TYPE'"
|
||||
|
||||
const val TOPIC_FACTORY = "topicJmsListenerContainerFactory"
|
||||
|
||||
@Configuration
|
||||
class ArtemisConfig : ArtemisConfigurationCustomizer {
|
||||
override fun customize(configuration: ArtemisConfiguration?) {
|
||||
@ -32,6 +43,21 @@ class ArtemisConfig : ArtemisConfigurationCustomizer {
|
||||
.setLastValueKey(QUEUE_UNIQUE_ID)
|
||||
.setRoutingType(RoutingType.ANYCAST)
|
||||
)
|
||||
it.addQueueConfiguration(
|
||||
QueueConfiguration(QUEUE_SSE)
|
||||
.setAddress(QUEUE_SSE)
|
||||
.setRoutingType(RoutingType.MULTICAST)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Bean(TOPIC_FACTORY)
|
||||
fun topicJmsListenerContainerFactory(
|
||||
connectionFactory: ConnectionFactory,
|
||||
configurer: DefaultJmsListenerContainerFactoryConfigurer,
|
||||
): DefaultJmsListenerContainerFactory =
|
||||
DefaultJmsListenerContainerFactory().apply {
|
||||
configurer.configure(this, connectionFactory)
|
||||
setPubSubDomain(true)
|
||||
}
|
||||
}
|
||||
|
@ -54,6 +54,12 @@ class BookDao(
|
||||
.fetchInto(b)
|
||||
.map { it.toDomain() }
|
||||
|
||||
override fun findAllBySeriesIds(seriesIds: Collection<String>): Collection<Book> =
|
||||
dsl.selectFrom(b)
|
||||
.where(b.SERIES_ID.`in`(seriesIds))
|
||||
.fetchInto(b)
|
||||
.map { it.toDomain() }
|
||||
|
||||
override fun findAll(): Collection<Book> =
|
||||
dsl.select(*b.fields())
|
||||
.from(b)
|
||||
@ -106,6 +112,12 @@ class BookDao(
|
||||
.where(b.ID.eq(bookId))
|
||||
.fetchOne(b.LIBRARY_ID)
|
||||
|
||||
override fun getSeriesIdOrNull(bookId: String): String? =
|
||||
dsl.select(b.SERIES_ID)
|
||||
.from(b)
|
||||
.where(b.ID.eq(bookId))
|
||||
.fetchOne(b.SERIES_ID)
|
||||
|
||||
override fun findFirstIdInSeriesOrNull(seriesId: String): String? =
|
||||
dsl.select(b.ID)
|
||||
.from(b)
|
||||
|
@ -43,6 +43,12 @@ class ReadProgressDao(
|
||||
.fetchInto(r)
|
||||
.map { it.toDomain() }
|
||||
|
||||
override fun findAllByBookIdsAndUserId(bookIds: Collection<String>, userId: String): Collection<ReadProgress> =
|
||||
dsl.selectFrom(r)
|
||||
.where(r.BOOK_ID.`in`(bookIds).and(r.USER_ID.eq(userId)))
|
||||
.fetchInto(r)
|
||||
.map { it.toDomain() }
|
||||
|
||||
override fun save(readProgress: ReadProgress) {
|
||||
dsl.transaction { config ->
|
||||
config.dsl().saveQuery(readProgress).execute()
|
||||
|
@ -41,7 +41,8 @@ class SecurityConfiguration(
|
||||
// all other endpoints are restricted to authenticated users
|
||||
.antMatchers(
|
||||
"/api/**",
|
||||
"/opds/**"
|
||||
"/opds/**",
|
||||
"/sse/**"
|
||||
).hasRole(ROLE_USER)
|
||||
|
||||
.and()
|
||||
|
@ -7,11 +7,13 @@ import io.swagger.v3.oas.annotations.media.Schema
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse
|
||||
import mu.KotlinLogging
|
||||
import org.apache.commons.io.IOUtils
|
||||
import org.gotson.komga.application.events.EventPublisher
|
||||
import org.gotson.komga.application.tasks.HIGHEST_PRIORITY
|
||||
import org.gotson.komga.application.tasks.HIGH_PRIORITY
|
||||
import org.gotson.komga.application.tasks.TaskReceiver
|
||||
import org.gotson.komga.domain.model.Author
|
||||
import org.gotson.komga.domain.model.BookSearchWithReadProgress
|
||||
import org.gotson.komga.domain.model.DomainEvent
|
||||
import org.gotson.komga.domain.model.ImageConversionException
|
||||
import org.gotson.komga.domain.model.Media
|
||||
import org.gotson.komga.domain.model.MediaNotReadyException
|
||||
@ -86,6 +88,7 @@ class BookController(
|
||||
private val bookDtoRepository: BookDtoRepository,
|
||||
private val readListRepository: ReadListRepository,
|
||||
private val contentDetector: ContentDetector,
|
||||
private val eventPublisher: EventPublisher,
|
||||
) {
|
||||
|
||||
@PageableAsQueryParam
|
||||
@ -471,6 +474,8 @@ class BookController(
|
||||
}
|
||||
bookMetadataRepository.update(updated)
|
||||
taskReceiver.aggregateSeriesMetadata(bookRepository.findByIdOrNull(bookId)!!.seriesId)
|
||||
|
||||
bookRepository.findByIdOrNull(bookId)?.let { eventPublisher.publishEvent(DomainEvent.BookUpdated(it)) }
|
||||
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
||||
|
||||
@PatchMapping("api/v1/books/{bookId}/read-progress")
|
||||
@ -504,7 +509,7 @@ class BookController(
|
||||
bookRepository.findByIdOrNull(bookId)?.let { book ->
|
||||
if (!principal.user.canAccessBook(book)) throw ResponseStatusException(HttpStatus.FORBIDDEN)
|
||||
|
||||
bookLifecycle.deleteReadProgress(book.id, principal.user)
|
||||
bookLifecycle.deleteReadProgress(book, principal.user)
|
||||
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
||||
}
|
||||
|
||||
|
@ -179,7 +179,7 @@ class ReadListController(
|
||||
@PathVariable id: String
|
||||
) {
|
||||
readListRepository.findByIdOrNull(id)?.let {
|
||||
readListLifecycle.deleteReadList(it.id)
|
||||
readListLifecycle.deleteReadList(it)
|
||||
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
||||
}
|
||||
|
||||
|
@ -152,7 +152,7 @@ class SeriesCollectionController(
|
||||
@PathVariable id: String
|
||||
) {
|
||||
collectionRepository.findByIdOrNull(id)?.let {
|
||||
collectionLifecycle.deleteCollection(it.id)
|
||||
collectionLifecycle.deleteCollection(it)
|
||||
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
||||
}
|
||||
|
||||
|
@ -9,10 +9,12 @@ import mu.KotlinLogging
|
||||
import org.apache.commons.compress.archivers.zip.ZipArchiveEntry
|
||||
import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream
|
||||
import org.apache.commons.io.IOUtils
|
||||
import org.gotson.komga.application.events.EventPublisher
|
||||
import org.gotson.komga.application.tasks.HIGH_PRIORITY
|
||||
import org.gotson.komga.application.tasks.TaskReceiver
|
||||
import org.gotson.komga.domain.model.Author
|
||||
import org.gotson.komga.domain.model.BookSearchWithReadProgress
|
||||
import org.gotson.komga.domain.model.DomainEvent
|
||||
import org.gotson.komga.domain.model.Media
|
||||
import org.gotson.komga.domain.model.ROLE_ADMIN
|
||||
import org.gotson.komga.domain.model.ROLE_FILE_DOWNLOAD
|
||||
@ -86,6 +88,7 @@ class SeriesController(
|
||||
private val bookDtoRepository: BookDtoRepository,
|
||||
private val collectionRepository: SeriesCollectionRepository,
|
||||
private val readProgressDtoRepository: ReadProgressDtoRepository,
|
||||
private val eventPublisher: EventPublisher,
|
||||
) {
|
||||
|
||||
@PageableAsQueryParam
|
||||
@ -353,6 +356,8 @@ class SeriesController(
|
||||
)
|
||||
}
|
||||
seriesMetadataRepository.update(updated)
|
||||
|
||||
seriesRepository.findByIdOrNull(seriesId)?.let { eventPublisher.publishEvent(DomainEvent.SeriesUpdated(it)) }
|
||||
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
||||
|
||||
@PostMapping("{seriesId}/read-progress")
|
||||
|
@ -0,0 +1,117 @@
|
||||
package org.gotson.komga.interfaces.sse
|
||||
|
||||
import mu.KotlinLogging
|
||||
import org.gotson.komga.domain.model.DomainEvent
|
||||
import org.gotson.komga.domain.model.KomgaUser
|
||||
import org.gotson.komga.domain.persistence.BookRepository
|
||||
import org.gotson.komga.infrastructure.jms.QUEUE_SSE
|
||||
import org.gotson.komga.infrastructure.jms.QUEUE_SSE_SELECTOR
|
||||
import org.gotson.komga.infrastructure.jms.QUEUE_TASKS
|
||||
import org.gotson.komga.infrastructure.jms.TOPIC_FACTORY
|
||||
import org.gotson.komga.infrastructure.security.KomgaPrincipal
|
||||
import org.gotson.komga.infrastructure.web.toFilePath
|
||||
import org.gotson.komga.interfaces.sse.dto.BookImportSseDto
|
||||
import org.gotson.komga.interfaces.sse.dto.BookSseDto
|
||||
import org.gotson.komga.interfaces.sse.dto.CollectionSseDto
|
||||
import org.gotson.komga.interfaces.sse.dto.LibrarySseDto
|
||||
import org.gotson.komga.interfaces.sse.dto.ReadListSseDto
|
||||
import org.gotson.komga.interfaces.sse.dto.ReadProgressSseDto
|
||||
import org.gotson.komga.interfaces.sse.dto.SeriesSseDto
|
||||
import org.gotson.komga.interfaces.sse.dto.TaskQueueSseDto
|
||||
import org.gotson.komga.interfaces.sse.dto.ThumbnailBookSseDto
|
||||
import org.gotson.komga.interfaces.sse.dto.ThumbnailSeriesSseDto
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.jms.annotation.JmsListener
|
||||
import org.springframework.jms.core.JmsTemplate
|
||||
import org.springframework.scheduling.annotation.Scheduled
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||
import org.springframework.stereotype.Controller
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter
|
||||
import java.io.IOException
|
||||
import java.util.Collections
|
||||
import javax.jms.QueueBrowser
|
||||
import javax.jms.Session
|
||||
|
||||
private val logger = KotlinLogging.logger {}
|
||||
|
||||
@Controller
|
||||
class SseController(
|
||||
private val bookRepository: BookRepository,
|
||||
private val jmsTemplate: JmsTemplate,
|
||||
) {
|
||||
|
||||
private val emitters = Collections.synchronizedMap(HashMap<SseEmitter, KomgaUser>())
|
||||
|
||||
@GetMapping("sse/v1/events")
|
||||
fun sse(
|
||||
@AuthenticationPrincipal principal: KomgaPrincipal,
|
||||
): SseEmitter {
|
||||
val emitter = SseEmitter()
|
||||
emitter.onCompletion { synchronized(emitters) { emitters.remove(emitter) } }
|
||||
emitter.onTimeout { emitter.complete() }
|
||||
emitters[emitter] = principal.user
|
||||
return emitter
|
||||
}
|
||||
|
||||
@Scheduled(fixedRate = 10_000)
|
||||
fun taskCount() {
|
||||
val size = jmsTemplate.browse(QUEUE_TASKS) { _: Session, browser: QueueBrowser ->
|
||||
browser.enumeration.toList().size
|
||||
} ?: 0
|
||||
|
||||
emitSse("TaskQueueStatus", TaskQueueSseDto(size), adminOnly = true)
|
||||
}
|
||||
|
||||
@JmsListener(destination = QUEUE_SSE, selector = QUEUE_SSE_SELECTOR, containerFactory = TOPIC_FACTORY)
|
||||
fun handleSseEvent(event: DomainEvent) {
|
||||
when (event) {
|
||||
is DomainEvent.LibraryAdded -> emitSse("LibraryAdded", LibrarySseDto(event.library.id))
|
||||
is DomainEvent.LibraryUpdated -> emitSse("LibraryChanged", LibrarySseDto(event.library.id))
|
||||
is DomainEvent.LibraryDeleted -> emitSse("LibraryDeleted", LibrarySseDto(event.library.id))
|
||||
|
||||
is DomainEvent.SeriesAdded -> emitSse("SeriesAdded", SeriesSseDto(event.series.id, event.series.libraryId))
|
||||
is DomainEvent.SeriesUpdated -> emitSse("SeriesChanged", SeriesSseDto(event.series.id, event.series.libraryId))
|
||||
is DomainEvent.SeriesDeleted -> emitSse("SeriesDeleted", SeriesSseDto(event.series.id, event.series.libraryId))
|
||||
|
||||
is DomainEvent.BookAdded -> emitSse("BookAdded", BookSseDto(event.book.id, event.book.seriesId, event.book.libraryId))
|
||||
is DomainEvent.BookUpdated -> emitSse("BookChanged", BookSseDto(event.book.id, event.book.seriesId, event.book.libraryId))
|
||||
is DomainEvent.BookDeleted -> emitSse("BookDeleted", BookSseDto(event.book.id, event.book.seriesId, event.book.libraryId))
|
||||
is DomainEvent.BookImported -> emitSse("BookImported", BookImportSseDto(event.book?.id, event.sourceFile.toFilePath(), event.success, event.message), adminOnly = true)
|
||||
|
||||
is DomainEvent.ReadListAdded -> emitSse("ReadListAdded", ReadListSseDto(event.readList.id, event.readList.bookIds.map { it.value }))
|
||||
is DomainEvent.ReadListUpdated -> emitSse("ReadListChanged", ReadListSseDto(event.readList.id, event.readList.bookIds.map { it.value }))
|
||||
is DomainEvent.ReadListDeleted -> emitSse("ReadListDeleted", ReadListSseDto(event.readList.id, event.readList.bookIds.map { it.value }))
|
||||
|
||||
is DomainEvent.CollectionAdded -> emitSse("CollectionAdded", CollectionSseDto(event.collection.id, event.collection.seriesIds))
|
||||
is DomainEvent.CollectionUpdated -> emitSse("CollectionChanged", CollectionSseDto(event.collection.id, event.collection.seriesIds))
|
||||
is DomainEvent.CollectionDeleted -> emitSse("CollectionDeleted", CollectionSseDto(event.collection.id, event.collection.seriesIds))
|
||||
|
||||
is DomainEvent.ReadProgressChanged -> emitSse("ReadProgressChanged", ReadProgressSseDto(event.progress.bookId, event.progress.userId), userIdOnly = event.progress.userId)
|
||||
is DomainEvent.ReadProgressDeleted -> emitSse("ReadProgressDeleted", ReadProgressSseDto(event.progress.bookId, event.progress.userId), userIdOnly = event.progress.userId)
|
||||
|
||||
is DomainEvent.ThumbnailBookAdded -> emitSse("ThumbnailBookAdded", ThumbnailBookSseDto(event.thumbnail.bookId, bookRepository.getSeriesIdOrNull(event.thumbnail.bookId).orEmpty()))
|
||||
is DomainEvent.ThumbnailSeriesAdded -> emitSse("ThumbnailSeriesAdded", ThumbnailSeriesSseDto(event.thumbnail.seriesId))
|
||||
}
|
||||
}
|
||||
|
||||
private fun emitSse(name: String, data: Any, adminOnly: Boolean = false, userIdOnly: String? = null) {
|
||||
logger.debug { "Publish SSE: '$name':$data" }
|
||||
|
||||
synchronized(emitters) {
|
||||
emitters
|
||||
.filter { if (adminOnly) it.value.roleAdmin else true }
|
||||
.filter { if (userIdOnly != null) it.value.id == userIdOnly else true }
|
||||
.forEach { (emitter, _) ->
|
||||
try {
|
||||
emitter.send(
|
||||
SseEmitter.event()
|
||||
.name(name)
|
||||
.data(data, MediaType.APPLICATION_JSON)
|
||||
)
|
||||
} catch (e: IOException) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,8 @@
|
||||
package org.gotson.komga.interfaces.sse.dto
|
||||
|
||||
data class BookImportSseDto(
|
||||
val bookId: String?,
|
||||
val sourceFile: String,
|
||||
val success: Boolean,
|
||||
val message: String? = null,
|
||||
)
|
@ -0,0 +1,7 @@
|
||||
package org.gotson.komga.interfaces.sse.dto
|
||||
|
||||
data class BookSseDto(
|
||||
val bookId: String,
|
||||
val seriesId: String,
|
||||
val libraryId: String,
|
||||
)
|
@ -0,0 +1,6 @@
|
||||
package org.gotson.komga.interfaces.sse.dto
|
||||
|
||||
data class CollectionSseDto(
|
||||
val collectionId: String,
|
||||
val seriesIds: List<String>,
|
||||
)
|
@ -0,0 +1,5 @@
|
||||
package org.gotson.komga.interfaces.sse.dto
|
||||
|
||||
data class LibrarySseDto(
|
||||
val libraryId: String,
|
||||
)
|
@ -0,0 +1,6 @@
|
||||
package org.gotson.komga.interfaces.sse.dto
|
||||
|
||||
data class ReadListSseDto(
|
||||
val readListId: String,
|
||||
val bookIds: List<String>,
|
||||
)
|
@ -0,0 +1,6 @@
|
||||
package org.gotson.komga.interfaces.sse.dto
|
||||
|
||||
data class ReadProgressSseDto(
|
||||
val bookId: String,
|
||||
val userId: String,
|
||||
)
|
@ -0,0 +1,6 @@
|
||||
package org.gotson.komga.interfaces.sse.dto
|
||||
|
||||
data class SeriesSseDto(
|
||||
val seriesId: String,
|
||||
val libraryId: String,
|
||||
)
|
@ -0,0 +1,5 @@
|
||||
package org.gotson.komga.interfaces.sse.dto
|
||||
|
||||
data class TaskQueueSseDto(
|
||||
val count: Int,
|
||||
)
|
@ -0,0 +1,6 @@
|
||||
package org.gotson.komga.interfaces.sse.dto
|
||||
|
||||
data class ThumbnailBookSseDto(
|
||||
val bookId: String,
|
||||
val seriesId: String,
|
||||
)
|
@ -0,0 +1,5 @@
|
||||
package org.gotson.komga.interfaces.sse.dto
|
||||
|
||||
data class ThumbnailSeriesSseDto(
|
||||
val seriesId: String,
|
||||
)
|
@ -2,9 +2,9 @@ komga:
|
||||
remember-me:
|
||||
key: changeMe!
|
||||
validity: 2592000 # 1 month
|
||||
# libraries-scan-cron: "*/5 * * * * ?" #every 5 seconds
|
||||
# libraries-scan-cron: "*/5 * * * * ?" #every 5 seconds
|
||||
libraries-scan-cron: "-" #disable
|
||||
libraries-scan-startup: true
|
||||
libraries-scan-startup: false
|
||||
database:
|
||||
file: ":memory:"
|
||||
cors.allowed-origins:
|
||||
|
@ -5,7 +5,7 @@ logging:
|
||||
file:
|
||||
name: \${user.home}/.komga/komga.log
|
||||
level:
|
||||
org.apache.activemq.audit.message: WARN
|
||||
org.apache.activemq.audit: WARN
|
||||
|
||||
komga:
|
||||
libraries-scan-cron: "0 */15 * * * ?"
|
||||
|
@ -75,7 +75,7 @@ class BookImporterTest(
|
||||
|
||||
@AfterEach
|
||||
fun `clear repository`() {
|
||||
seriesLifecycle.deleteMany(seriesRepository.findAll().map { it.id })
|
||||
seriesLifecycle.deleteMany(seriesRepository.findAll())
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@ -61,7 +61,7 @@ class BookLifecycleTest(
|
||||
|
||||
@AfterEach
|
||||
fun `clear repository`() {
|
||||
seriesLifecycle.deleteMany(seriesRepository.findAll().map { it.id })
|
||||
seriesLifecycle.deleteMany(seriesRepository.findAll())
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@ -49,7 +49,7 @@ class ReadListMatcherTest(
|
||||
@AfterEach
|
||||
fun `clear repository`() {
|
||||
readListRepository.deleteAll()
|
||||
seriesLifecycle.deleteMany(seriesRepository.findAll().map { it.id })
|
||||
seriesLifecycle.deleteMany(seriesRepository.findAll())
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@ -42,7 +42,7 @@ class SeriesLifecycleTest(
|
||||
|
||||
@AfterEach
|
||||
fun `clear repository`() {
|
||||
seriesLifecycle.deleteMany(seriesRepository.findAll().map { it.id })
|
||||
seriesLifecycle.deleteMany(seriesRepository.findAll())
|
||||
}
|
||||
|
||||
@Test
|
||||
@ -88,7 +88,7 @@ class SeriesLifecycleTest(
|
||||
|
||||
// when
|
||||
val book = bookRepository.findAllBySeriesId(createdSeries.id).first { it.name == "book 2" }
|
||||
bookLifecycle.deleteOne(book.id)
|
||||
bookLifecycle.deleteOne(book)
|
||||
seriesLifecycle.sortBooks(createdSeries)
|
||||
|
||||
// then
|
||||
|
@ -54,7 +54,7 @@ class BookDtoDaoTest(
|
||||
|
||||
@AfterEach
|
||||
fun deleteBooks() {
|
||||
bookLifecycle.deleteMany(bookRepository.findAll().map { it.id })
|
||||
bookLifecycle.deleteMany(bookRepository.findAll())
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
|
@ -51,7 +51,7 @@ class SeriesDtoDaoTest(
|
||||
|
||||
@AfterEach
|
||||
fun deleteSeries() {
|
||||
seriesLifecycle.deleteMany(seriesRepository.findAll().map { it.id })
|
||||
seriesLifecycle.deleteMany(seriesRepository.findAll())
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
|
@ -91,7 +91,7 @@ class BookControllerTest(
|
||||
|
||||
@AfterEach
|
||||
fun `clear repository`() {
|
||||
seriesLifecycle.deleteMany(seriesRepository.findAll().map { it.id })
|
||||
seriesLifecycle.deleteMany(seriesRepository.findAll())
|
||||
}
|
||||
|
||||
@Nested
|
||||
|
@ -83,7 +83,7 @@ class SeriesControllerTest(
|
||||
|
||||
@AfterEach
|
||||
fun `clear repository`() {
|
||||
seriesLifecycle.deleteMany(seriesRepository.findAll().map { it.id })
|
||||
seriesLifecycle.deleteMany(seriesRepository.findAll())
|
||||
}
|
||||
|
||||
@Nested
|
||||
|
Loading…
Reference in New Issue
Block a user