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))