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" :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> </div>
</template> </template>
@ -224,6 +244,20 @@ export default Vue.extend({
updateBulkBooks(): BookDto[] { updateBulkBooks(): BookDto[] {
return this.$store.state.updateBulkBooks 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 // series
updateSeriesDialog: { updateSeriesDialog: {
get(): boolean { get(): boolean {
@ -236,6 +270,20 @@ export default Vue.extend({
updateSeries(): SeriesDto | SeriesDto[] { updateSeries(): SeriesDto | SeriesDto[] {
return this.$store.state.updateSeries 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: { methods: {
async deleteLibrary() { 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> </script>

View file

@ -79,7 +79,7 @@
</v-tooltip> </v-tooltip>
</v-btn> </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> <v-tooltip bottom>
<template v-slot:activator="{ on }"> <template v-slot:activator="{ on }">
<v-icon v-on="on">mdi-delete</v-icon> <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 @click="markUnread" v-if="!isUnread">
<v-list-item-title>{{ $t('menu.mark_unread') }}</v-list-item-title> <v-list-item-title>{{ $t('menu.mark_unread') }}</v-list-item-title>
</v-list-item> </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-list>
</v-menu> </v-menu>
</div> </div>
@ -82,6 +85,9 @@ export default Vue.extend({
async markUnread () { async markUnread () {
await this.$komgaBooks.deleteReadProgress(this.book.id) await this.$komgaBooks.deleteReadProgress(this.book.id)
}, },
promptDeleteBook () {
this.$store.dispatch('dialogDeleteBook', this.book)
},
}, },
}) })
</script> </script>

View file

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

View file

@ -272,6 +272,15 @@
"button_confirm": "Analyze", "button_confirm": "Analyze",
"title": "Analyze library" "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": { "delete_collection": {
"button_confirm": "Delete", "button_confirm": "Delete",
"confirm_delete": "Yes, delete the collection \"{name}\"", "confirm_delete": "Yes, delete the collection \"{name}\"",
@ -302,6 +311,14 @@
"dialog_title": "Delete User", "dialog_title": "Delete User",
"warning_html": "The user <b>{name}</b> will be deleted from this server. This <b>cannot</b> be undone. Continue?" "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": { "edit_books": {
"authors_notice_multiple_edit": "You are editing authors for multiple books. This will override existing authors of each book.", "authors_notice_multiple_edit": "You are editing authors for multiple books. This will override existing authors of each book.",
"button_cancel": "Cancel", "button_cancel": "Cancel",

View file

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

View file

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

View file

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

View file

@ -13,8 +13,9 @@ sealed class Task(priority: Int = DEFAULT_PRIORITY) : Serializable {
abstract fun uniqueId(): String abstract fun uniqueId(): String
val priority = priority.coerceIn(0, 9) 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 uniqueId() = "SCAN_LIBRARY_$libraryId"
override fun toString(): String = "ScanLibrary(libraryId='$libraryId', priority='$priority')"
} }
class EmptyTrash(val libraryId: String, priority: Int = DEFAULT_PRIORITY) : Task(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 uniqueId() = "REBUILD_INDEX"
override fun toString(): String = "RebuildIndex(priority='$priority')" 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.BookMetadataLifecycle
import org.gotson.komga.domain.service.LibraryContentLifecycle import org.gotson.komga.domain.service.LibraryContentLifecycle
import org.gotson.komga.domain.service.LocalArtworkLifecycle 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.domain.service.SeriesMetadataLifecycle
import org.gotson.komga.infrastructure.jms.QUEUE_FACTORY import org.gotson.komga.infrastructure.jms.QUEUE_FACTORY
import org.gotson.komga.infrastructure.jms.QUEUE_TASKS import org.gotson.komga.infrastructure.jms.QUEUE_TASKS
@ -31,6 +32,7 @@ class TaskHandler(
private val libraryContentLifecycle: LibraryContentLifecycle, private val libraryContentLifecycle: LibraryContentLifecycle,
private val bookLifecycle: BookLifecycle, private val bookLifecycle: BookLifecycle,
private val bookMetadataLifecycle: BookMetadataLifecycle, private val bookMetadataLifecycle: BookMetadataLifecycle,
private val seriesLifecycle: SeriesLifecycle,
private val seriesMetadataLifecycle: SeriesMetadataLifecycle, private val seriesMetadataLifecycle: SeriesMetadataLifecycle,
private val localArtworkLifecycle: LocalArtworkLifecycle, private val localArtworkLifecycle: LocalArtworkLifecycle,
private val bookImporter: BookImporter, private val bookImporter: BookImporter,
@ -121,6 +123,20 @@ class TaskHandler(
} ?: logger.warn { "Cannot execute task $task: Book does not exist" } } ?: logger.warn { "Cannot execute task $task: Book does not exist" }
is Task.RebuildIndex -> searchIndexLifecycle.rebuildIndex() 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 { }.also {
logger.info { "Task $task executed in $it" } logger.info { "Task $task executed in $it" }

View file

@ -42,8 +42,8 @@ class TaskReceiver(
libraryRepository.findAll().forEach { scanLibrary(it.id) } libraryRepository.findAll().forEach { scanLibrary(it.id) }
} }
fun scanLibrary(libraryId: String) { fun scanLibrary(libraryId: String, priority: Int = DEFAULT_PRIORITY) {
submitTask(Task.ScanLibrary(libraryId)) submitTask(Task.ScanLibrary(libraryId, priority))
} }
fun emptyTrash(libraryId: String, priority: Int = DEFAULT_PRIORITY) { fun emptyTrash(libraryId: String, priority: Int = DEFAULT_PRIORITY) {
@ -121,6 +121,14 @@ class TaskReceiver(
submitTask(Task.RebuildIndex(priority)) 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) { private fun submitTask(task: Task) {
logger.info { "Sending task: $task" } logger.info { "Sending task: $task" }
jmsTemplates[task.priority]!!.convertAndSend(QUEUE_TASKS, task) { jmsTemplates[task.priority]!!.convertAndSend(QUEUE_TASKS, task) {

View file

@ -8,6 +8,7 @@ interface ThumbnailSeriesRepository {
fun findSelectedBySeriesIdOrNull(seriesId: String): ThumbnailSeries? fun findSelectedBySeriesIdOrNull(seriesId: String): ThumbnailSeries?
fun findAllBySeriesId(seriesId: String): Collection<ThumbnailSeries> fun findAllBySeriesId(seriesId: String): Collection<ThumbnailSeries>
fun findAllBySeriesIdIdAndType(seriesId: String, type: ThumbnailSeries.Type): Collection<ThumbnailSeries>
fun insert(thumbnail: ThumbnailSeries) fun insert(thumbnail: ThumbnailSeries)
fun markSelected(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.MediaNotReadyException
import org.gotson.komga.domain.model.ReadProgress import org.gotson.komga.domain.model.ReadProgress
import org.gotson.komga.domain.model.ThumbnailBook 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.BookMetadataRepository
import org.gotson.komga.domain.persistence.BookRepository import org.gotson.komga.domain.persistence.BookRepository
import org.gotson.komga.domain.persistence.MediaRepository 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.stereotype.Service
import org.springframework.transaction.support.TransactionTemplate import org.springframework.transaction.support.TransactionTemplate
import java.io.File import java.io.File
import java.io.FileNotFoundException
import java.time.LocalDateTime 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 {} private val logger = KotlinLogging.logger {}
@ -307,4 +316,19 @@ class BookLifecycle(
eventPublisher.publishEvent(DomainEvent.ReadProgressDeleted(progress)) 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.Series
import org.gotson.komga.domain.model.SeriesMetadata import org.gotson.komga.domain.model.SeriesMetadata
import org.gotson.komga.domain.model.ThumbnailSeries 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.BookMetadataAggregationRepository
import org.gotson.komga.domain.persistence.BookMetadataRepository import org.gotson.komga.domain.persistence.BookMetadataRepository
import org.gotson.komga.domain.persistence.BookRepository import org.gotson.komga.domain.persistence.BookRepository
@ -32,7 +33,15 @@ import org.gotson.komga.infrastructure.language.stripAccents
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.support.TransactionTemplate import org.springframework.transaction.support.TransactionTemplate
import java.io.File import java.io.File
import java.io.FileNotFoundException
import java.nio.file.Path
import java.time.LocalDateTime 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 logger = KotlinLogging.logger {}
private val natSortComparator: Comparator<String> = CaseInsensitiveSimpleNaturalComparator.getInstance() private val natSortComparator: Comparator<String> = CaseInsensitiveSimpleNaturalComparator.getInstance()
@ -279,6 +288,22 @@ class SeriesLifecycle(
eventPublisher.publishEvent(DomainEvent.ThumbnailSeriesDeleted(thumbnail)) 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) { private fun thumbnailsHouseKeeping(seriesId: String) {
logger.info { "House keeping thumbnails for series: $seriesId" } logger.info { "House keeping thumbnails for series: $seriesId" }
val all = thumbnailsSeriesRepository.findAllBySeriesId(seriesId) val all = thumbnailsSeriesRepository.findAllBySeriesId(seriesId)

View file

@ -29,6 +29,13 @@ class ThumbnailSeriesDao(
.fetchInto(ts) .fetchInto(ts)
.map { it.toDomain() } .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? = override fun findSelectedBySeriesIdOrNull(seriesId: String): ThumbnailSeries? =
dsl.selectFrom(ts) dsl.selectFrom(ts)
.where(ts.SERIES_ID.eq(seriesId)) .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) = private fun ResponseEntity.BodyBuilder.setNotModified(media: Media) =
this.setCachePrivate().lastModified(getBookLastModified(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.compress.archivers.zip.ZipArchiveOutputStream
import org.apache.commons.io.IOUtils import org.apache.commons.io.IOUtils
import org.gotson.komga.application.events.EventPublisher import org.gotson.komga.application.events.EventPublisher
import org.gotson.komga.application.tasks.HIGHEST_PRIORITY
import org.gotson.komga.application.tasks.HIGH_PRIORITY import org.gotson.komga.application.tasks.HIGH_PRIORITY
import org.gotson.komga.application.tasks.TaskReceiver import org.gotson.komga.application.tasks.TaskReceiver
import org.gotson.komga.domain.model.Author import org.gotson.komga.domain.model.Author
@ -682,4 +683,16 @@ class SeriesController(
.contentType(MediaType.parseMediaType("application/zip")) .contentType(MediaType.parseMediaType("application/zip"))
.body(streamingResponse) .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 package org.gotson.komga.domain.service
import com.google.common.jimfs.Configuration
import com.google.common.jimfs.Jimfs
import com.ninjasquad.springmockk.MockkBean import com.ninjasquad.springmockk.MockkBean
import io.mockk.every import io.mockk.every
import org.assertj.core.api.Assertions.assertThat 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.BookPage
import org.gotson.komga.domain.model.KomgaUser import org.gotson.komga.domain.model.KomgaUser
import org.gotson.komga.domain.model.Media import org.gotson.komga.domain.model.Media
import org.gotson.komga.domain.model.ThumbnailBook
import org.gotson.komga.domain.model.makeBook import org.gotson.komga.domain.model.makeBook
import org.gotson.komga.domain.model.makeBookPage import org.gotson.komga.domain.model.makeBookPage
import org.gotson.komga.domain.model.makeLibrary 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.MediaRepository
import org.gotson.komga.domain.persistence.ReadProgressRepository import org.gotson.komga.domain.persistence.ReadProgressRepository
import org.gotson.komga.domain.persistence.SeriesRepository 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.AfterAll
import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeAll 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.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.context.junit.jupiter.SpringExtension 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) @ExtendWith(SpringExtension::class)
@SpringBootTest @SpringBootTest
@ -36,6 +44,7 @@ class BookLifecycleTest(
@Autowired private val readProgressRepository: ReadProgressRepository, @Autowired private val readProgressRepository: ReadProgressRepository,
@Autowired private val mediaRepository: MediaRepository, @Autowired private val mediaRepository: MediaRepository,
@Autowired private val userRepository: KomgaUserRepository, @Autowired private val userRepository: KomgaUserRepository,
@Autowired private val thumbnailBookRepository: ThumbnailBookRepository,
) { ) {
@MockkBean @MockkBean
@ -130,4 +139,133 @@ class BookLifecycleTest(
// then // then
assertThat(readProgressRepository.findAll()).hasSize(2) 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 package org.gotson.komga.domain.service
import com.google.common.jimfs.Configuration
import com.google.common.jimfs.Jimfs
import com.ninjasquad.springmockk.SpykBean import com.ninjasquad.springmockk.SpykBean
import io.mockk.every import io.mockk.every
import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.catchThrowable import org.assertj.core.api.Assertions.catchThrowable
import org.gotson.komga.domain.model.BookMetadata import org.gotson.komga.domain.model.BookMetadata
import org.gotson.komga.domain.model.Media 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.ThumbnailSeries
import org.gotson.komga.domain.model.makeBook import org.gotson.komga.domain.model.makeBook
import org.gotson.komga.domain.model.makeLibrary 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.MediaRepository
import org.gotson.komga.domain.persistence.SeriesMetadataRepository import org.gotson.komga.domain.persistence.SeriesMetadataRepository
import org.gotson.komga.domain.persistence.SeriesRepository 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.jooq.exception.DataAccessException
import org.junit.jupiter.api.AfterAll import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.AfterEach 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.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.context.junit.jupiter.SpringExtension 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) @ExtendWith(SpringExtension::class)
@SpringBootTest @SpringBootTest
@ -35,7 +43,9 @@ class SeriesLifecycleTest(
@Autowired private val bookLifecycle: BookLifecycle, @Autowired private val bookLifecycle: BookLifecycle,
@Autowired private val seriesRepository: SeriesRepository, @Autowired private val seriesRepository: SeriesRepository,
@Autowired private val bookRepository: BookRepository, @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 @SpykBean
@ -265,4 +275,171 @@ class SeriesLifecycleTest(
assertThat(thrown).isInstanceOf(IllegalArgumentException::class.java) 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))
}
}
} }