diff --git a/komga/src/flyway/resources/db/migration/sqlite/V20220218111455__historical_events.sql b/komga/src/flyway/resources/db/migration/sqlite/V20220218111455__historical_events.sql new file mode 100644 index 000000000..e1a98453d --- /dev/null +++ b/komga/src/flyway/resources/db/migration/sqlite/V20220218111455__historical_events.sql @@ -0,0 +1,17 @@ +CREATE TABLE HISTORICAL_EVENT +( + ID varchar PRIMARY KEY, + TYPE varchar NOT NULL, + BOOK_ID varchar NULL, + SERIES_ID varchar NULL, + TIMESTAMP datetime NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE HISTORICAL_EVENT_PROPERTIES +( + ID varchar NOT NULL, + KEY varchar NOT NULL, + VALUE varchar NOT NULL, + PRIMARY KEY (ID, KEY), + FOREIGN KEY (ID) REFERENCES HISTORICAL_EVENT (ID) +); diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/model/HistoricalEvent.kt b/komga/src/main/kotlin/org/gotson/komga/domain/model/HistoricalEvent.kt new file mode 100644 index 000000000..5f178d1de --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/domain/model/HistoricalEvent.kt @@ -0,0 +1,70 @@ +package org.gotson.komga.domain.model + +import com.github.f4b6a3.tsid.TsidCreator +import java.nio.file.Path +import java.time.LocalDateTime + +sealed class HistoricalEvent( + val type: String, + val bookId: String? = null, + val seriesId: String? = null, + val properties: Map = emptyMap(), + val timestamp: LocalDateTime = LocalDateTime.now(), + val id: String = TsidCreator.getTsid256().toString(), +) { + class BookFileDeleted(book: Book, reason: String) : HistoricalEvent( + type = "BookFileDeleted", + bookId = book.id, + seriesId = book.seriesId, + properties = mapOf( + "reason" to reason, + "name" to book.path.toString(), + ), + ) + + class SeriesFolderDeleted(seriesId: String, seriesPath: Path, reason: String) : HistoricalEvent( + type = "SeriesFolderDeleted", + seriesId = seriesId, + properties = mapOf( + "reason" to reason, + "name" to seriesPath.toString(), + ), + ) { + constructor(series: Series, reason: String) : this(series.id, series.path, reason) + } + + class BookConverted(book: Book, previous: Book) : HistoricalEvent( + type = "BookConverted", + bookId = book.id, + seriesId = book.seriesId, + properties = mapOf( + "name" to book.path.toString(), + "former file" to previous.path.toString(), + ), + ) + + class BookImported(book: Book, series: Series, source: Path, upgrade: Boolean) : HistoricalEvent( + type = "BookImported", + bookId = book.id, + seriesId = series.id, + properties = mapOf( + "name" to book.path.toString(), + "source" to source.toString(), + "upgrade" to if (upgrade) "Yes" else "No", + ), + ) + + class DuplicatePageDeleted(book: Book, page: BookPageNumbered) : HistoricalEvent( + type = "DuplicatePageDeleted", + bookId = book.id, + seriesId = book.seriesId, + properties = mapOf( + "name" to book.path.toString(), + "page number" to page.pageNumber.toString(), + "page file name" to page.fileName, + "page file hash" to page.fileHash, + "page file size" to page.fileSize.toString(), + "page media type" to page.mediaType, + ), + ) +} diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/persistence/HistoricalEventRepository.kt b/komga/src/main/kotlin/org/gotson/komga/domain/persistence/HistoricalEventRepository.kt new file mode 100644 index 000000000..113b6a867 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/domain/persistence/HistoricalEventRepository.kt @@ -0,0 +1,7 @@ +package org.gotson.komga.domain.persistence + +import org.gotson.komga.domain.model.HistoricalEvent + +interface HistoricalEventRepository { + fun insert(event: HistoricalEvent) +} diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/service/BookConverter.kt b/komga/src/main/kotlin/org/gotson/komga/domain/service/BookConverter.kt index 536942502..4cbdeceae 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/service/BookConverter.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/service/BookConverter.kt @@ -9,6 +9,7 @@ import org.gotson.komga.domain.model.Book import org.gotson.komga.domain.model.BookConversionException import org.gotson.komga.domain.model.BookWithMedia import org.gotson.komga.domain.model.DomainEvent +import org.gotson.komga.domain.model.HistoricalEvent import org.gotson.komga.domain.model.Library import org.gotson.komga.domain.model.Media import org.gotson.komga.domain.model.MediaNotReadyException @@ -16,6 +17,7 @@ import org.gotson.komga.domain.model.MediaType import org.gotson.komga.domain.model.MediaUnsupportedException import org.gotson.komga.domain.model.restoreHashFrom import org.gotson.komga.domain.persistence.BookRepository +import org.gotson.komga.domain.persistence.HistoricalEventRepository import org.gotson.komga.domain.persistence.LibraryRepository import org.gotson.komga.domain.persistence.MediaRepository import org.gotson.komga.infrastructure.language.notEquals @@ -45,6 +47,7 @@ class BookConverter( private val libraryRepository: LibraryRepository, private val transactionTemplate: TransactionTemplate, private val eventPublisher: EventPublisher, + private val historicalEventRepository: HistoricalEventRepository, ) { private val convertibleTypes = listOf(MediaType.RAR_4.value) @@ -140,8 +143,10 @@ class BookConverter( throw e } - if (book.path.deleteIfExists()) + if (book.path.deleteIfExists()) { logger.info { "Deleted old file: ${book.path}" } + historicalEventRepository.insert(HistoricalEvent.BookFileDeleted(book, "File was deleted after conversion to CBZ")) + } val mediaWithHashes = convertedMedia.copy(pages = convertedMedia.pages.restoreHashFrom(media.pages)) @@ -150,6 +155,7 @@ class BookConverter( mediaRepository.update(mediaWithHashes) } + historicalEventRepository.insert(HistoricalEvent.BookConverted(convertedBook, book)) eventPublisher.publishEvent(DomainEvent.BookUpdated(convertedBook)) } diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/service/BookImporter.kt b/komga/src/main/kotlin/org/gotson/komga/domain/service/BookImporter.kt index d48274e2a..f9c23b868 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/service/BookImporter.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/service/BookImporter.kt @@ -7,6 +7,7 @@ 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.HistoricalEvent import org.gotson.komga.domain.model.Media import org.gotson.komga.domain.model.PathContainedInPath import org.gotson.komga.domain.model.Series @@ -14,6 +15,7 @@ import org.gotson.komga.domain.model.Sidecar 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.HistoricalEventRepository import org.gotson.komga.domain.persistence.LibraryRepository import org.gotson.komga.domain.persistence.MediaRepository import org.gotson.komga.domain.persistence.ReadListRepository @@ -56,6 +58,7 @@ class BookImporter( private val sidecarRepository: SidecarRepository, private val eventPublisher: EventPublisher, private val taskEmitter: TaskEmitter, + private val historicalEventRepository: HistoricalEventRepository, ) { fun importBook(sourceFile: Path, series: Series, copyMode: CopyMode, destinationName: String? = null, upgradeBookId: String? = null): Book { @@ -90,6 +93,7 @@ class BookImporter( logger.info { "Deleting existing file: ${bookToUpgrade.path}" } try { bookToUpgrade.path.deleteExisting() + historicalEventRepository.insert(HistoricalEvent.BookFileDeleted(bookToUpgrade, "File was deleted to import an upgrade")) deletedUpgradedFile = true } catch (e: NoSuchFileException) { logger.warn { "Could not delete upgraded book: ${bookToUpgrade.path}" } @@ -184,8 +188,10 @@ class BookImporter( } // delete upgraded book file on disk if it has not been replaced earlier - if (!deletedUpgradedFile && bookToUpgrade.path.deleteIfExists()) + if (!deletedUpgradedFile && bookToUpgrade.path.deleteIfExists()) { logger.info { "Deleted existing file: ${bookToUpgrade.path}" } + historicalEventRepository.insert(HistoricalEvent.BookFileDeleted(bookToUpgrade, "File was deleted to import an upgrade")) + } // delete upgraded book bookLifecycle.deleteOne(bookToUpgrade) @@ -206,6 +212,7 @@ class BookImporter( sidecarRepository.save(importedBook.libraryId, destSidecar) } + historicalEventRepository.insert(HistoricalEvent.BookImported(importedBook, series, sourceFile, upgradeBookId != null)) eventPublisher.publishEvent(DomainEvent.BookImported(importedBook, sourceFile.toUri().toURL(), success = true)) return importedBook diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/service/BookLifecycle.kt b/komga/src/main/kotlin/org/gotson/komga/domain/service/BookLifecycle.kt index d5514ef3a..42c96657a 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/service/BookLifecycle.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/service/BookLifecycle.kt @@ -6,6 +6,7 @@ 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.HistoricalEvent import org.gotson.komga.domain.model.ImageConversionException import org.gotson.komga.domain.model.KomgaUser import org.gotson.komga.domain.model.MarkSelectedPreference @@ -15,6 +16,7 @@ import org.gotson.komga.domain.model.ReadProgress import org.gotson.komga.domain.model.ThumbnailBook import org.gotson.komga.domain.persistence.BookMetadataRepository import org.gotson.komga.domain.persistence.BookRepository +import org.gotson.komga.domain.persistence.HistoricalEventRepository import org.gotson.komga.domain.persistence.LibraryRepository import org.gotson.komga.domain.persistence.MediaRepository import org.gotson.komga.domain.persistence.ReadListRepository @@ -50,6 +52,7 @@ class BookLifecycle( private val eventPublisher: EventPublisher, private val transactionTemplate: TransactionTemplate, private val hasher: Hasher, + private val historicalEventRepository: HistoricalEventRepository, ) { fun analyzeAndPersist(book: Book): Boolean { @@ -327,13 +330,19 @@ class BookLifecycle( .mapNotNull { it.url?.toURI()?.toPath() } .filter { it.exists() && it.isWritable() } - if (book.path.deleteIfExists()) logger.info { "Deleted file: ${book.path}" } + if (book.path.deleteIfExists()) { + logger.info { "Deleted file: ${book.path}" } + historicalEventRepository.insert(HistoricalEvent.BookFileDeleted(book, "File was deleted by user request")) + } thumbnails.forEach { if (it.deleteIfExists()) logger.info { "Deleted file: $it" } } if (book.path.parent.listDirectoryEntries().isEmpty()) - if (book.path.parent.deleteIfExists()) logger.info { "Deleted directory: ${book.path.parent}" } + if (book.path.parent.deleteIfExists()) { + logger.info { "Deleted directory: ${book.path.parent}" } + historicalEventRepository.insert(HistoricalEvent.SeriesFolderDeleted(book.seriesId, book.path.parent, "Folder was deleted because it was empty")) + } softDeleteMany(listOf(book)) } diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/service/BookPageEditor.kt b/komga/src/main/kotlin/org/gotson/komga/domain/service/BookPageEditor.kt index 7684512ae..191f2333f 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/service/BookPageEditor.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/service/BookPageEditor.kt @@ -10,6 +10,7 @@ import org.gotson.komga.domain.model.BookConversionException import org.gotson.komga.domain.model.BookPageNumbered import org.gotson.komga.domain.model.BookWithMedia import org.gotson.komga.domain.model.DomainEvent +import org.gotson.komga.domain.model.HistoricalEvent import org.gotson.komga.domain.model.Media import org.gotson.komga.domain.model.MediaNotReadyException import org.gotson.komga.domain.model.MediaType @@ -17,6 +18,7 @@ import org.gotson.komga.domain.model.MediaUnsupportedException import org.gotson.komga.domain.model.PageHash import org.gotson.komga.domain.model.restoreHashFrom import org.gotson.komga.domain.persistence.BookRepository +import org.gotson.komga.domain.persistence.HistoricalEventRepository import org.gotson.komga.domain.persistence.LibraryRepository import org.gotson.komga.domain.persistence.MediaRepository import org.gotson.komga.domain.persistence.PageHashRepository @@ -44,6 +46,7 @@ class BookPageEditor( private val pageHashRepository: PageHashRepository, private val transactionTemplate: TransactionTemplate, private val eventPublisher: EventPublisher, + private val historicalEventRepository: HistoricalEventRepository, ) { private val convertibleTypes = listOf(MediaType.ZIP.value) @@ -151,6 +154,7 @@ class BookPageEditor( .forEach { pageHashRepository.update(it.copy(deleteCount = it.deleteCount + 1)) } } + pagesToDelete.forEach { historicalEventRepository.insert(HistoricalEvent.DuplicatePageDeleted(book, it)) } eventPublisher.publishEvent(DomainEvent.BookUpdated(newBook)) } } diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/service/SeriesLifecycle.kt b/komga/src/main/kotlin/org/gotson/komga/domain/service/SeriesLifecycle.kt index ed55d0949..4bf997ab6 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/service/SeriesLifecycle.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/service/SeriesLifecycle.kt @@ -10,6 +10,7 @@ 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.HistoricalEvent import org.gotson.komga.domain.model.KomgaUser import org.gotson.komga.domain.model.Library import org.gotson.komga.domain.model.MarkSelectedPreference @@ -21,6 +22,7 @@ import org.gotson.komga.domain.model.ThumbnailSeries import org.gotson.komga.domain.persistence.BookMetadataAggregationRepository import org.gotson.komga.domain.persistence.BookMetadataRepository import org.gotson.komga.domain.persistence.BookRepository +import org.gotson.komga.domain.persistence.HistoricalEventRepository import org.gotson.komga.domain.persistence.LibraryRepository import org.gotson.komga.domain.persistence.MediaRepository import org.gotson.komga.domain.persistence.ReadProgressRepository @@ -59,6 +61,7 @@ class SeriesLifecycle( private val taskEmitter: TaskEmitter, private val eventPublisher: EventPublisher, private val transactionTemplate: TransactionTemplate, + private val historicalEventRepository: HistoricalEventRepository, ) { private val whitespacePattern = """\s+""".toRegex() @@ -305,7 +308,10 @@ class SeriesLifecycle( } if (series.path.exists() && series.path.listDirectoryEntries().isEmpty()) - if (series.path.deleteIfExists()) logger.info { "Deleted directory: ${series.path}" } + if (series.path.deleteIfExists()) { + logger.info { "Deleted directory: ${series.path}" } + historicalEventRepository.insert(HistoricalEvent.SeriesFolderDeleted(series, "Folder was deleted because it was empty")) + } softDeleteMany(listOf(series)) } diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/HistoricalEventDao.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/HistoricalEventDao.kt new file mode 100644 index 000000000..cc77791b4 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/HistoricalEventDao.kt @@ -0,0 +1,39 @@ +package org.gotson.komga.infrastructure.jooq + +import org.gotson.komga.domain.model.HistoricalEvent +import org.gotson.komga.domain.persistence.HistoricalEventRepository +import org.gotson.komga.jooq.Tables +import org.jooq.DSLContext +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional + +@Component +class HistoricalEventDao( + private val dsl: DSLContext, +) : HistoricalEventRepository { + + private val e = Tables.HISTORICAL_EVENT + private val ep = Tables.HISTORICAL_EVENT_PROPERTIES + + @Transactional + override fun insert(event: HistoricalEvent) { + dsl.insertInto(e) + .set(e.ID, event.id) + .set(e.TYPE, event.type) + .set(e.BOOK_ID, event.bookId) + .set(e.SERIES_ID, event.seriesId) + .set(e.TIMESTAMP, event.timestamp) + .execute() + + if (event.properties.isNotEmpty()) { + dsl.batch( + dsl.insertInto(ep, ep.ID, ep.KEY, ep.VALUE) + .values(null as String?, null, null), + ).also { step -> + event.properties.forEach { (key, value) -> + step.bind(event.id, key, value) + } + }.execute() + } + } +} diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/HistoricalEventDtoDao.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/HistoricalEventDtoDao.kt new file mode 100644 index 000000000..e6e4b7d85 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/HistoricalEventDtoDao.kt @@ -0,0 +1,56 @@ +package org.gotson.komga.infrastructure.jooq + +import org.gotson.komga.interfaces.api.persistence.HistoricalEventDtoRepository +import org.gotson.komga.interfaces.api.rest.dto.HistoricalEventDto +import org.gotson.komga.jooq.Tables +import org.jooq.DSLContext +import org.springframework.data.domain.Page +import org.springframework.data.domain.PageImpl +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Pageable +import org.springframework.data.domain.Sort +import org.springframework.stereotype.Component + +@Component +class HistoricalEventDtoDao( + private val dsl: DSLContext, +) : HistoricalEventDtoRepository { + + private val e = Tables.HISTORICAL_EVENT + private val ep = Tables.HISTORICAL_EVENT_PROPERTIES + + private val sorts = mapOf( + "type" to e.TYPE, + "bookId" to e.BOOK_ID, + "seriesId" to e.SERIES_ID, + "timestamp" to e.TIMESTAMP, + ) + + override fun findAll(pageable: Pageable): Page { + val count = dsl.fetchCount(e) + + val orderBy = pageable.sort.toOrderBy(sorts) + + val items = dsl.selectFrom(e) + .orderBy(orderBy) + .apply { if (pageable.isPaged) limit(pageable.pageSize).offset(pageable.offset) } + .map { er -> + val epr = dsl.selectFrom(ep).where(ep.ID.eq(er.id)).fetch() + HistoricalEventDto( + type = er.type, + timestamp = er.timestamp, + bookId = er.bookId, + seriesId = er.seriesId, + properties = epr.filterNot { it.key == null }.associate { it.key to it.value }, + ) + } + + val pageSort = if (orderBy.isNotEmpty()) pageable.sort else Sort.unsorted() + return PageImpl( + items, + if (pageable.isPaged) PageRequest.of(pageable.pageNumber, pageable.pageSize, pageSort) + else PageRequest.of(0, maxOf(count, 20), pageSort), + count.toLong(), + ) + } +} diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/persistence/HistoricalEventDtoRepository.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/persistence/HistoricalEventDtoRepository.kt new file mode 100644 index 000000000..0f6e9da80 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/persistence/HistoricalEventDtoRepository.kt @@ -0,0 +1,9 @@ +package org.gotson.komga.interfaces.api.persistence + +import org.gotson.komga.interfaces.api.rest.dto.HistoricalEventDto +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable + +interface HistoricalEventDtoRepository { + fun findAll(pageable: Pageable): Page +} diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/HistoricalEventController.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/HistoricalEventController.kt new file mode 100644 index 000000000..07cf4b4df --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/HistoricalEventController.kt @@ -0,0 +1,39 @@ +package org.gotson.komga.interfaces.api.rest + +import io.swagger.v3.oas.annotations.Parameter +import org.gotson.komga.infrastructure.swagger.PageableAsQueryParam +import org.gotson.komga.interfaces.api.persistence.HistoricalEventDtoRepository +import org.gotson.komga.interfaces.api.rest.dto.HistoricalEventDto +import org.springframework.data.domain.Page +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Pageable +import org.springframework.data.domain.Sort +import org.springframework.http.MediaType +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("api/v1/history", produces = [MediaType.APPLICATION_JSON_VALUE]) +class HistoricalEventController( + private val historicalEventDtoRepository: HistoricalEventDtoRepository, +) { + + @GetMapping + @PageableAsQueryParam + fun getAll( + @Parameter(hidden = true) page: Pageable, + ): Page { + val sort = + if (page.sort.isSorted) page.sort + else Sort.by(Sort.Order.desc("timestamp")) + + val pageRequest = PageRequest.of( + page.pageNumber, + page.pageSize, + sort, + ) + + return historicalEventDtoRepository.findAll(pageRequest) + } +} diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/HistoricalEventDto.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/HistoricalEventDto.kt new file mode 100644 index 000000000..6bbfbd4be --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/HistoricalEventDto.kt @@ -0,0 +1,11 @@ +package org.gotson.komga.interfaces.api.rest.dto + +import java.time.LocalDateTime + +data class HistoricalEventDto( + val type: String, + val timestamp: LocalDateTime, + val bookId: String?, + val seriesId: String?, + val properties: Map, +)