From 58fbd9510ae5638a06b9be7e4d5119788bbdeefa Mon Sep 17 00:00:00 2001 From: Tiberiu Ichim Date: Tue, 17 Feb 2026 10:56:29 +0200 Subject: [PATCH] Consolidate singles --- artifacts/2026-02-17/consolidate_singles.md | 42 ++++++++++++++ client/components/cards/LazyBookCard.vue | 2 +- client/pages/item/_id/index.vue | 2 +- server/controllers/LibraryItemController.js | 63 ++++++++++++++++----- server/models/LibraryItem.js | 17 ++++++ 5 files changed, 110 insertions(+), 16 deletions(-) create mode 100644 artifacts/2026-02-17/consolidate_singles.md diff --git a/artifacts/2026-02-17/consolidate_singles.md b/artifacts/2026-02-17/consolidate_singles.md new file mode 100644 index 000000000..1c17fee9e --- /dev/null +++ b/artifacts/2026-02-17/consolidate_singles.md @@ -0,0 +1,42 @@ +# Consolidate Single File Books Specification + +## Overview +The "Consolidate" feature is being expanded to support books that are currently single files (e.g., `.m4b`, `.mp3`) located directly in the root of a library folder. When consolidated, these files will be moved into a newly created folder named according to the `Author - Title` convention. + +## Implementation Details + +### Server-Side Changes + +#### 1. `handleMoveLibraryItem` Helper +Modified the internal helper to handle the case where a single file is being moved to a new "folder" name. +- **Logic**: If `libraryItem.isFile` is true AND a `newItemFolderName` is provided: + 1. Create the target directory: `Path.join(targetFolder.path, newItemFolderName)`. + 2. Move the file into this new directory. + 3. Update the `libraryItem.isFile` status to `false`. + 4. Update the `libraryItem.path` to the new directory path. + +#### 2. `LibraryItemController.consolidate` +- Removed the restriction that blocked consolidation for items where `isFile` is true. +- Consolidation is now permitted for any library item of type `book`. + +#### 3. `LibraryItemController.batchConsolidate` +- Updated the batch processing loop to allow processing of items where `isFile` is true. + +#### 4. `LibraryItem` Model +Added a `beforeSave` hook to the `LibraryItem` model to ensure that: +- **Metadata Sync**: Denormalized fields (`title`, `titleIgnorePrefix`, `authorNamesFirstLast`, `authorNamesLastFirst`) are automatically synchronized from the linked `media` object (Book or Podcast) before saving. +- **Status Recalculation**: The `isNotConsolidated` flag is recalculated whenever the item is saved, using the latest metadata and path. This ensures that features like "Match" or manual metadata updates immediately reflect the correct consolidation status in the library listings. + +### Client-Side Changes + +#### 1. `LazyBookCard.vue` +- Updated the context menu logic to show the "Consolidate" option for single-file books. +- Removed the `!this.isFile` check in the `moreMenuItems` computed property. + +#### 2. `item/_id/index.vue` (Book View Page) +- Updated the primary action button for consolidation to be visible even for single-file books. +- Removed the `!isFile` check in the template. + +## Verification +- **Scenario 1 (Single File Consolidation)**: Navigate to a book that is a single M4b file in the library root. Click "Consolidate". The file should be moved into a folder named `Author - Title`, and the UI should update showing the book is now consolidated. The database entry should reflect `isFile: false`. +- **Scenario 2 (Metadata Update Recalculation)**: Navigate to a folder-based book that is currently marked as "Consolidated". Manually rename the title in the "Edit" modal. Upon saving, the "Not Consolidated" indicator should appear because the folder name no longer matches the `Author - Title` convention based on the new metadata. diff --git a/client/components/cards/LazyBookCard.vue b/client/components/cards/LazyBookCard.vue index 12649eae6..4ea1a6ccb 100644 --- a/client/components/cards/LazyBookCard.vue +++ b/client/components/cards/LazyBookCard.vue @@ -575,7 +575,7 @@ export default { func: 'showEditModalMatch', text: this.$strings.HeaderMatch }) - if (!this.isFile && !this.isPodcast) { + if (!this.isPodcast) { items.push({ func: 'consolidate', text: 'Consolidate' diff --git a/client/pages/item/_id/index.vue b/client/pages/item/_id/index.vue index d67c8e152..8159ffc11 100644 --- a/client/pages/item/_id/index.vue +++ b/client/pages/item/_id/index.vue @@ -113,7 +113,7 @@ - + diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js index 6b8be7a59..22f9147e8 100644 --- a/server/controllers/LibraryItemController.js +++ b/server/controllers/LibraryItemController.js @@ -50,6 +50,7 @@ const ShareManager = require('../managers/ShareManager') async function handleMoveLibraryItem(libraryItem, targetLibrary, targetFolder, newItemFolderName = null) { const oldPath = libraryItem.path const oldLibraryId = libraryItem.libraryId + const oldIsFile = libraryItem.isFile // Calculate new paths const itemFolderName = newItemFolderName || Path.basename(libraryItem.path) @@ -72,7 +73,15 @@ async function handleMoveLibraryItem(libraryItem, targetLibrary, targetFolder, n // Move files on disk if (!isSamePath) { Logger.info(`[LibraryItemController] Moving item "${libraryItem.media.title}" from "${oldPath}" to "${newPath}"`) - await fs.move(oldPath, newPath) + if (libraryItem.isFile && newItemFolderName) { + // Handle single file consolidation: create folder and move file inside + await fs.ensureDir(newPath) + const destPath = Path.join(newPath, Path.basename(oldPath)) + await fs.move(oldPath, destPath) + libraryItem.isFile = false + } else { + await fs.move(oldPath, newPath) + } } // Update database within a transaction @@ -93,10 +102,18 @@ async function handleMoveLibraryItem(libraryItem, targetLibrary, targetFolder, n if (libraryItem.libraryFiles?.length) { libraryItem.libraryFiles = libraryItem.libraryFiles.map((lf) => { if (lf.metadata?.path) { - lf.metadata.path = lf.metadata.path.replace(oldPath, newPath) + if (oldIsFile && newItemFolderName) { + lf.metadata.path = Path.join(newPath, Path.basename(lf.metadata.path)) + } else { + lf.metadata.path = lf.metadata.path.replace(oldPath, newPath) + } } if (lf.metadata?.relPath) { - lf.metadata.relPath = lf.metadata.relPath.replace(oldRelPath, newRelPath) + if (oldIsFile && newItemFolderName) { + lf.metadata.relPath = Path.join(newRelPath, Path.basename(lf.metadata.relPath)) + } else { + lf.metadata.relPath = lf.metadata.relPath.replace(oldRelPath, newRelPath) + } } return lf }) @@ -110,10 +127,18 @@ async function handleMoveLibraryItem(libraryItem, targetLibrary, targetFolder, n if (libraryItem.media.audioFiles?.length) { libraryItem.media.audioFiles = libraryItem.media.audioFiles.map((af) => { if (af.metadata?.path) { - af.metadata.path = af.metadata.path.replace(oldPath, newPath) + if (oldIsFile && newItemFolderName) { + af.metadata.path = Path.join(newPath, Path.basename(af.metadata.path)) + } else { + af.metadata.path = af.metadata.path.replace(oldPath, newPath) + } } if (af.metadata?.relPath) { - af.metadata.relPath = af.metadata.relPath.replace(oldRelPath, newRelPath) + if (oldIsFile && newItemFolderName) { + af.metadata.relPath = Path.join(newRelPath, Path.basename(af.metadata.relPath)) + } else { + af.metadata.relPath = af.metadata.relPath.replace(oldRelPath, newRelPath) + } } return af }) @@ -121,18 +146,31 @@ async function handleMoveLibraryItem(libraryItem, targetLibrary, targetFolder, n } // Update ebookFile path if (libraryItem.media.ebookFile?.metadata?.path) { - libraryItem.media.ebookFile.metadata.path = libraryItem.media.ebookFile.metadata.path.replace(oldPath, newPath) + if (oldIsFile && newItemFolderName) { + libraryItem.media.ebookFile.metadata.path = Path.join(newPath, Path.basename(libraryItem.media.ebookFile.metadata.path)) + } else { + libraryItem.media.ebookFile.metadata.path = libraryItem.media.ebookFile.metadata.path.replace(oldPath, newPath) + } if (libraryItem.media.ebookFile.metadata?.relPath) { - libraryItem.media.ebookFile.metadata.relPath = libraryItem.media.ebookFile.metadata.relPath.replace(oldRelPath, newRelPath) + if (oldIsFile && newItemFolderName) { + libraryItem.media.ebookFile.metadata.relPath = Path.join(newRelPath, Path.basename(libraryItem.media.ebookFile.metadata.relPath)) + } else { + libraryItem.media.ebookFile.metadata.relPath = libraryItem.media.ebookFile.metadata.relPath.replace(oldRelPath, newRelPath) + } } libraryItem.media.changed('ebookFile', true) } // Update coverPath if (libraryItem.media.coverPath) { - libraryItem.media.coverPath = libraryItem.media.coverPath.replace(oldPath, newPath) + if (oldIsFile && newItemFolderName) { + libraryItem.media.coverPath = Path.join(newPath, Path.basename(libraryItem.media.coverPath)) + } else { + libraryItem.media.coverPath = libraryItem.media.coverPath.replace(oldPath, newPath) + } } await libraryItem.media.save({ transaction }) - } else if (libraryItem.isPodcast) { + } + else if (libraryItem.isPodcast) { // Update coverPath if (libraryItem.media.coverPath) { libraryItem.media.coverPath = libraryItem.media.coverPath.replace(oldPath, newPath) @@ -1652,9 +1690,6 @@ class LibraryItemController { if (!req.libraryItem.isBook) { return res.status(400).send('Consolidate only available for books') } - if (req.libraryItem.isFile) { - return res.status(400).send('Consolidate only available for books in a folder') - } const author = req.libraryItem.media.authors?.[0]?.name || 'Unknown Author' const title = req.libraryItem.media.title || 'Unknown Title' @@ -1713,8 +1748,8 @@ class LibraryItemController { const results = [] for (const libraryItem of libraryItems) { - if (libraryItem.mediaType !== 'book' || libraryItem.isFile) { - results.push({ id: libraryItem.id, success: false, error: 'Not a book in a folder' }) + if (libraryItem.mediaType !== 'book') { + results.push({ id: libraryItem.id, success: false, error: 'Not a book' }) continue } diff --git a/server/models/LibraryItem.js b/server/models/LibraryItem.js index 92b530768..5f1a3aef9 100644 --- a/server/models/LibraryItem.js +++ b/server/models/LibraryItem.js @@ -790,6 +790,23 @@ class LibraryItem extends Model { media.destroy() } }) + + LibraryItem.addHook('beforeSave', (instance) => { + if (instance.media) { + instance.title = instance.media.title + instance.titleIgnorePrefix = instance.media.titleIgnorePrefix + if (instance.isBook) { + if (instance.media.authors !== undefined) { + instance.authorNamesFirstLast = instance.media.authorName + instance.authorNamesLastFirst = instance.media.authorNameLF + } + } else if (instance.isPodcast) { + instance.authorNamesFirstLast = instance.media.author + instance.authorNamesLastFirst = instance.media.author + } + } + instance.isNotConsolidated = instance.checkIsNotConsolidated() + }) } get isBook() {