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_1015 | Error while deserializing ComicRack ReadingList
ERR_1016 | Directory not accessible or not a directory ERR_1016 | Directory not accessible or not a directory
ERR_1017 | Cannot scan folder that is part of an existing library 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.github.f4b6a3.tsid.TsidCreator
import com.jakewharton.byteunits.BinaryByteUnit import com.jakewharton.byteunits.BinaryByteUnit
import java.io.Serializable
import java.net.URL import java.net.URL
import java.nio.file.Path import java.nio.file.Path
import java.nio.file.Paths import java.nio.file.Paths
@ -20,8 +21,11 @@ data class Book(
override val createdDate: LocalDateTime = LocalDateTime.now(), override val createdDate: LocalDateTime = LocalDateTime.now(),
override val lastModifiedDate: LocalDateTime = LocalDateTime.now() override val lastModifiedDate: LocalDateTime = LocalDateTime.now()
) : Auditable() { ) : Auditable(), Serializable {
@delegate:Transient
val path: Path by lazy { Paths.get(this.url.toURI()) } val path: Path by lazy { Paths.get(this.url.toURI()) }
@delegate:Transient
val fileSizeHumanReadable: String by lazy { BinaryByteUnit.format(fileSize) } 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 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 MediaNotReadyException : Exception()
class MediaUnsupportedException(message: String, code: String = "") : CodedException(message, code) class MediaUnsupportedException(message: String, code: String = "") : CodedException(message, code)
class ImageConversionException(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 package org.gotson.komga.domain.model
import com.github.f4b6a3.tsid.TsidCreator import com.github.f4b6a3.tsid.TsidCreator
import java.io.Serializable
import java.time.LocalDateTime import java.time.LocalDateTime
import javax.validation.constraints.Email import javax.validation.constraints.Email
import javax.validation.constraints.NotBlank import javax.validation.constraints.NotBlank
@ -24,7 +25,7 @@ data class KomgaUser(
val id: String = TsidCreator.getTsid256().toString(), val id: String = TsidCreator.getTsid256().toString(),
override val createdDate: LocalDateTime = LocalDateTime.now(), override val createdDate: LocalDateTime = LocalDateTime.now(),
override val lastModifiedDate: LocalDateTime = LocalDateTime.now() override val lastModifiedDate: LocalDateTime = LocalDateTime.now()
) : Auditable() { ) : Auditable(), Serializable {
fun roles(): Set<String> { fun roles(): Set<String> {
val roles = mutableSetOf(ROLE_USER) val roles = mutableSetOf(ROLE_USER)

View file

@ -1,6 +1,7 @@
package org.gotson.komga.domain.model package org.gotson.komga.domain.model
import com.github.f4b6a3.tsid.TsidCreator import com.github.f4b6a3.tsid.TsidCreator
import java.io.Serializable
import java.net.URL import java.net.URL
import java.nio.file.Path import java.nio.file.Path
import java.nio.file.Paths import java.nio.file.Paths
@ -26,7 +27,8 @@ data class Library(
override val createdDate: LocalDateTime = LocalDateTime.now(), override val createdDate: LocalDateTime = LocalDateTime.now(),
override val lastModifiedDate: LocalDateTime = LocalDateTime.now() override val lastModifiedDate: LocalDateTime = LocalDateTime.now()
) : Auditable() { ) : Auditable(), Serializable {
@delegate:Transient
val path: Path by lazy { Paths.get(this.root.toURI()) } val path: Path by lazy { Paths.get(this.root.toURI()) }
} }

View file

@ -1,6 +1,7 @@
package org.gotson.komga.domain.model package org.gotson.komga.domain.model
import com.github.f4b6a3.tsid.TsidCreator import com.github.f4b6a3.tsid.TsidCreator
import java.io.Serializable
import java.time.LocalDateTime import java.time.LocalDateTime
import java.util.SortedMap import java.util.SortedMap
@ -18,4 +19,4 @@ data class ReadList(
* Indicates that the bookIds have been filtered and is not exhaustive. * Indicates that the bookIds have been filtered and is not exhaustive.
*/ */
val filtered: Boolean = false val filtered: Boolean = false
) : Auditable() ) : Auditable(), Serializable

View file

@ -1,5 +1,6 @@
package org.gotson.komga.domain.model package org.gotson.komga.domain.model
import java.io.Serializable
import java.time.LocalDateTime import java.time.LocalDateTime
data class ReadProgress( data class ReadProgress(
@ -10,4 +11,4 @@ data class ReadProgress(
override val createdDate: LocalDateTime = LocalDateTime.now(), override val createdDate: LocalDateTime = LocalDateTime.now(),
override val lastModifiedDate: LocalDateTime = LocalDateTime.now() override val lastModifiedDate: LocalDateTime = LocalDateTime.now()
) : Auditable() ) : Auditable(), Serializable

View file

@ -1,6 +1,7 @@
package org.gotson.komga.domain.model package org.gotson.komga.domain.model
import com.github.f4b6a3.tsid.TsidCreator import com.github.f4b6a3.tsid.TsidCreator
import java.io.Serializable
import java.net.URL import java.net.URL
import java.nio.file.Path import java.nio.file.Path
import java.nio.file.Paths import java.nio.file.Paths
@ -17,7 +18,8 @@ data class Series(
override val createdDate: LocalDateTime = LocalDateTime.now(), override val createdDate: LocalDateTime = LocalDateTime.now(),
override val lastModifiedDate: LocalDateTime = LocalDateTime.now() override val lastModifiedDate: LocalDateTime = LocalDateTime.now()
) : Auditable() { ) : Auditable(), Serializable {
@delegate:Transient
val path: Path by lazy { Paths.get(this.url.toURI()) } val path: Path by lazy { Paths.get(this.url.toURI()) }
} }

View file

@ -1,6 +1,7 @@
package org.gotson.komga.domain.model package org.gotson.komga.domain.model
import com.github.f4b6a3.tsid.TsidCreator import com.github.f4b6a3.tsid.TsidCreator
import java.io.Serializable
import java.time.LocalDateTime import java.time.LocalDateTime
data class SeriesCollection( data class SeriesCollection(
@ -18,4 +19,4 @@ data class SeriesCollection(
* Indicates that the seriesIds have been filtered and is not exhaustive. * Indicates that the seriesIds have been filtered and is not exhaustive.
*/ */
val filtered: Boolean = false val filtered: Boolean = false
) : Auditable() ) : Auditable(), Serializable

View file

@ -1,6 +1,7 @@
package org.gotson.komga.domain.model package org.gotson.komga.domain.model
import com.github.f4b6a3.tsid.TsidCreator import com.github.f4b6a3.tsid.TsidCreator
import java.io.Serializable
import java.net.URL import java.net.URL
import java.time.LocalDateTime import java.time.LocalDateTime
@ -15,7 +16,7 @@ data class ThumbnailBook(
override val createdDate: LocalDateTime = LocalDateTime.now(), override val createdDate: LocalDateTime = LocalDateTime.now(),
override val lastModifiedDate: LocalDateTime = LocalDateTime.now() override val lastModifiedDate: LocalDateTime = LocalDateTime.now()
) : Auditable() { ) : Auditable(), Serializable {
enum class Type { enum class Type {
GENERATED, SIDECAR GENERATED, SIDECAR
} }

View file

@ -1,6 +1,7 @@
package org.gotson.komga.domain.model package org.gotson.komga.domain.model
import com.github.f4b6a3.tsid.TsidCreator import com.github.f4b6a3.tsid.TsidCreator
import java.io.Serializable
import java.net.URL import java.net.URL
import java.time.LocalDateTime import java.time.LocalDateTime
@ -13,4 +14,4 @@ data class ThumbnailSeries(
override val createdDate: LocalDateTime = LocalDateTime.now(), override val createdDate: LocalDateTime = LocalDateTime.now(),
override val lastModifiedDate: 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 findAll(): Collection<Book>
fun findAllBySeriesId(seriesId: String): 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): Collection<Book>
fun findAll(bookSearch: BookSearch, pageable: Pageable): Page<Book> fun findAll(bookSearch: BookSearch, pageable: Pageable): Page<Book>
fun getLibraryIdOrNull(bookId: String): String? fun getLibraryIdOrNull(bookId: String): String?
fun getSeriesIdOrNull(bookId: String): String?
fun findFirstIdInSeriesOrNull(seriesId: String): String? fun findFirstIdInSeriesOrNull(seriesId: String): String?
fun findAllIdsBySeriesId(seriesId: String): Collection<String> fun findAllIdsBySeriesId(seriesId: String): Collection<String>

View file

@ -8,6 +8,7 @@ interface ReadProgressRepository {
fun findAll(): Collection<ReadProgress> fun findAll(): Collection<ReadProgress>
fun findAllByUserId(userId: String): Collection<ReadProgress> fun findAllByUserId(userId: String): Collection<ReadProgress>
fun findAllByBookId(bookId: String): Collection<ReadProgress> fun findAllByBookId(bookId: String): Collection<ReadProgress>
fun findAllByBookIdsAndUserId(bookIds: Collection<String>, userId: String): Collection<ReadProgress>
fun save(readProgress: ReadProgress) fun save(readProgress: ReadProgress)
fun save(readProgresses: Collection<ReadProgress>) fun save(readProgresses: Collection<ReadProgress>)

View file

@ -1,11 +1,15 @@
package org.gotson.komga.domain.service package org.gotson.komga.domain.service
import mu.KotlinLogging import mu.KotlinLogging
import org.gotson.komga.application.events.EventPublisher
import org.gotson.komga.domain.model.Book 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.CopyMode
import org.gotson.komga.domain.model.DomainEvent
import org.gotson.komga.domain.model.Media import org.gotson.komga.domain.model.Media
import org.gotson.komga.domain.model.PathContainedInPath import org.gotson.komga.domain.model.PathContainedInPath
import org.gotson.komga.domain.model.Series 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.BookMetadataRepository
import org.gotson.komga.domain.persistence.BookRepository import org.gotson.komga.domain.persistence.BookRepository
import org.gotson.komga.domain.persistence.LibraryRepository import org.gotson.komga.domain.persistence.LibraryRepository
@ -41,13 +45,15 @@ class BookImporter(
private val readProgressRepository: ReadProgressRepository, private val readProgressRepository: ReadProgressRepository,
private val readListRepository: ReadListRepository, private val readListRepository: ReadListRepository,
private val libraryRepository: LibraryRepository, private val libraryRepository: LibraryRepository,
private val eventPublisher: EventPublisher,
) { ) {
fun importBook(sourceFile: Path, series: Series, copyMode: CopyMode, destinationName: String? = null, upgradeBookId: String? = null): Book { 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 -> libraryRepository.findAll().forEach { library ->
if (sourceFile.startsWith(library.path)) throw PathContainedInPath("Cannot import file that is part of an existing 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( val destFile = series.path.resolve(
@ -55,16 +61,16 @@ class BookImporter(
else sourceFile.fileName.toString() else sourceFile.fileName.toString()
) )
val upgradedBookId = val upgradedBook =
if (upgradeBookId != null) { if (upgradeBookId != null) {
bookRepository.findByIdOrNull(upgradeBookId)?.let { bookRepository.findByIdOrNull(upgradeBookId)?.let {
if (it.seriesId != series.id) throw IllegalArgumentException("Book to upgrade ($upgradeBookId) does not belong to series: $series") if (it.seriesId != series.id) throw IllegalArgumentException("Book to upgrade ($upgradeBookId) does not belong to series: $series").withCode("ERR_1020")
it.id it
} }
} else null } else null
val upgradedBookPath = val upgradedBookPath =
if (upgradedBookId != null) if (upgradedBook != null)
bookRepository.findByIdOrNull(upgradedBookId)?.path bookRepository.findByIdOrNull(upgradedBook.id)?.path
else null else null
var deletedUpgradedFile = false var deletedUpgradedFile = false
@ -78,7 +84,7 @@ class BookImporter(
logger.warn { "Could not delete upgraded book: $upgradedBookPath" } logger.warn { "Could not delete upgraded book: $upgradedBookPath" }
} }
} }
destFile.exists() -> throw FileAlreadyExistsException("Destination file already exists: $destFile") destFile.exists() -> throw FileAlreadyExistsException("Destination file already exists: $destFile").withCode("ERR_1021")
} }
when (copyMode) { when (copyMode) {
@ -101,13 +107,13 @@ class BookImporter(
val importedBook = fileSystemScanner.scanFile(destFile) val importedBook = fileSystemScanner.scanFile(destFile)
?.copy(libraryId = series.libraryId) ?.copy(libraryId = series.libraryId)
?: throw IllegalStateException("Newly imported book could not be scanned: $destFile") ?: throw IllegalStateException("Newly imported book could not be scanned: $destFile").withCode("ERR_1022")
seriesLifecycle.addBooks(series, listOf(importedBook)) seriesLifecycle.addBooks(series, listOf(importedBook))
if (upgradedBookId != null) { if (upgradedBook != null) {
// copy media and mark it as outdated // copy media and mark it as outdated
mediaRepository.findById(upgradedBookId).let { mediaRepository.findById(upgradedBook.id).let {
mediaRepository.update( mediaRepository.update(
it.copy( it.copy(
bookId = importedBook.id, bookId = importedBook.id,
@ -117,21 +123,21 @@ class BookImporter(
} }
// copy metadata // copy metadata
metadataRepository.findById(upgradedBookId).let { metadataRepository.findById(upgradedBook.id).let {
metadataRepository.update(it.copy(bookId = importedBook.id)) metadataRepository.update(it.copy(bookId = importedBook.id))
} }
// copy read progress // copy read progress
readProgressRepository.findAllByBookId(upgradedBookId) readProgressRepository.findAllByBookId(upgradedBook.id)
.map { it.copy(bookId = importedBook.id) } .map { it.copy(bookId = importedBook.id) }
.forEach { readProgressRepository.save(it) } .forEach { readProgressRepository.save(it) }
// replace upgraded book by imported book in read lists // replace upgraded book by imported book in read lists
readListRepository.findAllContainingBookId(upgradedBookId, filterOnLibraryIds = null) readListRepository.findAllContainingBookId(upgradedBook.id, filterOnLibraryIds = null)
.forEach { rl -> .forEach { rl ->
readListRepository.update( readListRepository.update(
rl.copy( rl.copy(
bookIds = rl.bookIds.values.map { if (it == upgradedBookId) importedBook.id else it }.toIndexedMap() bookIds = rl.bookIds.values.map { if (it == upgradedBook.id) importedBook.id else it }.toIndexedMap()
) )
) )
} }
@ -141,11 +147,18 @@ class BookImporter(
logger.info { "Deleted existing file: $upgradedBookPath" } logger.info { "Deleted existing file: $upgradedBookPath" }
// delete upgraded book // delete upgraded book
bookLifecycle.deleteOne(upgradedBookId) bookLifecycle.deleteOne(upgradedBook)
} }
seriesLifecycle.sortBooks(series) seriesLifecycle.sortBooks(series)
eventPublisher.publishEvent(DomainEvent.BookImported(importedBook, sourceFile.toUri().toURL(), success = true))
return importedBook 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
}
} }
} }

View file

@ -1,9 +1,11 @@
package org.gotson.komga.domain.service package org.gotson.komga.domain.service
import mu.KotlinLogging import mu.KotlinLogging
import org.gotson.komga.application.events.EventPublisher
import org.gotson.komga.domain.model.Book import org.gotson.komga.domain.model.Book
import org.gotson.komga.domain.model.BookPageContent import org.gotson.komga.domain.model.BookPageContent
import org.gotson.komga.domain.model.BookWithMedia 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.ImageConversionException
import org.gotson.komga.domain.model.KomgaUser import org.gotson.komga.domain.model.KomgaUser
import org.gotson.komga.domain.model.Media import org.gotson.komga.domain.model.Media
@ -34,7 +36,8 @@ class BookLifecycle(
private val thumbnailBookRepository: ThumbnailBookRepository, private val thumbnailBookRepository: ThumbnailBookRepository,
private val readListRepository: ReadListRepository, private val readListRepository: ReadListRepository,
private val bookAnalyzer: BookAnalyzer, private val bookAnalyzer: BookAnalyzer,
private val imageConverter: ImageConverter private val imageConverter: ImageConverter,
private val eventPublisher: EventPublisher,
) { ) {
fun analyzeAndPersist(book: Book): Boolean { fun analyzeAndPersist(book: Book): Boolean {
@ -49,6 +52,9 @@ class BookLifecycle(
} }
mediaRepository.update(media) mediaRepository.update(media)
eventPublisher.publishEvent(DomainEvent.BookUpdated(book))
return media.status == Media.Status.READY return media.status == Media.Status.READY
} }
@ -79,6 +85,8 @@ class BookLifecycle(
} }
} }
eventPublisher.publishEvent(DomainEvent.ThumbnailBookAdded(thumbnail))
if (thumbnail.selected) if (thumbnail.selected)
thumbnailBookRepository.markSelected(thumbnail) thumbnailBookRepository.markSelected(thumbnail)
else else
@ -187,21 +195,24 @@ class BookLifecycle(
} }
} }
fun deleteOne(bookId: String) { fun deleteOne(book: Book) {
logger.info { "Delete book id: $bookId" } logger.info { "Delete book id: ${book.id}" }
readProgressRepository.deleteByBookId(bookId) readProgressRepository.deleteByBookId(book.id)
readListRepository.removeBookFromAll(bookId) readListRepository.removeBookFromAll(book.id)
mediaRepository.delete(bookId) mediaRepository.delete(book.id)
thumbnailBookRepository.deleteByBookId(bookId) thumbnailBookRepository.deleteByBookId(book.id)
bookMetadataRepository.delete(bookId) bookMetadataRepository.delete(book.id)
bookRepository.delete(bookId) bookRepository.delete(book.id)
eventPublisher.publishEvent(DomainEvent.BookDeleted(book))
} }
fun deleteMany(bookIds: Collection<String>) { fun deleteMany(books: Collection<Book>) {
logger.info { "Delete all books: $bookIds" } val bookIds = books.map { it.id }
logger.info { "Delete book ids: $bookIds" }
readProgressRepository.deleteByBookIds(bookIds) readProgressRepository.deleteByBookIds(bookIds)
readListRepository.removeBooksFromAll(bookIds) readListRepository.removeBooksFromAll(bookIds)
@ -211,22 +222,31 @@ class BookLifecycle(
bookMetadataRepository.delete(bookIds) bookMetadataRepository.delete(bookIds)
bookRepository.delete(bookIds) bookRepository.delete(bookIds)
books.forEach { eventPublisher.publishEvent(DomainEvent.BookDeleted(it)) }
} }
fun markReadProgress(book: Book, user: KomgaUser, page: Int) { fun markReadProgress(book: Book, user: KomgaUser, page: Int) {
val pages = mediaRepository.getPagesSize(book.id) val pages = mediaRepository.getPagesSize(book.id)
require(page in 1..pages) { "Page argument ($page) must be within 1 and book page count ($pages)" } 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) { fun markReadProgressCompleted(bookId: String, user: KomgaUser) {
val media = mediaRepository.findById(bookId) 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) { fun deleteReadProgress(book: Book, user: KomgaUser) {
readProgressRepository.delete(bookId, user.id) 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 // delete series that don't exist anymore
if (scannedSeries.isEmpty()) { if (scannedSeries.isEmpty()) {
logger.info { "Scan returned no series, deleting all existing series" } logger.info { "Scan returned no series, deleting all existing series" }
val seriesIds = seriesRepository.findAllByLibraryId(library.id).map { it.id } val series = seriesRepository.findAllByLibraryId(library.id)
seriesLifecycle.deleteMany(seriesIds) seriesLifecycle.deleteMany(series)
} else { } else {
scannedSeries.keys.map { it.url }.let { urls -> scannedSeries.keys.map { it.url }.let { urls ->
val series = seriesRepository.findAllByLibraryIdAndUrlNotIn(library.id, urls) val series = seriesRepository.findAllByLibraryIdAndUrlNotIn(library.id, urls)
if (series.isNotEmpty()) { if (series.isNotEmpty()) {
logger.info { "Deleting series not on disk anymore: $series" } 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) } .filterNot { existingBook -> newBooksUrls.contains(existingBook.url) }
.let { books -> .let { books ->
logger.info { "Deleting books not on disk anymore: $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) } books.map { it.seriesId }.distinct().forEach { taskReceiver.refreshSeriesMetadata(it) }
} }

View file

@ -1,8 +1,10 @@
package org.gotson.komga.domain.service package org.gotson.komga.domain.service
import mu.KotlinLogging import mu.KotlinLogging
import org.gotson.komga.application.events.EventPublisher
import org.gotson.komga.application.tasks.TaskReceiver import org.gotson.komga.application.tasks.TaskReceiver
import org.gotson.komga.domain.model.DirectoryNotFoundException 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.DuplicateNameException
import org.gotson.komga.domain.model.Library import org.gotson.komga.domain.model.Library
import org.gotson.komga.domain.model.PathContainedInPath import org.gotson.komga.domain.model.PathContainedInPath
@ -21,7 +23,8 @@ class LibraryLifecycle(
private val seriesLifecycle: SeriesLifecycle, private val seriesLifecycle: SeriesLifecycle,
private val seriesRepository: SeriesRepository, private val seriesRepository: SeriesRepository,
private val sidecarRepository: SidecarRepository, private val sidecarRepository: SidecarRepository,
private val taskReceiver: TaskReceiver private val taskReceiver: TaskReceiver,
private val eventPublisher: EventPublisher,
) { ) {
@Throws( @Throws(
@ -39,6 +42,8 @@ class LibraryLifecycle(
libraryRepository.insert(library) libraryRepository.insert(library)
taskReceiver.scanLibrary(library.id) taskReceiver.scanLibrary(library.id)
eventPublisher.publishEvent(DomainEvent.LibraryAdded(library))
return libraryRepository.findById(library.id) return libraryRepository.findById(library.id)
} }
@ -50,6 +55,8 @@ class LibraryLifecycle(
libraryRepository.update(toUpdate) libraryRepository.update(toUpdate)
taskReceiver.scanLibrary(toUpdate.id) taskReceiver.scanLibrary(toUpdate.id)
eventPublisher.publishEvent(DomainEvent.LibraryUpdated(toUpdate))
} }
private fun checkLibraryValidity(library: Library, existing: Collection<Library>) { private fun checkLibraryValidity(library: Library, existing: Collection<Library>) {
@ -73,10 +80,12 @@ class LibraryLifecycle(
fun deleteLibrary(library: Library) { fun deleteLibrary(library: Library) {
logger.info { "Deleting library: $library" } logger.info { "Deleting library: $library" }
val seriesIds = seriesRepository.findAllByLibraryId(library.id).map { it.id } val series = seriesRepository.findAllByLibraryId(library.id)
seriesLifecycle.deleteMany(seriesIds) seriesLifecycle.deleteMany(series)
sidecarRepository.deleteByLibraryId(library.id) sidecarRepository.deleteByLibraryId(library.id)
libraryRepository.delete(library.id) libraryRepository.delete(library.id)
eventPublisher.publishEvent(DomainEvent.LibraryDeleted(library))
} }
} }

View file

@ -1,10 +1,12 @@
package org.gotson.komga.domain.service package org.gotson.komga.domain.service
import mu.KotlinLogging import mu.KotlinLogging
import org.gotson.komga.application.events.EventPublisher
import org.gotson.komga.domain.model.Book import org.gotson.komga.domain.model.Book
import org.gotson.komga.domain.model.BookMetadataPatch import org.gotson.komga.domain.model.BookMetadataPatch
import org.gotson.komga.domain.model.BookMetadataPatchCapability import org.gotson.komga.domain.model.BookMetadataPatchCapability
import org.gotson.komga.domain.model.BookWithMedia 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.ReadList
import org.gotson.komga.domain.model.Series import org.gotson.komga.domain.model.Series
import org.gotson.komga.domain.model.SeriesCollection import org.gotson.komga.domain.model.SeriesCollection
@ -42,6 +44,7 @@ class MetadataLifecycle(
private val collectionLifecycle: SeriesCollectionLifecycle, private val collectionLifecycle: SeriesCollectionLifecycle,
private val readListRepository: ReadListRepository, private val readListRepository: ReadListRepository,
private val readListLifecycle: ReadListLifecycle, private val readListLifecycle: ReadListLifecycle,
private val eventPublisher: EventPublisher,
) { ) {
fun refreshMetadata(book: Book, capabilities: List<BookMetadataPatchCapability>) { fun refreshMetadata(book: Book, capabilities: List<BookMetadataPatchCapability>) {
@ -49,6 +52,7 @@ class MetadataLifecycle(
val media = mediaRepository.findById(book.id) val media = mediaRepository.findById(book.id)
val library = libraryRepository.findById(book.libraryId) val library = libraryRepository.findById(book.libraryId)
var changed = false
bookMetadataProviders.forEach { provider -> bookMetadataProviders.forEach { provider ->
when { when {
@ -70,6 +74,7 @@ class MetadataLifecycle(
(provider is IsbnBarcodeProvider && library.importBarcodeIsbn) (provider is IsbnBarcodeProvider && library.importBarcodeIsbn)
) { ) {
handlePatchForBookMetadata(patch, book) handlePatchForBookMetadata(patch, book)
changed = true
} }
if (provider is ComicInfoProvider && library.importComicInfoReadList) { if (provider is ComicInfoProvider && library.importComicInfoReadList) {
@ -78,6 +83,8 @@ class MetadataLifecycle(
} }
} }
} }
if (changed) eventPublisher.publishEvent(DomainEvent.BookUpdated(book))
} }
private fun handlePatchForReadLists( private fun handlePatchForReadLists(
@ -138,6 +145,7 @@ class MetadataLifecycle(
logger.info { "Refresh metadata for series: $series" } logger.info { "Refresh metadata for series: $series" }
val library = libraryRepository.findById(series.libraryId) val library = libraryRepository.findById(series.libraryId)
var changed = false
seriesMetadataProviders.forEach { provider -> seriesMetadataProviders.forEach { provider ->
when { when {
@ -153,6 +161,7 @@ class MetadataLifecycle(
(provider is EpubMetadataProvider && library.importEpubSeries) (provider is EpubMetadataProvider && library.importEpubSeries)
) { ) {
handlePatchForSeriesMetadata(patches, series) handlePatchForSeriesMetadata(patches, series)
changed = true
} }
if (provider is ComicInfoProvider && library.importComicInfoCollection) { if (provider is ComicInfoProvider && library.importComicInfoCollection) {
@ -161,6 +170,8 @@ class MetadataLifecycle(
} }
} }
} }
if (changed) eventPublisher.publishEvent(DomainEvent.SeriesUpdated(series))
} }
private fun handlePatchForCollections( private fun handlePatchForCollections(
@ -226,6 +237,8 @@ class MetadataLifecycle(
val aggregation = metadataAggregator.aggregate(metadatas).copy(seriesId = series.id) val aggregation = metadataAggregator.aggregate(metadatas).copy(seriesId = series.id)
bookMetadataAggregationRepository.update(aggregation) bookMetadataAggregationRepository.update(aggregation)
eventPublisher.publishEvent(DomainEvent.SeriesUpdated(series))
} }
private fun <T, R : Any> Iterable<T>.mostFrequent(transform: (T) -> R?): R? { private fun <T, R : Any> Iterable<T>.mostFrequent(transform: (T) -> R?): R? {

View file

@ -1,6 +1,8 @@
package org.gotson.komga.domain.service package org.gotson.komga.domain.service
import mu.KotlinLogging 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.DuplicateNameException
import org.gotson.komga.domain.model.ReadList import org.gotson.komga.domain.model.ReadList
import org.gotson.komga.domain.model.ReadListRequestResult import org.gotson.komga.domain.model.ReadListRequestResult
@ -18,6 +20,7 @@ class ReadListLifecycle(
private val mosaicGenerator: MosaicGenerator, private val mosaicGenerator: MosaicGenerator,
private val readListMatcher: ReadListMatcher, private val readListMatcher: ReadListMatcher,
private val readListProvider: ReadListProvider, private val readListProvider: ReadListProvider,
private val eventPublisher: EventPublisher,
) { ) {
@Throws( @Throws(
@ -31,6 +34,8 @@ class ReadListLifecycle(
readListRepository.insert(readList) readListRepository.insert(readList)
eventPublisher.publishEvent(DomainEvent.ReadListAdded(readList))
return readListRepository.findByIdOrNull(readList.id)!! return readListRepository.findByIdOrNull(readList.id)!!
} }
@ -43,10 +48,14 @@ class ReadListLifecycle(
throw DuplicateNameException("Read list name already exists") throw DuplicateNameException("Read list name already exists")
readListRepository.update(toUpdate) readListRepository.update(toUpdate)
eventPublisher.publishEvent(DomainEvent.ReadListUpdated(toUpdate))
} }
fun deleteReadList(readListId: String) { fun deleteReadList(readList: ReadList) {
readListRepository.delete(readListId) readListRepository.delete(readList.id)
eventPublisher.publishEvent(DomainEvent.ReadListDeleted(readList))
} }
fun getThumbnailBytes(readList: ReadList): ByteArray { fun getThumbnailBytes(readList: ReadList): ByteArray {

View file

@ -1,6 +1,8 @@
package org.gotson.komga.domain.service package org.gotson.komga.domain.service
import mu.KotlinLogging 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.DuplicateNameException
import org.gotson.komga.domain.model.SeriesCollection import org.gotson.komga.domain.model.SeriesCollection
import org.gotson.komga.domain.persistence.SeriesCollectionRepository import org.gotson.komga.domain.persistence.SeriesCollectionRepository
@ -13,7 +15,8 @@ private val logger = KotlinLogging.logger {}
class SeriesCollectionLifecycle( class SeriesCollectionLifecycle(
private val collectionRepository: SeriesCollectionRepository, private val collectionRepository: SeriesCollectionRepository,
private val seriesLifecycle: SeriesLifecycle, private val seriesLifecycle: SeriesLifecycle,
private val mosaicGenerator: MosaicGenerator private val mosaicGenerator: MosaicGenerator,
private val eventPublisher: EventPublisher,
) { ) {
@Throws( @Throws(
@ -27,6 +30,8 @@ class SeriesCollectionLifecycle(
collectionRepository.insert(collection) collectionRepository.insert(collection)
eventPublisher.publishEvent(DomainEvent.CollectionAdded(collection))
return collectionRepository.findByIdOrNull(collection.id)!! return collectionRepository.findByIdOrNull(collection.id)!!
} }
@ -40,10 +45,13 @@ class SeriesCollectionLifecycle(
throw DuplicateNameException("Collection name already exists") throw DuplicateNameException("Collection name already exists")
collectionRepository.update(toUpdate) collectionRepository.update(toUpdate)
eventPublisher.publishEvent(DomainEvent.CollectionUpdated(toUpdate))
} }
fun deleteCollection(collectionId: String) { fun deleteCollection(collection: SeriesCollection) {
collectionRepository.delete(collectionId) collectionRepository.delete(collection.id)
eventPublisher.publishEvent(DomainEvent.CollectionDeleted(collection))
} }
fun getThumbnailBytes(collection: SeriesCollection): ByteArray { fun getThumbnailBytes(collection: SeriesCollection): ByteArray {

View file

@ -3,11 +3,13 @@ package org.gotson.komga.domain.service
import mu.KotlinLogging import mu.KotlinLogging
import net.greypanther.natsort.CaseInsensitiveSimpleNaturalComparator import net.greypanther.natsort.CaseInsensitiveSimpleNaturalComparator
import org.apache.commons.lang3.StringUtils import org.apache.commons.lang3.StringUtils
import org.gotson.komga.application.events.EventPublisher
import org.gotson.komga.application.tasks.TaskReceiver import org.gotson.komga.application.tasks.TaskReceiver
import org.gotson.komga.domain.model.Book import org.gotson.komga.domain.model.Book
import org.gotson.komga.domain.model.BookMetadata import org.gotson.komga.domain.model.BookMetadata
import org.gotson.komga.domain.model.BookMetadataAggregation import org.gotson.komga.domain.model.BookMetadataAggregation
import org.gotson.komga.domain.model.BookMetadataPatchCapability 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.KomgaUser
import org.gotson.komga.domain.model.Media import org.gotson.komga.domain.model.Media
import org.gotson.komga.domain.model.ReadProgress import org.gotson.komga.domain.model.ReadProgress
@ -43,7 +45,8 @@ class SeriesLifecycle(
private val bookMetadataAggregationRepository: BookMetadataAggregationRepository, private val bookMetadataAggregationRepository: BookMetadataAggregationRepository,
private val collectionRepository: SeriesCollectionRepository, private val collectionRepository: SeriesCollectionRepository,
private val readProgressRepository: ReadProgressRepository, private val readProgressRepository: ReadProgressRepository,
private val taskReceiver: TaskReceiver private val taskReceiver: TaskReceiver,
private val eventPublisher: EventPublisher,
) { ) {
fun sortBooks(series: Series) { fun sortBooks(series: Series) {
@ -90,16 +93,15 @@ class SeriesLifecycle(
booksToAdd.forEach { booksToAdd.forEach {
check(it.libraryId == series.libraryId) { "Cannot add book to series if they don't share the same libraryId" } 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( bookRepository.insert(toAdd)
booksToAdd.map { it.copy(seriesId = series.id) }
)
// create associated media // create associated media
mediaRepository.insert(booksToAdd.map { Media(bookId = it.id) }) mediaRepository.insert(toAdd.map { Media(bookId = it.id) })
// create associated metadata // create associated metadata
booksToAdd.map { toAdd.map {
BookMetadata( BookMetadata(
title = it.name, title = it.name,
number = it.number.toString(), number = it.number.toString(),
@ -107,6 +109,8 @@ class SeriesLifecycle(
bookId = it.id bookId = it.id
) )
}.let { bookMetadataRepository.insert(it) } }.let { bookMetadataRepository.insert(it) }
toAdd.forEach { eventPublisher.publishEvent(DomainEvent.BookAdded(it)) }
} }
fun createSeries(series: Series): Series { fun createSeries(series: Series): Series {
@ -124,28 +128,17 @@ class SeriesLifecycle(
BookMetadataAggregation(seriesId = series.id) BookMetadataAggregation(seriesId = series.id)
) )
eventPublisher.publishEvent(DomainEvent.SeriesAdded(series))
return seriesRepository.findByIdOrNull(series.id)!! return seriesRepository.findByIdOrNull(series.id)!!
} }
fun deleteOne(seriesId: String) { fun deleteMany(series: Collection<Series>) {
logger.info { "Delete series id: $seriesId" } val seriesIds = series.map { it.id }
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>) {
logger.info { "Delete series ids: $seriesIds" } logger.info { "Delete series ids: $seriesIds" }
val bookIds = bookRepository.findAllIdsBySeriesIds(seriesIds) val books = bookRepository.findAllBySeriesIds(seriesIds)
bookLifecycle.deleteMany(bookIds) bookLifecycle.deleteMany(books)
collectionRepository.removeSeriesFromAll(seriesIds) collectionRepository.removeSeriesFromAll(seriesIds)
thumbnailsSeriesRepository.deleteBySeriesIds(seriesIds) thumbnailsSeriesRepository.deleteBySeriesIds(seriesIds)
@ -153,6 +146,8 @@ class SeriesLifecycle(
bookMetadataAggregationRepository.delete(seriesIds) bookMetadataAggregationRepository.delete(seriesIds)
seriesRepository.delete(seriesIds) seriesRepository.delete(seriesIds)
series.forEach { eventPublisher.publishEvent(DomainEvent.SeriesDeleted(it)) }
} }
fun markReadProgressCompleted(seriesId: String, user: KomgaUser) { fun markReadProgressCompleted(seriesId: String, user: KomgaUser) {
@ -160,10 +155,15 @@ class SeriesLifecycle(
.map { (bookId, pageSize) -> ReadProgress(bookId, user.id, pageSize, true) } .map { (bookId, pageSize) -> ReadProgress(bookId, user.id, pageSize, true) }
readProgressRepository.save(progresses) readProgressRepository.save(progresses)
progresses.forEach { eventPublisher.publishEvent(DomainEvent.ReadProgressChanged(it)) }
} }
fun deleteReadProgress(seriesId: String, user: KomgaUser) { 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? { fun getThumbnail(seriesId: String): ThumbnailSeries? {
@ -197,6 +197,8 @@ class SeriesLifecycle(
} }
thumbnailsSeriesRepository.insert(thumbnail) thumbnailsSeriesRepository.insert(thumbnail)
eventPublisher.publishEvent(DomainEvent.ThumbnailSeriesAdded(thumbnail))
if (thumbnail.selected) if (thumbnail.selected)
thumbnailsSeriesRepository.markSelected(thumbnail) thumbnailsSeriesRepository.markSelected(thumbnail)
} }
@ -224,5 +226,6 @@ class SeriesLifecycle(
} }
} }
} }
private fun ThumbnailSeries.exists(): Boolean = Files.exists(Paths.get(url.toURI())) 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.QueueConfiguration
import org.apache.activemq.artemis.api.core.RoutingType import org.apache.activemq.artemis.api.core.RoutingType
import org.apache.activemq.artemis.core.settings.impl.AddressSettings 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.boot.autoconfigure.jms.artemis.ArtemisConfigurationCustomizer
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration 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 import org.apache.activemq.artemis.core.config.Configuration as ArtemisConfiguration
const val QUEUE_UNIQUE_ID = "unique_id" const val QUEUE_UNIQUE_ID = "unique_id"
const val QUEUE_TYPE = "type" const val QUEUE_TYPE = "type"
const val QUEUE_TASKS = "tasks.background" const val QUEUE_TASKS = "tasks.background"
const val QUEUE_TASKS_TYPE = "task" const val QUEUE_TASKS_TYPE = "task"
const val QUEUE_TASKS_SELECTOR = "$QUEUE_TYPE = '$QUEUE_TASKS_TYPE'" 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 @Configuration
class ArtemisConfig : ArtemisConfigurationCustomizer { class ArtemisConfig : ArtemisConfigurationCustomizer {
override fun customize(configuration: ArtemisConfiguration?) { override fun customize(configuration: ArtemisConfiguration?) {
@ -32,6 +43,21 @@ class ArtemisConfig : ArtemisConfigurationCustomizer {
.setLastValueKey(QUEUE_UNIQUE_ID) .setLastValueKey(QUEUE_UNIQUE_ID)
.setRoutingType(RoutingType.ANYCAST) .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) .fetchInto(b)
.map { it.toDomain() } .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> = override fun findAll(): Collection<Book> =
dsl.select(*b.fields()) dsl.select(*b.fields())
.from(b) .from(b)
@ -106,6 +112,12 @@ class BookDao(
.where(b.ID.eq(bookId)) .where(b.ID.eq(bookId))
.fetchOne(b.LIBRARY_ID) .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? = override fun findFirstIdInSeriesOrNull(seriesId: String): String? =
dsl.select(b.ID) dsl.select(b.ID)
.from(b) .from(b)

View file

@ -43,6 +43,12 @@ class ReadProgressDao(
.fetchInto(r) .fetchInto(r)
.map { it.toDomain() } .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) { override fun save(readProgress: ReadProgress) {
dsl.transaction { config -> dsl.transaction { config ->
config.dsl().saveQuery(readProgress).execute() config.dsl().saveQuery(readProgress).execute()

View file

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

View file

@ -7,11 +7,13 @@ import io.swagger.v3.oas.annotations.media.Schema
import io.swagger.v3.oas.annotations.responses.ApiResponse import io.swagger.v3.oas.annotations.responses.ApiResponse
import mu.KotlinLogging import mu.KotlinLogging
import org.apache.commons.io.IOUtils 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.HIGHEST_PRIORITY
import org.gotson.komga.application.tasks.HIGH_PRIORITY import org.gotson.komga.application.tasks.HIGH_PRIORITY
import org.gotson.komga.application.tasks.TaskReceiver import org.gotson.komga.application.tasks.TaskReceiver
import org.gotson.komga.domain.model.Author import org.gotson.komga.domain.model.Author
import org.gotson.komga.domain.model.BookSearchWithReadProgress 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.ImageConversionException
import org.gotson.komga.domain.model.Media import org.gotson.komga.domain.model.Media
import org.gotson.komga.domain.model.MediaNotReadyException import org.gotson.komga.domain.model.MediaNotReadyException
@ -86,6 +88,7 @@ class BookController(
private val bookDtoRepository: BookDtoRepository, private val bookDtoRepository: BookDtoRepository,
private val readListRepository: ReadListRepository, private val readListRepository: ReadListRepository,
private val contentDetector: ContentDetector, private val contentDetector: ContentDetector,
private val eventPublisher: EventPublisher,
) { ) {
@PageableAsQueryParam @PageableAsQueryParam
@ -471,6 +474,8 @@ class BookController(
} }
bookMetadataRepository.update(updated) bookMetadataRepository.update(updated)
taskReceiver.aggregateSeriesMetadata(bookRepository.findByIdOrNull(bookId)!!.seriesId) taskReceiver.aggregateSeriesMetadata(bookRepository.findByIdOrNull(bookId)!!.seriesId)
bookRepository.findByIdOrNull(bookId)?.let { eventPublisher.publishEvent(DomainEvent.BookUpdated(it)) }
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
@PatchMapping("api/v1/books/{bookId}/read-progress") @PatchMapping("api/v1/books/{bookId}/read-progress")
@ -504,7 +509,7 @@ class BookController(
bookRepository.findByIdOrNull(bookId)?.let { book -> bookRepository.findByIdOrNull(bookId)?.let { book ->
if (!principal.user.canAccessBook(book)) throw ResponseStatusException(HttpStatus.FORBIDDEN) 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) } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
} }

View file

@ -179,7 +179,7 @@ class ReadListController(
@PathVariable id: String @PathVariable id: String
) { ) {
readListRepository.findByIdOrNull(id)?.let { readListRepository.findByIdOrNull(id)?.let {
readListLifecycle.deleteReadList(it.id) readListLifecycle.deleteReadList(it)
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
} }

View file

@ -152,7 +152,7 @@ class SeriesCollectionController(
@PathVariable id: String @PathVariable id: String
) { ) {
collectionRepository.findByIdOrNull(id)?.let { collectionRepository.findByIdOrNull(id)?.let {
collectionLifecycle.deleteCollection(it.id) collectionLifecycle.deleteCollection(it)
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) } ?: 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.ZipArchiveEntry
import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream
import org.apache.commons.io.IOUtils 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.HIGH_PRIORITY
import org.gotson.komga.application.tasks.TaskReceiver import org.gotson.komga.application.tasks.TaskReceiver
import org.gotson.komga.domain.model.Author import org.gotson.komga.domain.model.Author
import org.gotson.komga.domain.model.BookSearchWithReadProgress 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.Media
import org.gotson.komga.domain.model.ROLE_ADMIN import org.gotson.komga.domain.model.ROLE_ADMIN
import org.gotson.komga.domain.model.ROLE_FILE_DOWNLOAD import org.gotson.komga.domain.model.ROLE_FILE_DOWNLOAD
@ -86,6 +88,7 @@ class SeriesController(
private val bookDtoRepository: BookDtoRepository, private val bookDtoRepository: BookDtoRepository,
private val collectionRepository: SeriesCollectionRepository, private val collectionRepository: SeriesCollectionRepository,
private val readProgressDtoRepository: ReadProgressDtoRepository, private val readProgressDtoRepository: ReadProgressDtoRepository,
private val eventPublisher: EventPublisher,
) { ) {
@PageableAsQueryParam @PageableAsQueryParam
@ -353,6 +356,8 @@ class SeriesController(
) )
} }
seriesMetadataRepository.update(updated) seriesMetadataRepository.update(updated)
seriesRepository.findByIdOrNull(seriesId)?.let { eventPublisher.publishEvent(DomainEvent.SeriesUpdated(it)) }
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
@PostMapping("{seriesId}/read-progress") @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

@ -4,7 +4,7 @@ komga:
validity: 2592000 # 1 month validity: 2592000 # 1 month
# libraries-scan-cron: "*/5 * * * * ?" #every 5 seconds # libraries-scan-cron: "*/5 * * * * ?" #every 5 seconds
libraries-scan-cron: "-" #disable libraries-scan-cron: "-" #disable
libraries-scan-startup: true libraries-scan-startup: false
database: database:
file: ":memory:" file: ":memory:"
cors.allowed-origins: cors.allowed-origins:

View file

@ -5,7 +5,7 @@ logging:
file: file:
name: \${user.home}/.komga/komga.log name: \${user.home}/.komga/komga.log
level: level:
org.apache.activemq.audit.message: WARN org.apache.activemq.audit: WARN
komga: komga:
libraries-scan-cron: "0 */15 * * * ?" libraries-scan-cron: "0 */15 * * * ?"

View file

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

View file

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

View file

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

View file

@ -42,7 +42,7 @@ class SeriesLifecycleTest(
@AfterEach @AfterEach
fun `clear repository`() { fun `clear repository`() {
seriesLifecycle.deleteMany(seriesRepository.findAll().map { it.id }) seriesLifecycle.deleteMany(seriesRepository.findAll())
} }
@Test @Test
@ -88,7 +88,7 @@ class SeriesLifecycleTest(
// when // when
val book = bookRepository.findAllBySeriesId(createdSeries.id).first { it.name == "book 2" } val book = bookRepository.findAllBySeriesId(createdSeries.id).first { it.name == "book 2" }
bookLifecycle.deleteOne(book.id) bookLifecycle.deleteOne(book)
seriesLifecycle.sortBooks(createdSeries) seriesLifecycle.sortBooks(createdSeries)
// then // then

View file

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

View file

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

View file

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

View file

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