feat(sse): publish server-sent events

This commit is contained in:
Gauthier Roebroeck 2021-06-21 14:43:54 +08:00
parent b7c2c09ff4
commit 691c7f0071
52 changed files with 565 additions and 164 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -41,7 +41,8 @@ class SecurityConfiguration(
// all other endpoints are restricted to authenticated users
.antMatchers(
"/api/**",
"/opds/**"
"/opds/**",
"/sse/**"
).hasRole(ROLE_USER)
.and()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,7 @@
package org.gotson.komga.interfaces.sse.dto
data class BookSseDto(
val bookId: String,
val seriesId: String,
val libraryId: String,
)

View File

@ -0,0 +1,6 @@
package org.gotson.komga.interfaces.sse.dto
data class CollectionSseDto(
val collectionId: String,
val seriesIds: List<String>,
)

View File

@ -0,0 +1,5 @@
package org.gotson.komga.interfaces.sse.dto
data class LibrarySseDto(
val libraryId: String,
)

View File

@ -0,0 +1,6 @@
package org.gotson.komga.interfaces.sse.dto
data class ReadListSseDto(
val readListId: String,
val bookIds: List<String>,
)

View File

@ -0,0 +1,6 @@
package org.gotson.komga.interfaces.sse.dto
data class ReadProgressSseDto(
val bookId: String,
val userId: String,
)

View File

@ -0,0 +1,6 @@
package org.gotson.komga.interfaces.sse.dto
data class SeriesSseDto(
val seriesId: String,
val libraryId: String,
)

View File

@ -0,0 +1,5 @@
package org.gotson.komga.interfaces.sse.dto
data class TaskQueueSseDto(
val count: Int,
)

View File

@ -0,0 +1,6 @@
package org.gotson.komga.interfaces.sse.dto
data class ThumbnailBookSseDto(
val bookId: String,
val seriesId: String,
)

View File

@ -0,0 +1,5 @@
package org.gotson.komga.interfaces.sse.dto
data class ThumbnailSeriesSseDto(
val seriesId: String,
)

View File

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

View File

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

View File

@ -75,7 +75,7 @@ class BookImporterTest(
@AfterEach
fun `clear repository`() {
seriesLifecycle.deleteMany(seriesRepository.findAll().map { it.id })
seriesLifecycle.deleteMany(seriesRepository.findAll())
}
@Test

View File

@ -61,7 +61,7 @@ class BookLifecycleTest(
@AfterEach
fun `clear repository`() {
seriesLifecycle.deleteMany(seriesRepository.findAll().map { it.id })
seriesLifecycle.deleteMany(seriesRepository.findAll())
}
@Test

View File

@ -49,7 +49,7 @@ class ReadListMatcherTest(
@AfterEach
fun `clear repository`() {
readListRepository.deleteAll()
seriesLifecycle.deleteMany(seriesRepository.findAll().map { it.id })
seriesLifecycle.deleteMany(seriesRepository.findAll())
}
@Test

View File

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

View File

@ -54,7 +54,7 @@ class BookDtoDaoTest(
@AfterEach
fun deleteBooks() {
bookLifecycle.deleteMany(bookRepository.findAll().map { it.id })
bookLifecycle.deleteMany(bookRepository.findAll())
}
@AfterAll

View File

@ -51,7 +51,7 @@ class SeriesDtoDaoTest(
@AfterEach
fun deleteSeries() {
seriesLifecycle.deleteMany(seriesRepository.findAll().map { it.id })
seriesLifecycle.deleteMany(seriesRepository.findAll())
}
@AfterAll

View File

@ -91,7 +91,7 @@ class BookControllerTest(
@AfterEach
fun `clear repository`() {
seriesLifecycle.deleteMany(seriesRepository.findAll().map { it.id })
seriesLifecycle.deleteMany(seriesRepository.findAll())
}
@Nested

View File

@ -83,7 +83,7 @@ class SeriesControllerTest(
@AfterEach
fun `clear repository`() {
seriesLifecycle.deleteMany(seriesRepository.findAll().map { it.id })
seriesLifecycle.deleteMany(seriesRepository.findAll())
}
@Nested