feat: series and book files deletion

closes #731 

Co-authored-by: Gauthier Roebroeck <gauthier.roebroeck@gmail.com>
This commit is contained in:
Snd-R 2021-12-22 05:03:04 +03:00 committed by GitHub
parent 31ad351144
commit e626ff850f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 596 additions and 5 deletions

View File

@ -70,6 +70,26 @@
:series="updateSeries"
/>
<confirmation-dialog
v-model="deleteSeriesDialog"
:title="$t('dialog.delete_series.dialog_title')"
:body-html="seriesToDeleteSingle ? $t('dialog.delete_series.warning_html', {name: seriesToDelete.name}) : $t('dialog.delete_series.warning_multiple_html', {count: seriesToDelete.length})"
:confirm-text="seriesToDeleteSingle ? $t('dialog.delete_series.confirm_delete', {name: seriesToDelete.name}) : $t('dialog.delete_series.confirm_delete_multiple', {count: seriesToDelete.length})"
:button-confirm="$t('dialog.delete_series.button_confirm')"
button-confirm-color="error"
@confirm="deleteSeries"
/>
<confirmation-dialog
v-model="deleteBookDialog"
:title="booksToDeleteSingle ? $t('dialog.delete_book.dialog_title') : $t('dialog.delete_book.dialog_title_multiple')"
:body-html="booksToDeleteSingle ? $t('dialog.delete_book.warning_html', {name: booksToDelete.name}) : $t('dialog.delete_book.warning_multiple_html', {count: booksToDelete.length})"
:confirm-text="booksToDeleteSingle ? $t('dialog.delete_book.confirm_delete', {name: booksToDelete.name}) : $t('dialog.delete_book.confirm_delete_multiple', {count: booksToDelete.length})"
:button-confirm="$t('dialog.delete_book.button_confirm')"
button-confirm-color="error"
@confirm="deleteBooks"
/>
</div>
</template>
@ -224,6 +244,20 @@ export default Vue.extend({
updateBulkBooks(): BookDto[] {
return this.$store.state.updateBulkBooks
},
deleteBookDialog: {
get(): boolean {
return this.$store.state.deleteBookDialog
},
set(val) {
this.$store.dispatch('dialogDeleteBookDisplay', val)
},
},
booksToDelete(): BookDto | BookDto[] {
return this.$store.state.deleteBooks
},
booksToDeleteSingle(): boolean {
return !Array.isArray(this.booksToDelete)
},
// series
updateSeriesDialog: {
get(): boolean {
@ -236,6 +270,20 @@ export default Vue.extend({
updateSeries(): SeriesDto | SeriesDto[] {
return this.$store.state.updateSeries
},
deleteSeriesDialog: {
get(): boolean {
return this.$store.state.deleteSeriesDialog
},
set(val) {
this.$store.dispatch('dialogDeleteSeriesDisplay', val)
},
},
seriesToDelete(): SeriesDto | SeriesDto[] {
return this.$store.state.deleteSeries
},
seriesToDeleteSingle(): boolean {
return !Array.isArray(this.seriesToDelete)
},
},
methods: {
async deleteLibrary() {
@ -265,6 +313,26 @@ export default Vue.extend({
}
}
},
async deleteSeries() {
const toUpdate = (this.seriesToDeleteSingle ? [this.seriesToDelete] : this.seriesToDelete) as SeriesDto[]
for (const b of toUpdate) {
try {
await this.$komgaSeries.deleteSeries(b.id)
} catch (e) {
this.$eventHub.$emit(ERROR, {message: e.message} as ErrorEvent)
}
}
},
async deleteBooks() {
const toUpdate = (this.booksToDeleteSingle ? [this.booksToDelete] : this.booksToDelete) as BookDto[]
for (const b of toUpdate) {
try {
await this.$komgaBooks.deleteBook(b.id)
} catch (e) {
this.$eventHub.$emit(ERROR, {message: e.message} as ErrorEvent)
}
}
},
},
})
</script>

View File

@ -79,7 +79,7 @@
</v-tooltip>
</v-btn>
<v-btn icon @click="doDelete" v-if="isAdmin && (kind === 'collections' || kind === 'readlists')">
<v-btn icon @click="doDelete" v-if="isAdmin">
<v-tooltip bottom>
<template v-slot:activator="{ on }">
<v-icon v-on="on">mdi-delete</v-icon>

View File

@ -22,6 +22,9 @@
<v-list-item @click="markUnread" v-if="!isUnread">
<v-list-item-title>{{ $t('menu.mark_unread') }}</v-list-item-title>
</v-list-item>
<v-list-item @click="promptDeleteBook" class="list-warning" v-if="isAdmin">
<v-list-item-title>{{ $t('menu.delete') }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</div>
@ -82,6 +85,9 @@ export default Vue.extend({
async markUnread () {
await this.$komgaBooks.deleteReadProgress(this.book.id)
},
promptDeleteBook () {
this.$store.dispatch('dialogDeleteBook', this.book)
},
},
})
</script>

View File

@ -22,6 +22,9 @@
<v-list-item @click="markUnread" v-if="!isUnread">
<v-list-item-title>{{ $t('menu.mark_unread') }}</v-list-item-title>
</v-list-item>
<v-list-item @click="promptDeleteSeries" class="list-warning" v-if="isAdmin">
<v-list-item-title>{{ $t('menu.delete') }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</div>
@ -81,6 +84,9 @@ export default Vue.extend({
await this.$komgaSeries.markAsUnread(this.series.id)
// this.$eventHub.$emit(SERIES_CHANGED, seriesToEventSeriesChanged(this.series))
},
promptDeleteSeries () {
this.$store.dispatch('dialogDeleteSeries', this.series)
},
},
})
</script>

View File

@ -272,6 +272,15 @@
"button_confirm": "Analyze",
"title": "Analyze library"
},
"delete_book": {
"button_confirm": "Delete",
"confirm_delete": "Yes, delete book \"{name}\" and its files",
"confirm_delete_multiple": "Yes, delete {count} books and their files",
"dialog_title": "Delete Book",
"dialog_title_multiple": "Delete Books",
"warning_html": "The book <b>{name}</b> will be removed from this server alongside with stored media files. This <b>cannot</b> be undone. Continue?",
"warning_multiple_html": "{count} books will be removed from this server alongside with stored media files. This <b>cannot</b> be undone. Continue?"
},
"delete_collection": {
"button_confirm": "Delete",
"confirm_delete": "Yes, delete the collection \"{name}\"",
@ -302,6 +311,14 @@
"dialog_title": "Delete User",
"warning_html": "The user <b>{name}</b> will be deleted from this server. This <b>cannot</b> be undone. Continue?"
},
"delete_series": {
"button_confirm": "Delete",
"confirm_delete": "Yes, delete series \"{name}\" and its files",
"confirm_delete_multiple": "Yes, delete {count} series and their files",
"dialog_title": "Delete Series",
"warning_html": "The Series <b>{name}</b> will be removed from this server alongside with stored media files. This <b>cannot</b> be undone. Continue?",
"warning_multiple_html": "{count} series will be removed from this server alongside with stored media files. This <b>cannot</b> be undone. Continue?"
},
"edit_books": {
"authors_notice_multiple_edit": "You are editing authors for multiple books. This will override existing authors of each book.",
"button_cancel": "Cancel",

View File

@ -212,4 +212,16 @@ export default class KomgaBooksService {
throw new Error(msg)
}
}
async deleteBook(bookId: string) {
try {
await this.http.delete(`${API_BOOKS}/${bookId}/file`)
} catch (e) {
let msg = 'An error occurred while trying to delete book'
if (e.response.data.message) {
msg += `: ${e.response.data.message}`
}
throw new Error(msg)
}
}
}

View File

@ -265,4 +265,16 @@ export default class KomgaSeriesService {
throw new Error(msg)
}
}
async deleteSeries(seriesId: string) {
try {
await this.http.delete(`${API_SERIES}/${seriesId}/file`)
} catch (e) {
let msg = `An error occurred while trying delete series '${seriesId}'`
if (e.response.data.message) {
msg += `: ${e.response.data.message}`
}
throw new Error(msg)
}
}
}

View File

@ -36,6 +36,8 @@ export default new Vuex.Store({
// books
updateBooks: {} as BookDto | BookDto[],
updateBooksDialog: false,
deleteBooks: {} as BookDto | BookDto[],
deleteBookDialog: false,
// books bulk
updateBulkBooks: [] as BookDto[],
updateBulkBooksDialog: false,
@ -43,6 +45,8 @@ export default new Vuex.Store({
// series
updateSeries: {} as SeriesDto | SeriesDto[],
updateSeriesDialog: false,
deleteSeries: {} as SeriesDto | SeriesDto[],
deleteSeriesDialog: false,
booksToCheck: 0,
},
@ -105,6 +109,12 @@ export default new Vuex.Store({
setUpdateBooksDialog(state, dialog) {
state.updateBooksDialog = dialog
},
setDeleteBooks(state, books) {
state.deleteBooks = books
},
setDeleteBookDialog(state, dialog) {
state.deleteBookDialog = dialog
},
// Books bulk
setUpdateBulkBooks(state, books) {
state.updateBulkBooks = books
@ -122,6 +132,12 @@ export default new Vuex.Store({
setBooksToCheck(state, count) {
state.booksToCheck = count
},
setDeleteSeries(state, series) {
state.deleteSeries = series
},
setDeleteSeriesDialog(state, dialog) {
state.deleteSeriesDialog = dialog
},
},
actions: {
// collections
@ -195,6 +211,13 @@ export default new Vuex.Store({
dialogUpdateBooksDisplay({commit}, value) {
commit('setUpdateBooksDialog', value)
},
dialogDeleteBook({commit}, books) {
commit('setDeleteBooks', books)
commit('setDeleteBookDialog', true)
},
dialogDeleteBookDisplay({commit}, value) {
commit('setDeleteBookDialog', value)
},
// books bulk
dialogUpdateBulkBooks({commit}, books) {
commit('setUpdateBulkBooks', books)
@ -211,6 +234,13 @@ export default new Vuex.Store({
dialogUpdateSeriesDisplay({commit}, value) {
commit('setUpdateSeriesDialog', value)
},
dialogDeleteSeries({commit}, series) {
commit('setDeleteSeries', series)
commit('setDeleteSeriesDialog', true)
},
dialogDeleteSeriesDisplay({commit}, value) {
commit('setDeleteSeriesDialog', value)
},
},
modules: {
persistedState: persistedModule,

View File

@ -35,6 +35,7 @@
@mark-unread="markSelectedUnread"
@add-to-collection="addToCollection"
@edit="editMultipleSeries"
@delete="deleteSeries"
/>
<library-navigation v-if="$vuetify.breakpoint.name === 'xs'" :libraryId="libraryId" bottom-navigation/>
@ -554,6 +555,9 @@ export default Vue.extend({
editMultipleSeries() {
this.$store.dispatch('dialogUpdateSeries', this.selectedSeries)
},
deleteSeries() {
this.$store.dispatch('dialogDeleteSeries', this.selectedSeries)
},
},
})
</script>

View File

@ -48,6 +48,7 @@
@add-to-readlist="addToReadList"
@bulk-edit="bulkEditMultipleBooks"
@edit="editMultipleBooks"
@delete="deleteBooks"
/>
<filter-drawer
@ -832,6 +833,9 @@ export default Vue.extend({
))
this.selectedBooks = []
},
deleteBooks() {
this.$store.dispatch('dialogDeleteBook', this.selectedBooks)
},
},
})
</script>

View File

@ -13,8 +13,9 @@ sealed class Task(priority: Int = DEFAULT_PRIORITY) : Serializable {
abstract fun uniqueId(): String
val priority = priority.coerceIn(0, 9)
data class ScanLibrary(val libraryId: String) : Task() {
class ScanLibrary(val libraryId: String, priority: Int = DEFAULT_PRIORITY) : Task(priority) {
override fun uniqueId() = "SCAN_LIBRARY_$libraryId"
override fun toString(): String = "ScanLibrary(libraryId='$libraryId', priority='$priority')"
}
class EmptyTrash(val libraryId: String, priority: Int = DEFAULT_PRIORITY) : Task(priority) {
@ -82,4 +83,14 @@ sealed class Task(priority: Int = DEFAULT_PRIORITY) : Serializable {
override fun uniqueId() = "REBUILD_INDEX"
override fun toString(): String = "RebuildIndex(priority='$priority')"
}
class DeleteBook(val bookId: String, priority: Int = DEFAULT_PRIORITY) : Task(priority) {
override fun uniqueId() = "DELETE_BOOK_$bookId"
override fun toString(): String = "DeleteBook(bookId='$bookId', priority='$priority')"
}
class DeleteSeries(val seriesId: String, priority: Int = DEFAULT_PRIORITY) : Task(priority) {
override fun uniqueId() = "DELETE_SERIES_$seriesId"
override fun toString(): String = "DeleteSeries(seriesId='$seriesId', priority='$priority')"
}
}

View File

@ -10,6 +10,7 @@ import org.gotson.komga.domain.service.BookLifecycle
import org.gotson.komga.domain.service.BookMetadataLifecycle
import org.gotson.komga.domain.service.LibraryContentLifecycle
import org.gotson.komga.domain.service.LocalArtworkLifecycle
import org.gotson.komga.domain.service.SeriesLifecycle
import org.gotson.komga.domain.service.SeriesMetadataLifecycle
import org.gotson.komga.infrastructure.jms.QUEUE_FACTORY
import org.gotson.komga.infrastructure.jms.QUEUE_TASKS
@ -31,6 +32,7 @@ class TaskHandler(
private val libraryContentLifecycle: LibraryContentLifecycle,
private val bookLifecycle: BookLifecycle,
private val bookMetadataLifecycle: BookMetadataLifecycle,
private val seriesLifecycle: SeriesLifecycle,
private val seriesMetadataLifecycle: SeriesMetadataLifecycle,
private val localArtworkLifecycle: LocalArtworkLifecycle,
private val bookImporter: BookImporter,
@ -121,6 +123,20 @@ class TaskHandler(
} ?: logger.warn { "Cannot execute task $task: Book does not exist" }
is Task.RebuildIndex -> searchIndexLifecycle.rebuildIndex()
is Task.DeleteBook -> {
bookRepository.findByIdOrNull(task.bookId)?.let { book ->
bookLifecycle.deleteBookFiles(book)
taskReceiver.scanLibrary(book.libraryId, task.priority)
}
}
is Task.DeleteSeries -> {
seriesRepository.findByIdOrNull(task.seriesId)?.let { series ->
seriesLifecycle.deleteSeriesFiles(series)
taskReceiver.scanLibrary(series.libraryId, task.priority)
}
}
}
}.also {
logger.info { "Task $task executed in $it" }

View File

@ -42,8 +42,8 @@ class TaskReceiver(
libraryRepository.findAll().forEach { scanLibrary(it.id) }
}
fun scanLibrary(libraryId: String) {
submitTask(Task.ScanLibrary(libraryId))
fun scanLibrary(libraryId: String, priority: Int = DEFAULT_PRIORITY) {
submitTask(Task.ScanLibrary(libraryId, priority))
}
fun emptyTrash(libraryId: String, priority: Int = DEFAULT_PRIORITY) {
@ -121,6 +121,14 @@ class TaskReceiver(
submitTask(Task.RebuildIndex(priority))
}
fun deleteBook(bookId: String, priority: Int = DEFAULT_PRIORITY) {
submitTask(Task.DeleteBook(bookId, priority))
}
fun deleteSeries(seriesId: String, priority: Int = DEFAULT_PRIORITY) {
submitTask(Task.DeleteSeries(seriesId, priority))
}
private fun submitTask(task: Task) {
logger.info { "Sending task: $task" }
jmsTemplates[task.priority]!!.convertAndSend(QUEUE_TASKS, task) {

View File

@ -8,6 +8,7 @@ interface ThumbnailSeriesRepository {
fun findSelectedBySeriesIdOrNull(seriesId: String): ThumbnailSeries?
fun findAllBySeriesId(seriesId: String): Collection<ThumbnailSeries>
fun findAllBySeriesIdIdAndType(seriesId: String, type: ThumbnailSeries.Type): Collection<ThumbnailSeries>
fun insert(thumbnail: ThumbnailSeries)
fun markSelected(thumbnail: ThumbnailSeries)

View File

@ -13,6 +13,7 @@ import org.gotson.komga.domain.model.Media
import org.gotson.komga.domain.model.MediaNotReadyException
import org.gotson.komga.domain.model.ReadProgress
import org.gotson.komga.domain.model.ThumbnailBook
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.MediaRepository
@ -26,7 +27,15 @@ import org.gotson.komga.infrastructure.image.ImageType
import org.springframework.stereotype.Service
import org.springframework.transaction.support.TransactionTemplate
import java.io.File
import java.io.FileNotFoundException
import java.time.LocalDateTime
import kotlin.io.path.deleteExisting
import kotlin.io.path.deleteIfExists
import kotlin.io.path.exists
import kotlin.io.path.isWritable
import kotlin.io.path.listDirectoryEntries
import kotlin.io.path.notExists
import kotlin.io.path.toPath
private val logger = KotlinLogging.logger {}
@ -307,4 +316,19 @@ class BookLifecycle(
eventPublisher.publishEvent(DomainEvent.ReadProgressDeleted(progress))
}
}
fun deleteBookFiles(book: Book) {
if (book.path.notExists() || !book.path.isWritable())
throw FileNotFoundException("File is not accessible : ${book.path}").withCode("ERR_1018")
val thumbnails = thumbnailBookRepository.findAllByBookIdAndType(book.id, ThumbnailBook.Type.SIDECAR)
.mapNotNull { it.url?.toURI()?.toPath() }
.filter { it.exists() && it.isWritable() }
book.path.deleteIfExists()
thumbnails.forEach { it.deleteIfExists() }
if (book.path.parent.listDirectoryEntries().isEmpty())
book.path.parent.deleteExisting()
}
}

View File

@ -18,6 +18,7 @@ import org.gotson.komga.domain.model.ReadProgress
import org.gotson.komga.domain.model.Series
import org.gotson.komga.domain.model.SeriesMetadata
import org.gotson.komga.domain.model.ThumbnailSeries
import org.gotson.komga.domain.model.withCode
import org.gotson.komga.domain.persistence.BookMetadataAggregationRepository
import org.gotson.komga.domain.persistence.BookMetadataRepository
import org.gotson.komga.domain.persistence.BookRepository
@ -32,7 +33,15 @@ import org.gotson.komga.infrastructure.language.stripAccents
import org.springframework.stereotype.Service
import org.springframework.transaction.support.TransactionTemplate
import java.io.File
import java.io.FileNotFoundException
import java.nio.file.Path
import java.time.LocalDateTime
import kotlin.io.path.deleteIfExists
import kotlin.io.path.exists
import kotlin.io.path.isWritable
import kotlin.io.path.listDirectoryEntries
import kotlin.io.path.notExists
import kotlin.io.path.toPath
private val logger = KotlinLogging.logger {}
private val natSortComparator: Comparator<String> = CaseInsensitiveSimpleNaturalComparator.getInstance()
@ -279,6 +288,22 @@ class SeriesLifecycle(
eventPublisher.publishEvent(DomainEvent.ThumbnailSeriesDeleted(thumbnail))
}
fun deleteSeriesFiles(series: Series) {
if (series.path.notExists() || !series.path.isWritable())
throw FileNotFoundException("File is not accessible : ${series.path}").withCode("ERR_1018")
val thumbnails = thumbnailsSeriesRepository.findAllBySeriesIdIdAndType(series.id, ThumbnailSeries.Type.SIDECAR)
.mapNotNull { it.url?.toURI()?.toPath() }
.filter { it.exists() && it.isWritable() }
bookRepository.findAllBySeriesId(series.id)
.forEach { bookLifecycle.deleteBookFiles(it) }
thumbnails.forEach(Path::deleteIfExists)
if (series.path.exists() && series.path.listDirectoryEntries().isEmpty())
series.path.deleteIfExists()
}
private fun thumbnailsHouseKeeping(seriesId: String) {
logger.info { "House keeping thumbnails for series: $seriesId" }
val all = thumbnailsSeriesRepository.findAllBySeriesId(seriesId)

View File

@ -29,6 +29,13 @@ class ThumbnailSeriesDao(
.fetchInto(ts)
.map { it.toDomain() }
override fun findAllBySeriesIdIdAndType(seriesId: String, type: ThumbnailSeries.Type): Collection<ThumbnailSeries> =
dsl.selectFrom(ts)
.where(ts.SERIES_ID.eq(seriesId))
.and(ts.TYPE.eq(type.toString()))
.fetchInto(ts)
.map { it.toDomain() }
override fun findSelectedBySeriesIdOrNull(seriesId: String): ThumbnailSeries? =
dsl.selectFrom(ts)
.where(ts.SERIES_ID.eq(seriesId))

View File

@ -643,6 +643,18 @@ class BookController(
}
}
@DeleteMapping("api/v1/books/{bookId}/file")
@PreAuthorize("hasRole('$ROLE_ADMIN')")
@ResponseStatus(HttpStatus.ACCEPTED)
fun deleteBook(
@PathVariable bookId: String
) {
taskReceiver.deleteBook(
bookId = bookId,
priority = HIGHEST_PRIORITY,
)
}
private fun ResponseEntity.BodyBuilder.setNotModified(media: Media) =
this.setCachePrivate().lastModified(getBookLastModified(media))

View File

@ -12,6 +12,7 @@ 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.HIGHEST_PRIORITY
import org.gotson.komga.application.tasks.HIGH_PRIORITY
import org.gotson.komga.application.tasks.TaskReceiver
import org.gotson.komga.domain.model.Author
@ -682,4 +683,16 @@ class SeriesController(
.contentType(MediaType.parseMediaType("application/zip"))
.body(streamingResponse)
}
@DeleteMapping("v1/series/{seriesId}/file")
@PreAuthorize("hasRole('$ROLE_ADMIN')")
@ResponseStatus(HttpStatus.ACCEPTED)
fun deleteSeries(
@PathVariable seriesId: String
) {
taskReceiver.deleteSeries(
seriesId = seriesId,
priority = HIGHEST_PRIORITY,
)
}
}

View File

@ -1,11 +1,15 @@
package org.gotson.komga.domain.service
import com.google.common.jimfs.Configuration
import com.google.common.jimfs.Jimfs
import com.ninjasquad.springmockk.MockkBean
import io.mockk.every
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.catchThrowable
import org.gotson.komga.domain.model.BookPage
import org.gotson.komga.domain.model.KomgaUser
import org.gotson.komga.domain.model.Media
import org.gotson.komga.domain.model.ThumbnailBook
import org.gotson.komga.domain.model.makeBook
import org.gotson.komga.domain.model.makeBookPage
import org.gotson.komga.domain.model.makeLibrary
@ -16,6 +20,7 @@ import org.gotson.komga.domain.persistence.LibraryRepository
import org.gotson.komga.domain.persistence.MediaRepository
import org.gotson.komga.domain.persistence.ReadProgressRepository
import org.gotson.komga.domain.persistence.SeriesRepository
import org.gotson.komga.domain.persistence.ThumbnailBookRepository
import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeAll
@ -24,6 +29,9 @@ import org.junit.jupiter.api.extension.ExtendWith
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.context.junit.jupiter.SpringExtension
import java.io.FileNotFoundException
import java.nio.file.Files
import java.nio.file.Paths
@ExtendWith(SpringExtension::class)
@SpringBootTest
@ -36,6 +44,7 @@ class BookLifecycleTest(
@Autowired private val readProgressRepository: ReadProgressRepository,
@Autowired private val mediaRepository: MediaRepository,
@Autowired private val userRepository: KomgaUserRepository,
@Autowired private val thumbnailBookRepository: ThumbnailBookRepository,
) {
@MockkBean
@ -130,4 +139,133 @@ class BookLifecycleTest(
// then
assertThat(readProgressRepository.findAll()).hasSize(2)
}
@Test
fun `given a book with a sidecar when deleting book then all book files should be deleted`() {
Jimfs.newFileSystem(Configuration.unix()).use { fs ->
// given
val root = fs.getPath("/root")
Files.createDirectory(root)
val seriesPath = root.resolve("series")
Files.createDirectory(seriesPath)
val bookPath = seriesPath.resolve("book1.cbz")
Files.createFile(bookPath)
val sidecarPath = seriesPath.resolve("sidecar1.png")
Files.createFile(sidecarPath)
val series = makeSeries(name = "series", libraryId = library.id, url = seriesPath.toUri().toURL())
val book = makeBook("1", libraryId = library.id, url = bookPath.toUri().toURL())
val sidecar = ThumbnailBook(bookId = book.id, type = ThumbnailBook.Type.SIDECAR, url = sidecarPath.toUri().toURL())
seriesLifecycle.createSeries(series)
seriesLifecycle.addBooks(series, listOf(book))
thumbnailBookRepository.insert(sidecar)
// when
bookLifecycle.deleteBookFiles(book)
// then
assertThat(Files.notExists(bookPath))
assertThat(Files.notExists(sidecarPath))
}
}
@Test
fun `given a non-existent book file when deleting book then exception is thrown`() {
// given
val bookPath = Paths.get("/non-existent")
val book = makeBook("1", libraryId = library.id, url = bookPath.toUri().toURL())
// when
val thrown = catchThrowable { bookLifecycle.deleteBookFiles(book) }
// then
assertThat(thrown).hasCauseInstanceOf(FileNotFoundException::class.java)
}
@Test
fun `given a book and a non-existent sidecar file when deleting book then book should be deleted`() {
Jimfs.newFileSystem(Configuration.unix()).use { fs ->
// given
val root = fs.getPath("/root")
Files.createDirectory(root)
val seriesPath = root.resolve("series")
Files.createDirectory(seriesPath)
val bookPath = seriesPath.resolve("book1.cbz")
Files.createFile(bookPath)
val sidecar1Path = seriesPath.resolve("sidecar1.png")
Files.createFile(sidecar1Path)
val sidecar2Path = seriesPath.resolve("sidecar2.png")
val series = makeSeries(name = "series", libraryId = library.id, url = seriesPath.toUri().toURL())
val book = makeBook("1", libraryId = library.id, url = bookPath.toUri().toURL())
val sidecar1 = ThumbnailBook(bookId = book.id, type = ThumbnailBook.Type.SIDECAR, url = sidecar1Path.toUri().toURL())
val sidecar2 = ThumbnailBook(bookId = book.id, type = ThumbnailBook.Type.SIDECAR, url = sidecar2Path.toUri().toURL())
seriesLifecycle.createSeries(series)
seriesLifecycle.addBooks(series, listOf(book))
thumbnailBookRepository.insert(sidecar1)
thumbnailBookRepository.insert(sidecar2)
// when
bookLifecycle.deleteBookFiles(book)
// then
assertThat(Files.notExists(seriesPath))
}
}
@Test
fun `given a single book file when deleting book then parent directory should be deleted`() {
Jimfs.newFileSystem(Configuration.unix()).use { fs ->
// given
val root = fs.getPath("/root")
Files.createDirectory(root)
val seriesPath = root.resolve("series")
Files.createDirectory(seriesPath)
val bookPath = seriesPath.resolve("book1.cbz")
Files.createFile(bookPath)
val series = makeSeries(name = "series", libraryId = library.id, url = seriesPath.toUri().toURL())
val book = makeBook("1", libraryId = library.id, url = bookPath.toUri().toURL())
seriesLifecycle.createSeries(series)
seriesLifecycle.addBooks(series, listOf(book))
// when
bookLifecycle.deleteBookFiles(book)
// then
assertThat(Files.notExists(seriesPath))
}
}
@Test
fun `given a single book file with unrelated files in directory when deleting book then parent directory should not be deleted`() {
Jimfs.newFileSystem(Configuration.unix()).use { fs ->
// given
val root = fs.getPath("/root")
Files.createDirectory(root)
val seriesPath = root.resolve("series")
Files.createDirectory(seriesPath)
val bookPath = seriesPath.resolve("book1.cbz")
Files.createFile(bookPath)
val filePath = seriesPath.resolve("file.txt")
Files.createFile(filePath)
val series = makeSeries(name = "series", libraryId = library.id, url = seriesPath.toUri().toURL())
val book = makeBook("1", libraryId = library.id, url = bookPath.toUri().toURL())
seriesLifecycle.createSeries(series)
seriesLifecycle.addBooks(series, listOf(book))
// when
bookLifecycle.deleteBookFiles(book)
// then
assertThat(Files.exists(seriesPath))
assertThat(Files.exists(filePath))
assertThat(Files.notExists(bookPath))
}
}
}

View File

@ -1,11 +1,14 @@
package org.gotson.komga.domain.service
import com.google.common.jimfs.Configuration
import com.google.common.jimfs.Jimfs
import com.ninjasquad.springmockk.SpykBean
import io.mockk.every
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.catchThrowable
import org.gotson.komga.domain.model.BookMetadata
import org.gotson.komga.domain.model.Media
import org.gotson.komga.domain.model.ThumbnailBook
import org.gotson.komga.domain.model.ThumbnailSeries
import org.gotson.komga.domain.model.makeBook
import org.gotson.komga.domain.model.makeLibrary
@ -17,6 +20,8 @@ import org.gotson.komga.domain.persistence.LibraryRepository
import org.gotson.komga.domain.persistence.MediaRepository
import org.gotson.komga.domain.persistence.SeriesMetadataRepository
import org.gotson.komga.domain.persistence.SeriesRepository
import org.gotson.komga.domain.persistence.ThumbnailBookRepository
import org.gotson.komga.domain.persistence.ThumbnailSeriesRepository
import org.jooq.exception.DataAccessException
import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.AfterEach
@ -27,6 +32,9 @@ import org.junit.jupiter.api.extension.ExtendWith
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.context.junit.jupiter.SpringExtension
import java.io.FileNotFoundException
import java.nio.file.Files
import java.nio.file.Paths
@ExtendWith(SpringExtension::class)
@SpringBootTest
@ -35,7 +43,9 @@ class SeriesLifecycleTest(
@Autowired private val bookLifecycle: BookLifecycle,
@Autowired private val seriesRepository: SeriesRepository,
@Autowired private val bookRepository: BookRepository,
@Autowired private val libraryRepository: LibraryRepository
@Autowired private val libraryRepository: LibraryRepository,
@Autowired private val thumbnailSeriesRepository: ThumbnailSeriesRepository,
@Autowired private val thumbnailBookRepository: ThumbnailBookRepository
) {
@SpykBean
@ -265,4 +275,171 @@ class SeriesLifecycleTest(
assertThat(thrown).isInstanceOf(IllegalArgumentException::class.java)
}
@Test
fun `given a series when deleting series then series directory is deleted`() {
Jimfs.newFileSystem(Configuration.unix()).use { fs ->
// given
val root = fs.getPath("/root")
Files.createDirectory(root)
val seriesPath = root.resolve("series")
Files.createDirectory(seriesPath)
val book1Path = seriesPath.resolve("book1.cbz")
Files.createFile(book1Path)
val book2Path = seriesPath.resolve("book2.cbz")
Files.createFile(book2Path)
val bookSidecarPath = seriesPath.resolve("sidecar1.png")
Files.createFile(bookSidecarPath)
val series = makeSeries(name = "series", libraryId = library.id, url = seriesPath.toUri().toURL())
val books = listOf(
makeBook("1", libraryId = library.id, url = book1Path.toUri().toURL()),
makeBook("2", libraryId = library.id, url = book2Path.toUri().toURL()),
)
val bookSidecar = ThumbnailBook(bookId = books[0].id, type = ThumbnailBook.Type.SIDECAR, url = bookSidecarPath.toUri().toURL())
seriesLifecycle.createSeries(series)
seriesLifecycle.addBooks(series, books)
thumbnailBookRepository.insert(bookSidecar)
// when
seriesLifecycle.deleteSeriesFiles(series)
// then
assertThat(Files.notExists(seriesPath))
}
}
@Test
fun `given a series with a series sidecar when deleting series then series directory is deleted`() {
Jimfs.newFileSystem(Configuration.unix()).use { fs ->
// given
val root = fs.getPath("/root")
Files.createDirectory(root)
val seriesPath = root.resolve("series")
Files.createDirectory(seriesPath)
val book1Path = seriesPath.resolve("book1.cbz")
Files.createFile(book1Path)
val book2Path = seriesPath.resolve("book2.cbz")
Files.createFile(book2Path)
val bookSidecarPath = seriesPath.resolve("sidecar1.png")
Files.createFile(bookSidecarPath)
val seriesSidecarPath = seriesPath.resolve("cover.png")
Files.createFile(seriesSidecarPath)
val series = makeSeries(name = "series", libraryId = library.id, url = seriesPath.toUri().toURL())
val books = listOf(
makeBook("1", libraryId = library.id, url = book1Path.toUri().toURL()),
makeBook("2", libraryId = library.id, url = book2Path.toUri().toURL()),
)
val bookSidecar = ThumbnailBook(bookId = books[0].id, type = ThumbnailBook.Type.SIDECAR, url = bookSidecarPath.toUri().toURL())
val seriesSidecar = ThumbnailSeries(seriesId = series.id, type = ThumbnailSeries.Type.SIDECAR, url = seriesSidecarPath.toUri().toURL())
seriesLifecycle.createSeries(series)
seriesLifecycle.addBooks(series, books)
thumbnailBookRepository.insert(bookSidecar)
thumbnailSeriesRepository.insert(seriesSidecar)
// when
seriesLifecycle.deleteSeriesFiles(series)
// then
assertThat(Files.notExists(seriesPath))
}
}
@Test
fun `given a series directory with unrelated files when deleting series then series directory should not be deleted`() {
Jimfs.newFileSystem(Configuration.unix()).use { fs ->
// given
val root = fs.getPath("/root")
Files.createDirectory(root)
val seriesPath = root.resolve("series")
Files.createDirectory(seriesPath)
val book1Path = seriesPath.resolve("book1.cbz")
Files.createFile(book1Path)
val book2Path = seriesPath.resolve("book2.cbz")
Files.createFile(book2Path)
val filePath = seriesPath.resolve("file.txt")
Files.createFile(filePath)
val bookSidecarPath = seriesPath.resolve("sidecar1.png")
Files.createFile(bookSidecarPath)
val seriesSidecarPath = seriesPath.resolve("cover.png")
Files.createFile(seriesSidecarPath)
val series = makeSeries(name = "series", libraryId = library.id, url = seriesPath.toUri().toURL())
val books = listOf(
makeBook("1", libraryId = library.id, url = book1Path.toUri().toURL()),
makeBook("2", libraryId = library.id, url = book2Path.toUri().toURL()),
)
val bookSidecar = ThumbnailBook(bookId = books[0].id, type = ThumbnailBook.Type.SIDECAR, url = bookSidecarPath.toUri().toURL())
val seriesSidecar = ThumbnailSeries(seriesId = series.id, type = ThumbnailSeries.Type.SIDECAR, url = seriesSidecarPath.toUri().toURL())
seriesLifecycle.createSeries(series)
seriesLifecycle.addBooks(series, books)
thumbnailBookRepository.insert(bookSidecar)
thumbnailSeriesRepository.insert(seriesSidecar)
// when
seriesLifecycle.deleteSeriesFiles(series)
// then
assertThat(Files.exists(seriesPath))
assertThat(Files.exists(filePath))
assertThat(Files.notExists(book1Path))
assertThat(Files.notExists(book2Path))
assertThat(Files.notExists(bookSidecarPath))
assertThat(Files.notExists(seriesSidecarPath))
}
}
@Test
fun `given a non-existent series directory when deleting series then exception is thrown`() {
// given
val seriesPath = Paths.get("/non-existent")
val series = makeSeries(name = "series", libraryId = library.id, url = seriesPath.toUri().toURL())
// when
val thrown = catchThrowable { seriesLifecycle.deleteSeriesFiles(series) }
// then
assertThat(thrown).hasCauseInstanceOf(FileNotFoundException::class.java)
}
@Test
fun `given a series and a non-existent sidecar file when deleting series then series should be deleted`() {
Jimfs.newFileSystem(Configuration.unix()).use { fs ->
// given
val root = fs.getPath("/root")
Files.createDirectory(root)
val seriesPath = root.resolve("series")
Files.createDirectory(seriesPath)
val book1Path = seriesPath.resolve("book1.cbz")
Files.createFile(book1Path)
val book2Path = seriesPath.resolve("book2.cbz")
Files.createFile(book2Path)
val bookSidecarPath = seriesPath.resolve("sidecar1.png")
Files.createFile(bookSidecarPath)
val seriesSidecarPath = seriesPath.resolve("cover.png")
val series = makeSeries(name = "series", libraryId = library.id, url = seriesPath.toUri().toURL())
val books = listOf(
makeBook("1", libraryId = library.id, url = book1Path.toUri().toURL()),
makeBook("2", libraryId = library.id, url = book2Path.toUri().toURL()),
)
val bookSidecar = ThumbnailBook(bookId = books[0].id, type = ThumbnailBook.Type.SIDECAR, url = bookSidecarPath.toUri().toURL())
val seriesSidecar = ThumbnailSeries(seriesId = series.id, type = ThumbnailSeries.Type.SIDECAR, url = seriesSidecarPath.toUri().toURL())
seriesLifecycle.createSeries(series)
seriesLifecycle.addBooks(series, books)
thumbnailBookRepository.insert(bookSidecar)
thumbnailSeriesRepository.insert(seriesSidecar)
// when
seriesLifecycle.deleteSeriesFiles(series)
// then
assertThat(Files.notExists(seriesPath))
}
}
}