diff --git a/komga-webui/src/components/Dialogs.vue b/komga-webui/src/components/Dialogs.vue
index 339db3480..0d106dca3 100644
--- a/komga-webui/src/components/Dialogs.vue
+++ b/komga-webui/src/components/Dialogs.vue
@@ -70,6 +70,26 @@
:series="updateSeries"
/>
+
+
+
+
@@ -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)
+ }
+ }
+ },
},
})
diff --git a/komga-webui/src/components/bars/MultiSelectBar.vue b/komga-webui/src/components/bars/MultiSelectBar.vue
index 72089f1f1..aeed951b5 100644
--- a/komga-webui/src/components/bars/MultiSelectBar.vue
+++ b/komga-webui/src/components/bars/MultiSelectBar.vue
@@ -79,7 +79,7 @@
-
+
mdi-delete
diff --git a/komga-webui/src/components/menus/BookActionsMenu.vue b/komga-webui/src/components/menus/BookActionsMenu.vue
index 1d4f84d6d..5ac3a5e11 100644
--- a/komga-webui/src/components/menus/BookActionsMenu.vue
+++ b/komga-webui/src/components/menus/BookActionsMenu.vue
@@ -22,6 +22,9 @@
{{ $t('menu.mark_unread') }}
+
+ {{ $t('menu.delete') }}
+
@@ -82,6 +85,9 @@ export default Vue.extend({
async markUnread () {
await this.$komgaBooks.deleteReadProgress(this.book.id)
},
+ promptDeleteBook () {
+ this.$store.dispatch('dialogDeleteBook', this.book)
+ },
},
})
diff --git a/komga-webui/src/components/menus/SeriesActionsMenu.vue b/komga-webui/src/components/menus/SeriesActionsMenu.vue
index 095d69560..bd58873fe 100644
--- a/komga-webui/src/components/menus/SeriesActionsMenu.vue
+++ b/komga-webui/src/components/menus/SeriesActionsMenu.vue
@@ -22,6 +22,9 @@
{{ $t('menu.mark_unread') }}
+
+ {{ $t('menu.delete') }}
+
@@ -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)
+ },
},
})
diff --git a/komga-webui/src/locales/en.json b/komga-webui/src/locales/en.json
index 398d5c410..fbd0fdb40 100644
--- a/komga-webui/src/locales/en.json
+++ b/komga-webui/src/locales/en.json
@@ -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 {name} will be removed from this server alongside with stored media files. This cannot be undone. Continue?",
+ "warning_multiple_html": "{count} books will be removed from this server alongside with stored media files. This cannot 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 {name} will be deleted from this server. This cannot 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 {name} will be removed from this server alongside with stored media files. This cannot be undone. Continue?",
+ "warning_multiple_html": "{count} series will be removed from this server alongside with stored media files. This cannot 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",
diff --git a/komga-webui/src/services/komga-books.service.ts b/komga-webui/src/services/komga-books.service.ts
index 47ddb41a3..1c47190fc 100644
--- a/komga-webui/src/services/komga-books.service.ts
+++ b/komga-webui/src/services/komga-books.service.ts
@@ -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)
+ }
+ }
}
diff --git a/komga-webui/src/services/komga-series.service.ts b/komga-webui/src/services/komga-series.service.ts
index 5481623be..6a9d1113c 100644
--- a/komga-webui/src/services/komga-series.service.ts
+++ b/komga-webui/src/services/komga-series.service.ts
@@ -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)
+ }
+ }
}
diff --git a/komga-webui/src/store.ts b/komga-webui/src/store.ts
index 77e425980..96ca892d4 100644
--- a/komga-webui/src/store.ts
+++ b/komga-webui/src/store.ts
@@ -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,
diff --git a/komga-webui/src/views/BrowseLibraries.vue b/komga-webui/src/views/BrowseLibraries.vue
index f66460bce..e7bf90012 100644
--- a/komga-webui/src/views/BrowseLibraries.vue
+++ b/komga-webui/src/views/BrowseLibraries.vue
@@ -35,6 +35,7 @@
@mark-unread="markSelectedUnread"
@add-to-collection="addToCollection"
@edit="editMultipleSeries"
+ @delete="deleteSeries"
/>
@@ -554,6 +555,9 @@ export default Vue.extend({
editMultipleSeries() {
this.$store.dispatch('dialogUpdateSeries', this.selectedSeries)
},
+ deleteSeries() {
+ this.$store.dispatch('dialogDeleteSeries', this.selectedSeries)
+ },
},
})
diff --git a/komga-webui/src/views/BrowseSeries.vue b/komga-webui/src/views/BrowseSeries.vue
index ea3e49d15..26886d08e 100644
--- a/komga-webui/src/views/BrowseSeries.vue
+++ b/komga-webui/src/views/BrowseSeries.vue
@@ -48,6 +48,7 @@
@add-to-readlist="addToReadList"
@bulk-edit="bulkEditMultipleBooks"
@edit="editMultipleBooks"
+ @delete="deleteBooks"
/>
diff --git a/komga/src/main/kotlin/org/gotson/komga/application/tasks/Task.kt b/komga/src/main/kotlin/org/gotson/komga/application/tasks/Task.kt
index 7c09271ba..d5c7ee17e 100644
--- a/komga/src/main/kotlin/org/gotson/komga/application/tasks/Task.kt
+++ b/komga/src/main/kotlin/org/gotson/komga/application/tasks/Task.kt
@@ -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')"
+ }
}
diff --git a/komga/src/main/kotlin/org/gotson/komga/application/tasks/TaskHandler.kt b/komga/src/main/kotlin/org/gotson/komga/application/tasks/TaskHandler.kt
index 50e8c2b8c..c334fe470 100644
--- a/komga/src/main/kotlin/org/gotson/komga/application/tasks/TaskHandler.kt
+++ b/komga/src/main/kotlin/org/gotson/komga/application/tasks/TaskHandler.kt
@@ -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" }
diff --git a/komga/src/main/kotlin/org/gotson/komga/application/tasks/TaskReceiver.kt b/komga/src/main/kotlin/org/gotson/komga/application/tasks/TaskReceiver.kt
index 17099ccfb..f8f1bcc57 100644
--- a/komga/src/main/kotlin/org/gotson/komga/application/tasks/TaskReceiver.kt
+++ b/komga/src/main/kotlin/org/gotson/komga/application/tasks/TaskReceiver.kt
@@ -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) {
diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/persistence/ThumbnailSeriesRepository.kt b/komga/src/main/kotlin/org/gotson/komga/domain/persistence/ThumbnailSeriesRepository.kt
index 6e3666ee4..4458f8746 100644
--- a/komga/src/main/kotlin/org/gotson/komga/domain/persistence/ThumbnailSeriesRepository.kt
+++ b/komga/src/main/kotlin/org/gotson/komga/domain/persistence/ThumbnailSeriesRepository.kt
@@ -8,6 +8,7 @@ interface ThumbnailSeriesRepository {
fun findSelectedBySeriesIdOrNull(seriesId: String): ThumbnailSeries?
fun findAllBySeriesId(seriesId: String): Collection
+ fun findAllBySeriesIdIdAndType(seriesId: String, type: ThumbnailSeries.Type): Collection
fun insert(thumbnail: ThumbnailSeries)
fun markSelected(thumbnail: ThumbnailSeries)
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 90dc37347..7bf30c3c8 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
@@ -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()
+ }
}
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 2780ed54b..00c25283e 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
@@ -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 = 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)
diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/ThumbnailSeriesDao.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/ThumbnailSeriesDao.kt
index fbbcb07db..e4f533cf4 100644
--- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/ThumbnailSeriesDao.kt
+++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/ThumbnailSeriesDao.kt
@@ -29,6 +29,13 @@ class ThumbnailSeriesDao(
.fetchInto(ts)
.map { it.toDomain() }
+ override fun findAllBySeriesIdIdAndType(seriesId: String, type: ThumbnailSeries.Type): Collection =
+ 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))
diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/BookController.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/BookController.kt
index bdbf6962e..9c53eaad6 100644
--- a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/BookController.kt
+++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/BookController.kt
@@ -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))
diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/SeriesController.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/SeriesController.kt
index bc65801d6..45da9499f 100644
--- a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/SeriesController.kt
+++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/SeriesController.kt
@@ -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,
+ )
+ }
}
diff --git a/komga/src/test/kotlin/org/gotson/komga/domain/service/BookLifecycleTest.kt b/komga/src/test/kotlin/org/gotson/komga/domain/service/BookLifecycleTest.kt
index 12ff961e4..a66b0e088 100644
--- a/komga/src/test/kotlin/org/gotson/komga/domain/service/BookLifecycleTest.kt
+++ b/komga/src/test/kotlin/org/gotson/komga/domain/service/BookLifecycleTest.kt
@@ -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))
+ }
+ }
}
diff --git a/komga/src/test/kotlin/org/gotson/komga/domain/service/SeriesLifecycleTest.kt b/komga/src/test/kotlin/org/gotson/komga/domain/service/SeriesLifecycleTest.kt
index 52e890c8b..20c8a73c1 100644
--- a/komga/src/test/kotlin/org/gotson/komga/domain/service/SeriesLifecycleTest.kt
+++ b/komga/src/test/kotlin/org/gotson/komga/domain/service/SeriesLifecycleTest.kt
@@ -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))
+ }
+ }
}