diff --git a/client/components/modals/libraries/LibraryTools.vue b/client/components/modals/libraries/LibraryTools.vue index 69659b7be..ffa3f4924 100644 --- a/client/components/modals/libraries/LibraryTools.vue +++ b/client/components/modals/libraries/LibraryTools.vue @@ -25,6 +25,18 @@ +
+
+
+

{{ $strings.LabelCleanupAuthors }}

+

{{ $strings.LabelCleanupAuthorsHelp }}

+
+
+
+ {{ $strings.ButtonRemove }} +
+
+
@@ -114,6 +126,38 @@ export default { .finally(() => { this.$emit('update:processing', false) }) + }, + cleanupAuthorsClick() { + const payload = { + message: this.$strings.MessageConfirmCleanupAuthors, + persistent: true, + callback: (confirmed) => { + if (confirmed) { + this.cleanupAuthors() + } + }, + type: 'yesNo' + } + this.$store.commit('globals/setConfirmPrompt', payload) + }, + cleanupAuthors() { + this.$emit('update:processing', true) + this.$axios + .$delete(`/api/libraries/${this.libraryId}/authors/cleanup?force=1`) + .then((data) => { + if (data.removed) { + this.$toast.success(this.$getString('ToastCleanupAuthorsSuccess', [data.removed])) + } else { + this.$toast.info(this.$strings.ToastCleanupAuthorsNoAuthors) + } + }) + .catch((error) => { + console.error('Failed to cleanup authors', error) + this.$toast.error(this.$strings.ToastCleanupAuthorsFailed) + }) + .finally(() => { + this.$emit('update:processing', false) + }) } }, mounted() {} diff --git a/client/strings/en-us.json b/client/strings/en-us.json index 567cfcf3b..54c38a575 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -284,6 +284,8 @@ "LabelChapterTitle": "Chapter Title", "LabelChapters": "Chapters", "LabelChaptersFound": "chapters found", + "LabelCleanupAuthors": "Cleanup authors", + "LabelCleanupAuthorsHelp": "Remove authors that have no books in this library.", "LabelClickForMoreInfo": "Click for more info", "LabelClickToUseCurrentValue": "Click to use current value", "LabelClosePlayer": "Close player", @@ -800,6 +802,7 @@ "MessageConfirmRemoveCollection": "Are you sure you want to remove collection \"{0}\"?", "MessageConfirmRemoveEpisode": "Are you sure you want to remove episode \"{0}\"?", "MessageConfirmRemoveItemsWithIssues": "Are you sure you want to remove all items with issues?", + "MessageConfirmCleanupAuthors": "Are you sure you want to remove all authors with no books in this library?", "MessageConfirmRemoveEpisodeNote": "Note: This does not delete the audio file unless toggling \"Hard delete file\"", "MessageConfirmRemoveEpisodes": "Are you sure you want to remove {0} episodes?", "MessageConfirmRemoveListeningSessions": "Are you sure you want to remove {0} listening sessions?", @@ -1085,6 +1088,9 @@ "ToastLibraryItemsWithIssuesNoneFound": "No items with issues found", "ToastLibraryItemsWithIssuesRemoved": "Removed items with issues", "ToastLibraryItemsWithIssuesRemoveFailed": "Failed to remove items with issues", + "ToastCleanupAuthorsSuccess": "Cleaned up {0} authors", + "ToastCleanupAuthorsNoAuthors": "No authors found to cleanup", + "ToastCleanupAuthorsFailed": "Failed to cleanup authors", "ToastLibraryScanStarted": "Library scan started", "ToastLibraryUpdateSuccess": "Library \"{0}\" updated", "ToastMatchAllAuthorsFailed": "Failed to match all authors", diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js index 55ef45690..83966102c 100644 --- a/server/controllers/LibraryController.js +++ b/server/controllers/LibraryController.js @@ -1412,6 +1412,57 @@ class LibraryController { }) } + /** + * DELETE: /api/libraries/:id/authors/cleanup + * Remove authors with no books, no description and no image + * + * @this {import('../routers/ApiRouter')} + * + * @param {LibraryControllerRequest} req + * @param {Response} res + */ + async cleanupAuthorsWithNoBooks(req, res) { + if (!req.user.isAdminOrUp) { + Logger.error(`[LibraryController] Non-admin user "${req.user.username}" attempted to cleanup authors`) + return res.sendStatus(403) + } + + const force = req.query.force === '1' + + const authors = await Database.authorModel.findAll({ + where: { + libraryId: req.library.id + }, + attributes: ['id'] + }) + + if (!authors.length) { + return res.json({ + removed: 0 + }) + } + + const authorIds = authors.map((a) => a.id) + const initialCount = authorIds.length + + // This method is defined on ApiRouter + await this.checkRemoveAuthorsWithNoBooks(authorIds, force) + + // Check how many are left + const remainingCount = await Database.authorModel.count({ + where: { + id: authorIds + } + }) + + const removed = initialCount - remainingCount + Logger.info(`[LibraryController] Cleaned up ${removed} authors (force=${force}) with no books for library "${req.library.name}"`) + + res.json({ + removed + }) + } + /** * GET: /api/library/:id/download * Downloads multiple library items diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index 27761e9a8..08cc3e5fe 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -84,6 +84,7 @@ class ApiRouter { this.router.get('/libraries/:id/search', LibraryController.middleware.bind(this), LibraryController.search.bind(this)) this.router.get('/libraries/:id/stats', LibraryController.middleware.bind(this), LibraryController.stats.bind(this)) this.router.get('/libraries/:id/authors', LibraryController.middleware.bind(this), LibraryController.getAuthors.bind(this)) + this.router.delete('/libraries/:id/authors/cleanup', LibraryController.middleware.bind(this), LibraryController.cleanupAuthorsWithNoBooks.bind(this)) this.router.get('/libraries/:id/narrators', LibraryController.middleware.bind(this), LibraryController.getNarrators.bind(this)) this.router.patch('/libraries/:id/narrators/:narratorId', LibraryController.middleware.bind(this), LibraryController.updateNarrator.bind(this)) this.router.delete('/libraries/:id/narrators/:narratorId', LibraryController.middleware.bind(this), LibraryController.removeNarrator.bind(this)) @@ -464,29 +465,38 @@ class ApiRouter { * @param {string[]} authorIds * @returns {Promise} */ - async checkRemoveAuthorsWithNoBooks(authorIds) { + async checkRemoveAuthorsWithNoBooks(authorIds, force = false) { if (!authorIds?.length) return const transaction = await Database.sequelize.transaction() try { + const where = [ + sequelize.where(sequelize.literal('(SELECT count(*) FROM bookAuthors ba WHERE ba.authorId = author.id)'), 0) + ] + + if (!force) { + where.push({ + id: authorIds, + asin: { + [sequelize.Op.or]: [null, ''] + }, + description: { + [sequelize.Op.or]: [null, ''] + }, + imagePath: { + [sequelize.Op.or]: [null, ''] + } + }) + } else { + where.push({ + id: authorIds + }) + } + // Select authors with locking to prevent concurrent updates const bookAuthorsToRemove = ( await Database.authorModel.findAll({ - where: [ - { - id: authorIds, - asin: { - [sequelize.Op.or]: [null, ''] - }, - description: { - [sequelize.Op.or]: [null, ''] - }, - imagePath: { - [sequelize.Op.or]: [null, ''] - } - }, - sequelize.where(sequelize.literal('(SELECT count(*) FROM bookAuthors ba WHERE ba.authorId = author.id)'), 0) - ], + where, attributes: ['id', 'name', 'libraryId'], raw: true, transaction