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