diff --git a/artifacts/2026-02-20/promote_file_to_book.md b/artifacts/2026-02-20/promote_file_to_book.md new file mode 100644 index 000000000..96c9e57e2 --- /dev/null +++ b/artifacts/2026-02-20/promote_file_to_book.md @@ -0,0 +1,42 @@ +# Promote File to Book Specification + +## Overview +This feature allows users to "promote" files from an existing book into a standalone book in the library. This is useful when a single library item incorrectly groups multiple separate books or files together. The feature has two mechanisms: a quick single-file context action, and a bulk "Split Book" Wizard. + +## UI Requirements + +### 1. Single-File Promotion (Quick Action) +- Added to the "Library Files" table (`LibraryFilesTableRow.vue`). +- A new context menu item "Promote to book" is available for active files. +- Selecting it opens a confirmation prompt. + +### 2. Multi-File Book Split (Wizard) +- A "Split Book" button added to the header of the "Library Files" table (`LibraryFilesTable.vue`). +- Opens `SplitBookModal.vue`, passing the current library item files. +- Displays a table of audio/ebook files with an input binding for "Book Number" (Default 1). +- Includes an "Assign 1 to N" quick action for automatically splitting every single file into its own standalone book. +- Submits an array of file assignments containing the target Book Number. + +## Backend Requirements + +### 1. Single-File Promotion +- **Endpoint**: `POST /api/items/:id/file/:fileid/promote` +- **Logic**: + 1. Determine a new folder name based on the target filename. + 2. Create the target destination folder. + 3. Move the specified file. + 4. Detach record from the current database entry. + 5. Trigger `LibraryScanner.scan(library)` to generate the standalone library item. + +### 2. Multi-file Book Split +- **Endpoint**: `POST /api/items/:id/split` +- **Request Body**: `{ assignments: [{ ino: string, bookNumber: number }] }` +- **Logic**: + 1. Group payload assignments by `bookNumber` (ignoring `1` since that designates the current book). + 2. Iterate through groups. For each group `[Book 2, Book 3, etc]`: + a. Compute target folder path based on original directory + `- Book [N]`. + b. Ensure custom directory is created. + c. Iterate through `ino` targets and migrate target resources. + 3. Detach file payload records from existing library item. + 4. Emit completion via `SocketAuthority.libraryItemEmitter`. + 5. Call `LibraryScanner.scan(library)` to construct the new entities sequentially. diff --git a/client/components/modals/item/SplitBookModal.vue b/client/components/modals/item/SplitBookModal.vue new file mode 100644 index 000000000..8fa5c7b37 --- /dev/null +++ b/client/components/modals/item/SplitBookModal.vue @@ -0,0 +1,120 @@ + + + diff --git a/client/components/tables/LibraryFilesTable.vue b/client/components/tables/LibraryFilesTable.vue index 6f6e74b8c..b83837832 100644 --- a/client/components/tables/LibraryFilesTable.vue +++ b/client/components/tables/LibraryFilesTable.vue @@ -7,6 +7,7 @@
+ {{ $strings.ButtonSplitBook || 'Split Book' }}
@@ -28,6 +29,7 @@ +
@@ -46,6 +48,7 @@ export default { showFiles: false, showFullPath: false, showAudioFileDataModal: false, + showSplitBookModal: false, selectedAudioFile: null } }, diff --git a/client/components/tables/LibraryFilesTableRow.vue b/client/components/tables/LibraryFilesTableRow.vue index 5bc42925b..43367364d 100644 --- a/client/components/tables/LibraryFilesTableRow.vue +++ b/client/components/tables/LibraryFilesTableRow.vue @@ -55,6 +55,12 @@ export default { action: 'download' }) } + if (this.userCanDelete && (this.file.audioFile || this.file.isEBookFile)) { + items.push({ + text: this.$strings.LabelPromoteToBook || 'Promote to book', + action: 'promote' + }) + } if (this.userCanDelete) { items.push({ text: this.$strings.ButtonDelete, @@ -77,6 +83,8 @@ export default { this.deleteLibraryFile() } else if (action === 'download') { this.downloadLibraryFile() + } else if (action === 'promote') { + this.promoteLibraryFile() } else if (action === 'more') { this.$emit('showMore', this.file.audioFile) } @@ -103,6 +111,27 @@ export default { }, downloadLibraryFile() { this.$downloadFile(this.downloadUrl, this.file.metadata.filename) + }, + promoteLibraryFile() { + const payload = { + message: this.$strings.MessageConfirmPromoteFile || 'Are you sure you want to promote this file to a new book?', + callback: (confirmed) => { + if (confirmed) { + this.$axios + .$post(`/api/items/${this.libraryItemId}/file/${this.file.ino}/promote`) + .then(() => { + this.$toast.success(this.$strings.ToastPromoteFileSuccess || 'File successfully promoted to new book') + }) + .catch((error) => { + console.error('Failed to promote file', error) + const errorMsg = error.response?.data || 'Unknown error' + this.$toast.error(this.$strings.ToastPromoteFileFailed || `Failed to promote file: ${errorMsg}`) + }) + } + }, + type: 'yesNo' + } + this.$store.commit('globals/setConfirmPrompt', payload) } }, mounted() {} diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js index ecbbe31c8..1ea741ac9 100644 --- a/server/controllers/LibraryItemController.js +++ b/server/controllers/LibraryItemController.js @@ -13,6 +13,7 @@ const { getAudioMimeTypeFromExtname, encodeUriPath, sanitizeFilename } = require const LibraryItemScanner = require('../scanner/LibraryItemScanner') const AudioFileScanner = require('../scanner/AudioFileScanner') const Scanner = require('../scanner/Scanner') +const LibraryScanner = require('../scanner/LibraryScanner') const Watcher = require('../Watcher') const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilters') @@ -1477,6 +1478,176 @@ class LibraryItemController { res.sendStatus(200) } + /** + * POST api/items/:id/file/:fileid/promote + * + * @param {LibraryItemControllerRequestWithFile} req + * @param {Response} res + */ + async promoteLibraryFile(req, res) { + if (!req.user.canDelete) { + Logger.warn(`[LibraryItemController] User "${req.user.username}" attempted to promote file without permission`) + return res.sendStatus(403) + } + + if (!req.libraryItem.isBook) { + return res.status(400).send('Promote only available for books') + } + + const libraryFile = req.libraryFile + + // Determine new folder name based on file name without extension + const ext = Path.extname(libraryFile.metadata.path) + const baseName = Path.basename(libraryFile.metadata.path, ext) + const sanitizedFolderName = Database.libraryItemModel.getConsolidatedFolderName('Unknown Author', baseName) + + const library = await Database.libraryModel.findByIdWithFolders(req.libraryItem.libraryId) + // Find the library folder that currently contains this item + const targetFolder = library.libraryFolders.find((f) => req.libraryItem.path.startsWith(f.path)) || library.libraryFolders[0] + + const targetPath = Path.join(targetFolder.path, sanitizedFolderName) + + if (await fs.pathExists(targetPath)) { + return res.status(409).send('Destination folder already exists') + } + + try { + await fs.ensureDir(targetPath) + + const newFilePath = Path.join(targetPath, Path.basename(libraryFile.metadata.path)) + await fs.move(libraryFile.metadata.path, newFilePath) + + // Remove the file from the original library item + req.libraryItem.libraryFiles = req.libraryItem.libraryFiles.filter((lf) => lf.ino !== req.params.fileid) + req.libraryItem.changed('libraryFiles', true) + + if (req.libraryItem.media.audioFiles.some((af) => af.ino === req.params.fileid)) { + req.libraryItem.media.audioFiles = req.libraryItem.media.audioFiles.filter((af) => af.ino !== req.params.fileid) + req.libraryItem.media.changed('audioFiles', true) + } else if (req.libraryItem.media.ebookFile?.ino === req.params.fileid) { + req.libraryItem.media.ebookFile = null + req.libraryItem.media.changed('ebookFile', true) + } + + if (!req.libraryItem.media.hasMediaFiles) { + req.libraryItem.isMissing = true + } + + if (req.libraryItem.media.changed()) { + await req.libraryItem.media.save() + } + + await req.libraryItem.save() + + SocketAuthority.libraryItemEmitter('item_updated', req.libraryItem) + + // Trigger scan on the library to pick up the new folder + LibraryScanner.scan(library) + + res.json({ + success: true + }) + } catch (error) { + Logger.error(`[LibraryItemController] Failed to promote file`, error) + return res.status(500).send(error.message || 'Failed to promote file') + } + } + + /** + * POST api/items/:id/split + * + * @param {LibraryItemControllerRequest} req + * @param {Response} res + */ + async splitLibraryItem(req, res) { + if (!req.user.canDelete) { + Logger.warn(`[LibraryItemController] User "${req.user.username}" attempted to split book without permission`) + return res.sendStatus(403) + } + + if (!req.libraryItem.isBook) { + return res.status(400).send('Split only available for books') + } + + const assignments = req.body.assignments || [] + if (!assignments.length) { + return res.status(400).send('No file assignments provided') + } + + const library = await Database.libraryModel.findByIdWithFolders(req.libraryItem.libraryId) + const targetFolder = library.libraryFolders.find((f) => req.libraryItem.path.startsWith(f.path)) || library.libraryFolders[0] + + // Group files by bookNumber + const groups = {} + assignments.forEach(({ ino, bookNumber }) => { + // Only care about split files (bookNumber > 1) + if (bookNumber > 1) { + if (!groups[bookNumber]) groups[bookNumber] = [] + groups[bookNumber].push(ino) + } + }) + + if (Object.keys(groups).length === 0) { + return res.status(400).send('No files were assigned to new books') + } + + // Process each group + try { + const originalPathBase = Path.basename(req.libraryItem.path) + let filesRemoved = 0 + + for (const [bookNumber, inos] of Object.entries(groups)) { + const newFolderName = `${originalPathBase} - Book ${bookNumber}` + const targetPath = Path.join(targetFolder.path, newFolderName) + + await fs.ensureDir(targetPath) + + for (const ino of inos) { + const libraryFile = req.libraryItem.getLibraryFileWithIno(ino) + if (!libraryFile) continue + + const newFilePath = Path.join(targetPath, Path.basename(libraryFile.metadata.path)) + await fs.move(libraryFile.metadata.path, newFilePath) + + // Remove the file from original library item + req.libraryItem.libraryFiles = req.libraryItem.libraryFiles.filter((lf) => lf.ino !== ino) + + if (req.libraryItem.media.audioFiles.some((af) => af.ino === ino)) { + req.libraryItem.media.audioFiles = req.libraryItem.media.audioFiles.filter((af) => af.ino !== ino) + } else if (req.libraryItem.media.ebookFile?.ino === ino) { + req.libraryItem.media.ebookFile = null + } + filesRemoved++ + } + } + + if (filesRemoved > 0) { + req.libraryItem.changed('libraryFiles', true) + req.libraryItem.media.changed('audioFiles', true) + req.libraryItem.media.changed('ebookFile', true) + + if (!req.libraryItem.media.hasMediaFiles) { + req.libraryItem.isMissing = true + } + + if (req.libraryItem.media.changed()) { + await req.libraryItem.media.save() + } + + await req.libraryItem.save() + SocketAuthority.libraryItemEmitter('item_updated', req.libraryItem) + + // Trigger scan on the library to pick up the new folders + LibraryScanner.scan(library) + } + + res.json({ success: true, filesMoved: filesRemoved }) + } catch (error) { + Logger.error(`[LibraryItemController] Failed to split book`, error) + return res.status(500).send(error.message || 'Failed to split book') + } + } + /** * GET api/items/:id/file/:fileid/download * Same as GET api/items/:id/file/:fileid but allows logging and restricting downloads diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index a46dbed89..c7cc65e2f 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -131,9 +131,11 @@ class ApiRouter { this.router.get('/items/:id/ffprobe/:fileid', LibraryItemController.middleware.bind(this), LibraryItemController.getFFprobeData.bind(this)) this.router.get('/items/:id/file/:fileid', LibraryItemController.middleware.bind(this), LibraryItemController.getLibraryFile.bind(this)) this.router.delete('/items/:id/file/:fileid', LibraryItemController.middleware.bind(this), LibraryItemController.deleteLibraryFile.bind(this)) + this.router.post('/items/:id/file/:fileid/promote', LibraryItemController.middleware.bind(this), LibraryItemController.promoteLibraryFile.bind(this)) this.router.get('/items/:id/file/:fileid/download', LibraryItemController.middleware.bind(this), LibraryItemController.downloadLibraryFile.bind(this)) this.router.get('/items/:id/ebook/:fileid?', LibraryItemController.middleware.bind(this), LibraryItemController.getEBookFile.bind(this)) this.router.patch('/items/:id/ebook/:fileid/status', LibraryItemController.middleware.bind(this), LibraryItemController.updateEbookFileStatus.bind(this)) + this.router.post('/items/:id/split', LibraryItemController.middleware.bind(this), LibraryItemController.splitLibraryItem.bind(this)) this.router.post('/items/:id/move', LibraryItemController.middleware.bind(this), LibraryItemController.move.bind(this)) this.router.post('/items/:id/consolidate', LibraryItemController.middleware.bind(this), LibraryItemController.consolidate.bind(this))