diff --git a/artifacts/2026-02-13/consolidate.md b/artifacts/2026-02-13/consolidate.md
index 203a0e0bc..4d4edd89e 100644
--- a/artifacts/2026-02-13/consolidate.md
+++ b/artifacts/2026-02-13/consolidate.md
@@ -48,6 +48,17 @@ The "Consolidate" feature allows users to organize their book library by renamin
- `handleMoveLibraryItem` assumes `itemFolderName` is `Path.basename(libraryItem.path)`. It does NOT rename the folder name itself.
- So we need a custom logic or a modified helper that supports renaming.
- **Response**: JSON object indicating success and the updated item.
+- **Robustness Improvements**:
+ - Enhanced `handleMoveLibraryItem` to detect if the source and destination paths are identical.
+ - Skips file move operation if the paths match, preventing "Destination already exists" Errors.
+ - Ensures `Watcher` ignore directories are correctly managed even when paths are identical.
+
+## Batch Consolidation
+
+- **Endpoint**: `POST /api/items/batch/consolidate`
+- **Controller**: `LibraryItemController.batchConsolidate`
+- **Logic**: Iterates through selected item IDs and calls the consolidation logic for each.
+- **Response**: Summary of successful and failed consolidation operations.
## Artifacts
diff --git a/artifacts/2026-02-15/consolidation_badge.md b/artifacts/2026-02-15/consolidation_badge.md
index 836fc4e2f..97cde5092 100644
--- a/artifacts/2026-02-15/consolidation_badge.md
+++ b/artifacts/2026-02-15/consolidation_badge.md
@@ -15,11 +15,19 @@ Add a visual indicator (badge) to the book thumbnail card in listings to identif
### Backend (Server)
- **Model**: `LibraryItem` (`server/models/LibraryItem.js`)
-- **Logic**: Added `checkIsNotConsolidated()` which:
- 1. Checks if the item is a book folder.
- 2. Sanitizes the `Author - Title` name using `sanitizeFilename`.
+- **Logic**: Enhanced `checkIsNotConsolidated()` which:
+ 1. Checks if the item is a book folder (not a single file).
+ 2. Sanitizes the `Author - Title` name using `LibraryItem.getConsolidatedFolderName(author, title)`.
3. Compares the sanitized name with the folder's name (`Path.basename(this.path)`).
-- **API**: The flag `isNotConsolidated` is included in the JSON response for library items.
+ 4. **Subfolder Check**: Verifies the item is located at the root of the library folder. If it's in a subfolder (e.g., `Author/Title`), it's considered "Not Consolidated" even if the folder name is correct.
+
+### Library-wide Status Update Tool
+A tool was added to the Library Settings to allow manual re-evaluation of the consolidation status for all items.
+
+- **Frontend**: Added "Update Consolidation Status" button in Library Settings -> Tools tab.
+- **Backend Controller**: `LibraryController.updateConsolidationStatus`
+- **API**: `POST /api/libraries/:id/update-consolidation`
+- **Behavior**: Iterates through all items in the library, runs `checkIsNotConsolidated()`, and updates the database flag if it has changed. This is useful if the folder structure was manually altered on disk outside of the application.
### Frontend (Client)
- **Component**: `LazyBookCard` (`client/components/cards/LazyBookCard.vue`)
@@ -32,4 +40,4 @@ Add a visual indicator (badge) to the book thumbnail card in listings to identif
- **UI (Badge)**: Badge added next to the book title when `isNotConsolidated` is true.
- **UI (Button)**: "Consolidate" button added to the primary action row (after Edit and Mark as Finished).
- **Behavior**: The "Consolidate" button is disabled if the book is already consolidated.
-- **Cleanup**: The "Consolidate" option has been removed from the context menu on this page.
+- **Robustness**: Modified `handleMoveLibraryItem` to correctly identify when a book is already at its target path, avoiding redundant file operations and preventing "destination already exists" errors.
diff --git a/client/components/modals/libraries/LibraryTools.vue b/client/components/modals/libraries/LibraryTools.vue
index ffa3f4924..44875b663 100644
--- a/client/components/modals/libraries/LibraryTools.vue
+++ b/client/components/modals/libraries/LibraryTools.vue
@@ -37,6 +37,18 @@
+
+
+
+
{{ $strings.LabelUpdateConsolidationStatus }}
+
{{ $strings.LabelUpdateConsolidationStatusHelp }}
+
+
+
+ {{ $strings.ButtonUpdate }}
+
+
+
@@ -158,6 +170,34 @@ export default {
.finally(() => {
this.$emit('update:processing', false)
})
+ },
+ updateConsolidationStatusClick() {
+ const payload = {
+ message: this.$strings.MessageConfirmUpdateConsolidationStatus,
+ persistent: true,
+ callback: (confirmed) => {
+ if (confirmed) {
+ this.updateConsolidationStatus()
+ }
+ },
+ type: 'yesNo'
+ }
+ this.$store.commit('globals/setConfirmPrompt', payload)
+ },
+ updateConsolidationStatus() {
+ this.$emit('update:processing', true)
+ this.$axios
+ .$post(`/api/libraries/${this.libraryId}/update-consolidation`)
+ .then((data) => {
+ this.$toast.success(this.$getString('ToastUpdateConsolidationStatusSuccess', [data.updated]))
+ })
+ .catch((error) => {
+ console.error('Failed to update consolidation status', error)
+ this.$toast.error(this.$strings.ToastUpdateConsolidationStatusFailed)
+ })
+ .finally(() => {
+ this.$emit('update:processing', false)
+ })
}
},
mounted() {}
diff --git a/client/strings/en-us.json b/client/strings/en-us.json
index 127159320..6d79693a5 100644
--- a/client/strings/en-us.json
+++ b/client/strings/en-us.json
@@ -107,6 +107,7 @@
"ButtonStartMetadataEmbed": "Start Metadata Embed",
"ButtonStats": "Stats",
"ButtonSubmit": "Submit",
+ "ButtonUpdate": "Update",
"ButtonTest": "Test",
"ButtonUnlinkOpenId": "Unlink OpenID",
"ButtonUpload": "Upload",
@@ -286,6 +287,8 @@
"LabelChaptersFound": "chapters found",
"LabelCleanupAuthors": "Cleanup authors",
"LabelCleanupAuthorsHelp": "Remove authors that have no books in this library.",
+ "LabelUpdateConsolidationStatus": "Update Consolidation Status",
+ "LabelUpdateConsolidationStatusHelp": "Checks all items in this library and updates their consolidation status. This is useful if you have manually moved folders on disk.",
"LabelClickForMoreInfo": "Click for more info",
"LabelClickToUseCurrentValue": "Click to use current value",
"LabelClosePlayer": "Close player",
@@ -806,6 +809,7 @@
"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?",
+ "MessageConfirmUpdateConsolidationStatus": "Are you sure you want to update the consolidation status for all items in this library? This will re-calculate the 'Not Consolidated' badge for every book.",
"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?",
@@ -1188,6 +1192,8 @@
"ToastUserPasswordMismatch": "Passwords do not match",
"ToastUserPasswordMustChange": "New password cannot match old password",
"ToastUserRootRequireName": "Must enter a root username",
+ "ToastUpdateConsolidationStatusSuccess": "Successfully updated consolidation status for {0} items.",
+ "ToastUpdateConsolidationStatusFailed": "Failed to update consolidation status.",
"TooltipAddChapters": "Add chapter(s)",
"TooltipAddOneSecond": "Add 1 second",
"TooltipAdjustChapterStart": "Click to adjust start time",
diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js
index 83966102c..fd49cdebf 100644
--- a/server/controllers/LibraryController.js
+++ b/server/controllers/LibraryController.js
@@ -1374,6 +1374,39 @@ class LibraryController {
})
}
+ /**
+ * POST: /api/libraries/:id/update-consolidation
+ * Update isNotConsolidated flag for all items in library
+ *
+ * @param {LibraryControllerRequest} req
+ * @param {Response} res
+ */
+ async updateConsolidationStatus(req, res) {
+ if (!req.user.isAdminOrUp) {
+ Logger.error(`[LibraryController] Non-admin user "${req.user.username}" attempted to update consolidation status`)
+ return res.sendStatus(403)
+ }
+
+ const items = await Database.libraryItemModel.findAllExpandedWhere({
+ libraryId: req.library.id
+ })
+
+ let updatedCount = 0
+ for (const item of items) {
+ const isNotConsolidated = item.checkIsNotConsolidated()
+ if (item.isNotConsolidated !== isNotConsolidated) {
+ item.isNotConsolidated = isNotConsolidated
+ await item.save()
+ updatedCount++
+ }
+ }
+
+ Logger.info(`[LibraryController] Updated consolidation status for ${updatedCount} items in library "${req.library.name}"`)
+ res.json({
+ updated: updatedCount
+ })
+ }
+
/**
* GET: /api/libraries/:id/podcast-titles
*
diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js
index c36439997..6b8be7a59 100644
--- a/server/controllers/LibraryItemController.js
+++ b/server/controllers/LibraryItemController.js
@@ -58,19 +58,22 @@ async function handleMoveLibraryItem(libraryItem, targetLibrary, targetFolder, n
// Check if destination already exists
const destinationExists = await fs.pathExists(newPath)
- if (destinationExists) {
+ const isSamePath = oldPath === newPath
+ if (destinationExists && !isSamePath) {
throw new Error(`Destination already exists: ${newPath}`)
}
try {
Watcher.addIgnoreDir(oldPath)
- Watcher.addIgnoreDir(newPath)
+ if (!isSamePath) Watcher.addIgnoreDir(newPath)
const oldRelPath = libraryItem.relPath
// Move files on disk
- Logger.info(`[LibraryItemController] Moving item "${libraryItem.media.title}" from "${oldPath}" to "${newPath}"`)
- await fs.move(oldPath, newPath)
+ if (!isSamePath) {
+ Logger.info(`[LibraryItemController] Moving item "${libraryItem.media.title}" from "${oldPath}" to "${newPath}"`)
+ await fs.move(oldPath, newPath)
+ }
// Update database within a transaction
const transaction = await Database.sequelize.transaction()
@@ -276,7 +279,7 @@ async function handleMoveLibraryItem(libraryItem, targetLibrary, targetFolder, n
throw error
} finally {
Watcher.removeIgnoreDir(oldPath)
- Watcher.removeIgnoreDir(newPath)
+ if (typeof isSamePath !== 'undefined' && !isSamePath) Watcher.removeIgnoreDir(newPath)
}
}
diff --git a/server/models/LibraryItem.js b/server/models/LibraryItem.js
index 085b349f7..92b530768 100644
--- a/server/models/LibraryItem.js
+++ b/server/models/LibraryItem.js
@@ -927,7 +927,12 @@ class LibraryItem extends Model {
const title = this.title || 'Unknown Title'
const folderName = LibraryItem.getConsolidatedFolderName(author, title)
const currentFolderName = Path.basename(this.path.replace(/[\/\\]$/, ''))
- return currentFolderName !== folderName
+ if (currentFolderName !== folderName) return true
+
+ // Check if it is in a subfolder
+ const relPathPOSIX = (this.relPath || '').replace(/\\/g, '/')
+ const cleanRelPath = relPathPOSIX.replace(/\/$/, '')
+ return cleanRelPath !== currentFolderName
}
static getConsolidatedFolderName(author, title) {
diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js
index a8c187aab..a223de9b2 100644
--- a/server/routers/ApiRouter.js
+++ b/server/routers/ApiRouter.js
@@ -94,6 +94,7 @@ class ApiRouter {
this.router.get('/libraries/:id/opml', LibraryController.middleware.bind(this), LibraryController.getOPMLFile.bind(this))
this.router.post('/libraries/order', LibraryController.reorder.bind(this))
this.router.post('/libraries/:id/remove-metadata', LibraryController.middleware.bind(this), LibraryController.removeAllMetadataFiles.bind(this))
+ this.router.post('/libraries/:id/update-consolidation', LibraryController.middleware.bind(this), LibraryController.updateConsolidationStatus.bind(this))
this.router.get('/libraries/:id/podcast-titles', LibraryController.middleware.bind(this), LibraryController.getPodcastTitles.bind(this))
this.router.get('/libraries/:id/download', LibraryController.middleware.bind(this), LibraryController.downloadMultiple.bind(this))