Allow removing authors with no books

This commit is contained in:
Tiberiu Ichim 2026-02-12 19:21:25 +02:00
parent 771f8c586f
commit fc97b10f58
4 changed files with 127 additions and 16 deletions

View file

@ -25,6 +25,18 @@
</div> </div>
</div> </div>
</div> </div>
<div v-if="isBookLibrary" class="w-full border border-black-200 p-4 my-8">
<div class="flex flex-wrap items-center">
<div>
<p class="text-lg">{{ $strings.LabelCleanupAuthors }}</p>
<p class="max-w-sm text-sm pt-2 text-gray-300">{{ $strings.LabelCleanupAuthorsHelp }}</p>
</div>
<div class="grow" />
<div>
<ui-btn @click.stop="cleanupAuthorsClick">{{ $strings.ButtonRemove }}</ui-btn>
</div>
</div>
</div>
</div> </div>
</template> </template>
@ -114,6 +126,38 @@ export default {
.finally(() => { .finally(() => {
this.$emit('update:processing', false) 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() {} mounted() {}

View file

@ -284,6 +284,8 @@
"LabelChapterTitle": "Chapter Title", "LabelChapterTitle": "Chapter Title",
"LabelChapters": "Chapters", "LabelChapters": "Chapters",
"LabelChaptersFound": "chapters found", "LabelChaptersFound": "chapters found",
"LabelCleanupAuthors": "Cleanup authors",
"LabelCleanupAuthorsHelp": "Remove authors that have no books in this library.",
"LabelClickForMoreInfo": "Click for more info", "LabelClickForMoreInfo": "Click for more info",
"LabelClickToUseCurrentValue": "Click to use current value", "LabelClickToUseCurrentValue": "Click to use current value",
"LabelClosePlayer": "Close player", "LabelClosePlayer": "Close player",
@ -800,6 +802,7 @@
"MessageConfirmRemoveCollection": "Are you sure you want to remove collection \"{0}\"?", "MessageConfirmRemoveCollection": "Are you sure you want to remove collection \"{0}\"?",
"MessageConfirmRemoveEpisode": "Are you sure you want to remove episode \"{0}\"?", "MessageConfirmRemoveEpisode": "Are you sure you want to remove episode \"{0}\"?",
"MessageConfirmRemoveItemsWithIssues": "Are you sure you want to remove all items with issues?", "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\"", "MessageConfirmRemoveEpisodeNote": "Note: This does not delete the audio file unless toggling \"Hard delete file\"",
"MessageConfirmRemoveEpisodes": "Are you sure you want to remove {0} episodes?", "MessageConfirmRemoveEpisodes": "Are you sure you want to remove {0} episodes?",
"MessageConfirmRemoveListeningSessions": "Are you sure you want to remove {0} listening sessions?", "MessageConfirmRemoveListeningSessions": "Are you sure you want to remove {0} listening sessions?",
@ -1085,6 +1088,9 @@
"ToastLibraryItemsWithIssuesNoneFound": "No items with issues found", "ToastLibraryItemsWithIssuesNoneFound": "No items with issues found",
"ToastLibraryItemsWithIssuesRemoved": "Removed items with issues", "ToastLibraryItemsWithIssuesRemoved": "Removed items with issues",
"ToastLibraryItemsWithIssuesRemoveFailed": "Failed to remove 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", "ToastLibraryScanStarted": "Library scan started",
"ToastLibraryUpdateSuccess": "Library \"{0}\" updated", "ToastLibraryUpdateSuccess": "Library \"{0}\" updated",
"ToastMatchAllAuthorsFailed": "Failed to match all authors", "ToastMatchAllAuthorsFailed": "Failed to match all authors",

View file

@ -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 * GET: /api/library/:id/download
* Downloads multiple library items * Downloads multiple library items

View file

@ -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/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/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.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.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.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)) this.router.delete('/libraries/:id/narrators/:narratorId', LibraryController.middleware.bind(this), LibraryController.removeNarrator.bind(this))
@ -464,29 +465,38 @@ class ApiRouter {
* @param {string[]} authorIds * @param {string[]} authorIds
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
async checkRemoveAuthorsWithNoBooks(authorIds) { async checkRemoveAuthorsWithNoBooks(authorIds, force = false) {
if (!authorIds?.length) return if (!authorIds?.length) return
const transaction = await Database.sequelize.transaction() const transaction = await Database.sequelize.transaction()
try { 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 // Select authors with locking to prevent concurrent updates
const bookAuthorsToRemove = ( const bookAuthorsToRemove = (
await Database.authorModel.findAll({ await Database.authorModel.findAll({
where: [ 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)
],
attributes: ['id', 'name', 'libraryId'], attributes: ['id', 'name', 'libraryId'],
raw: true, raw: true,
transaction transaction