mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-03-01 05:29:41 +00:00
Add consolidate feature
This commit is contained in:
parent
56eca37304
commit
96707200b8
8 changed files with 294 additions and 13 deletions
|
|
@ -88,11 +88,11 @@ I have implemented the "Merge Books" feature, which allows users to combine mult
|
|||
- **`server/routers/ApiRouter.js`**: Added `POST /api/items/batch/merge` route.
|
||||
|
||||
### Frontend
|
||||
|
||||
- **`client/components/app/Appbar.vue`**: Added "Merge" option to the multi-select context menu.
|
||||
- Enabled only when multiple books are selected.
|
||||
- Shows a confirmation dialog before proceeding.
|
||||
- **`client/strings/en-us.json`**: Added localization strings for the new feature.
|
||||
- **`client/components/app/Appbar.vue`**: Added "Merge" option to the multi-select context menu.
|
||||
- Enabled only when multiple books are selected.
|
||||
- Shows a confirmation dialog before proceeding.
|
||||
- Automatically navigates to the merged book upon success.
|
||||
- **`client/strings/en-us.json`**: Added localization strings for the new feature.
|
||||
|
||||
## Verification
|
||||
|
||||
|
|
|
|||
54
artifacts/2026-02-13/consolidate.md
Normal file
54
artifacts/2026-02-13/consolidate.md
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
# Consolidate Book Feature Specification
|
||||
|
||||
## Overview
|
||||
|
||||
The "Consolidate" feature allows users to organize their book library by renaming a book's folder to a standard `Author - Book Name` format and moving it to the root of the library folder. This helps in flattening nested structures and maintaining a consistent naming convention.
|
||||
|
||||
## User Interface
|
||||
|
||||
### Frontend
|
||||
|
||||
- Context menu on Book Card (Library View).
|
||||
- Context menu on Book View page.
|
||||
- A new option "Consolidate" will be added to the book card's context menu (the "meatball" menu).
|
||||
- **Visibility**:
|
||||
- Only available for Books (not Podcasts).
|
||||
- Only available if the user has "Update" permissions.
|
||||
- Only available if the item is a folder (not a single file).
|
||||
- **Interaction**:
|
||||
- Clicking "Consolidate" triggers a confirmation dialog explaining the action.
|
||||
- Upon confirmation, the operation is performed.
|
||||
- A toast notification indicates success or failure.
|
||||
|
||||
## Backend Logic
|
||||
|
||||
- **Endpoint**: `POST /api/items/:id/consolidate`
|
||||
- **Controller**: `LibraryItemController.consolidate`
|
||||
- **Logic**:
|
||||
1. **Retrieve Item**: Fetch the library item by ID. Verify it is a book and the user has permissions.
|
||||
2. **Determine New Name**: Construct the folder name using the pattern `${Author} - ${Title}`.
|
||||
- `Author`: Primary author name.
|
||||
- `Title`: Book title.
|
||||
- **Sanitization**: Ensure the name is safe for the file system (remove illegal characters).
|
||||
3. **Determine New Path**:
|
||||
- `Target Library Folder`: The root path of the library the item belongs to.
|
||||
- `New Path`: `Path.join(LibraryRoot, NewFolderName)`.
|
||||
4. **Validation**:
|
||||
- Check if `New Path` already exists.
|
||||
- If it exists and is the same as the current path, return success (no-op).
|
||||
- If it exists and is different, return an error (or handle collision - for now, error).
|
||||
5. **Execution**:
|
||||
- Move the directory from `Old Path` to `New Path`.
|
||||
- Update the `path` and `relPath` in the `libraryItems` table.
|
||||
- Update paths of all associated files (audio files, ebook files, cover, etc.) in the database.
|
||||
- Update `libraryFolderId` to the root folder ID (if applicable/tracked).
|
||||
6. **Cleanup**:
|
||||
- If the old folder was inside another folder (e.g., `Author/Series/Book`), check if the parent folders are now empty and delete them if so (similar to how Move or Delete handles it). _Note: The existing `move` logic might handle this or we can reuse `handleMoveLibraryItem` if we can trick it or modify it._
|
||||
- Actually, `handleMoveLibraryItem` takes a `targetFolder`. If we pass the library root as `targetFolder` and rename the directory before/during move?
|
||||
- `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.
|
||||
|
||||
## Artifacts
|
||||
|
||||
- This specification is saved as `artifacts/2026-02-13/consolidate.md`.
|
||||
|
|
@ -209,6 +209,13 @@ export default {
|
|||
action: 'merge'
|
||||
})
|
||||
}
|
||||
|
||||
if (this.isBookLibrary) {
|
||||
options.push({
|
||||
text: 'Consolidate',
|
||||
action: 'consolidate'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return options
|
||||
|
|
@ -252,8 +259,38 @@ export default {
|
|||
this.batchMoveToLibrary()
|
||||
} else if (action === 'merge') {
|
||||
this.batchMerge()
|
||||
} else if (action === 'consolidate') {
|
||||
this.batchConsolidate()
|
||||
}
|
||||
},
|
||||
batchConsolidate() {
|
||||
const payload = {
|
||||
message: this.$getString('MessageConfirmConsolidate', [this.$getString('MessageItemsSelected', [this.numMediaItemsSelected]), 'Author - Title']),
|
||||
callback: (confirmed) => {
|
||||
if (confirmed) {
|
||||
this.$store.commit('setProcessingBatch', true)
|
||||
this.$axios
|
||||
.$post('/api/items/batch/consolidate', {
|
||||
libraryItemIds: this.selectedMediaItems.map((i) => i.id)
|
||||
})
|
||||
.then((data) => {
|
||||
this.$toast.success(this.$strings.ToastBatchConsolidateSuccess)
|
||||
this.cancelSelectionMode()
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Batch consolidation failed', error)
|
||||
const errorMsg = error.response?.data || this.$strings.ToastBatchConsolidateFailed
|
||||
this.$toast.error(errorMsg)
|
||||
})
|
||||
.finally(() => {
|
||||
this.$store.commit('setProcessingBatch', false)
|
||||
})
|
||||
}
|
||||
},
|
||||
type: 'yesNo'
|
||||
}
|
||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||
},
|
||||
batchMerge() {
|
||||
const payload = {
|
||||
message: this.$strings.MessageConfirmBatchMerge,
|
||||
|
|
@ -266,10 +303,14 @@ export default {
|
|||
.then((data) => {
|
||||
if (data.success) {
|
||||
this.$toast.success(this.$strings.ToastBatchMergeSuccess)
|
||||
if (data.mergedItemId) {
|
||||
this.$router.push(`/item/${data.mergedItemId}`)
|
||||
}
|
||||
} else {
|
||||
this.$toast.warning(this.$strings.ToastBatchMergePartiallySuccess)
|
||||
}
|
||||
this.cancelSelectionMode()
|
||||
this.$store.commit('globals/resetSelectedMediaItems', [])
|
||||
this.$eventBus.$emit('bookshelf_clear_selection')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Batch merge failed', error)
|
||||
|
|
|
|||
|
|
@ -565,6 +565,12 @@ export default {
|
|||
func: 'showEditModalMatch',
|
||||
text: this.$strings.HeaderMatch
|
||||
})
|
||||
if (!this.isFile && !this.isPodcast) {
|
||||
items.push({
|
||||
func: 'consolidate',
|
||||
text: 'Consolidate'
|
||||
})
|
||||
}
|
||||
}
|
||||
if ((this.userIsAdminOrUp || this.userCanDelete) && !this.isFile) {
|
||||
items.push({
|
||||
|
|
@ -799,6 +805,31 @@ export default {
|
|||
// More menu func
|
||||
this.$emit('edit', this.libraryItem, 'match')
|
||||
},
|
||||
consolidate() {
|
||||
const payload = {
|
||||
message: this.$getString('MessageConfirmConsolidate', [this.title, `${this.author} - ${this.title}`]),
|
||||
callback: (confirmed) => {
|
||||
if (confirmed) {
|
||||
this.processing = true
|
||||
const axios = this.$axios || this.$nuxt.$axios
|
||||
axios
|
||||
.$post(`/api/items/${this.libraryItemId}/consolidate`)
|
||||
.then(() => {
|
||||
this.$toast.success(this.$strings.ToastConsolidateSuccess || 'Consolidate successful')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to consolidate', error)
|
||||
this.$toast.error(error.response?.data || this.$strings.ToastConsolidateFailed || 'Consolidate failed')
|
||||
})
|
||||
.finally(() => {
|
||||
this.processing = false
|
||||
})
|
||||
}
|
||||
},
|
||||
type: 'yesNo'
|
||||
}
|
||||
this.store.commit('globals/setConfirmPrompt', payload)
|
||||
},
|
||||
sendToDevice(deviceName) {
|
||||
// More menu func
|
||||
const payload = {
|
||||
|
|
|
|||
|
|
@ -428,6 +428,12 @@ export default {
|
|||
text: this.$strings.ButtonReScan,
|
||||
action: 'rescan'
|
||||
})
|
||||
if (!this.isFile && !this.isPodcast) {
|
||||
items.push({
|
||||
text: 'Consolidate',
|
||||
action: 'consolidate'
|
||||
})
|
||||
}
|
||||
items.push({
|
||||
text: this.$strings.ButtonMoveToLibrary,
|
||||
action: 'move'
|
||||
|
|
@ -786,6 +792,31 @@ export default {
|
|||
this.processing = false
|
||||
})
|
||||
},
|
||||
consolidate() {
|
||||
const author = this.authors?.[0]?.name || 'Unknown Author'
|
||||
const payload = {
|
||||
message: this.$getString('MessageConfirmConsolidate', [this.title, `${author} - ${this.title}`]),
|
||||
callback: (confirmed) => {
|
||||
if (confirmed) {
|
||||
this.processing = true
|
||||
this.$axios
|
||||
.$post(`/api/items/${this.libraryItemId}/consolidate`)
|
||||
.then(() => {
|
||||
this.$toast.success(this.$strings.ToastConsolidateSuccess)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to consolidate', error)
|
||||
this.$toast.error(error.response?.data || this.$strings.ToastConsolidateFailed)
|
||||
})
|
||||
.finally(() => {
|
||||
this.processing = false
|
||||
})
|
||||
}
|
||||
},
|
||||
type: 'yesNo'
|
||||
}
|
||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||
},
|
||||
contextMenuAction({ action, data }) {
|
||||
if (action === 'collections') {
|
||||
this.$store.commit('setSelectedLibraryItem', this.libraryItem)
|
||||
|
|
@ -806,6 +837,8 @@ export default {
|
|||
} else if (action === 'move') {
|
||||
this.$store.commit('setSelectedLibraryItem', this.libraryItem)
|
||||
this.$store.commit('globals/setShowMoveToLibraryModal', true)
|
||||
} else if (action === 'consolidate') {
|
||||
this.consolidate()
|
||||
} else if (action === 'sendToDevice') {
|
||||
this.sendToDevice(data)
|
||||
} else if (action === 'share') {
|
||||
|
|
|
|||
|
|
@ -774,6 +774,7 @@
|
|||
"MessageChaptersNotFound": "Chapters not found",
|
||||
"MessageCheckingCron": "Checking cron...",
|
||||
"MessageConfirmBatchMerge": "Are you sure you want to merge the selected books into a single book? The files will be moved to the first selected book's folder, and other books will be deleted.",
|
||||
"MessageConfirmConsolidate": "Are you sure you want to consolidate \"{0}\"? This will rename the folder to \"{1}\" and move it to the library root.",
|
||||
"MessageConfirmCloseFeed": "Are you sure you want to close this feed?",
|
||||
"MessageConfirmDeleteApiKey": "Are you sure you want to delete API key \"{0}\"?",
|
||||
"MessageConfirmDeleteBackup": "Are you sure you want to delete backup for {0}?",
|
||||
|
|
@ -1020,6 +1021,10 @@
|
|||
"ToastBatchApplyDetailsToItemsSuccess": "Details applied to items",
|
||||
"ToastBatchDeleteFailed": "Batch delete failed",
|
||||
"ToastBatchDeleteSuccess": "Batch delete success",
|
||||
"ToastConsolidateFailed": "Consolidate failed",
|
||||
"ToastConsolidateSuccess": "Consolidate successful",
|
||||
"ToastBatchConsolidateFailed": "Batch consolidate failed",
|
||||
"ToastBatchConsolidateSuccess": "Batch consolidate successful",
|
||||
"ToastBatchMergeFailed": "Failed to merge books",
|
||||
"ToastBatchMergePartiallySuccess": "Books merged with some errors",
|
||||
"ToastBatchMergeSuccess": "Books merged successfully",
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ const Database = require('../Database')
|
|||
const zipHelpers = require('../utils/zipHelpers')
|
||||
const { reqSupportsWebp } = require('../utils/index')
|
||||
const { ScanResult, AudioMimeType } = require('../utils/constants')
|
||||
const { getAudioMimeTypeFromExtname, encodeUriPath } = require('../utils/fileUtils')
|
||||
const { getAudioMimeTypeFromExtname, encodeUriPath, sanitizeFilename } = require('../utils/fileUtils')
|
||||
const LibraryItemScanner = require('../scanner/LibraryItemScanner')
|
||||
const AudioFileScanner = require('../scanner/AudioFileScanner')
|
||||
const Scanner = require('../scanner/Scanner')
|
||||
|
|
@ -47,12 +47,12 @@ const ShareManager = require('../managers/ShareManager')
|
|||
* @param {import('../models/Library')} targetLibrary
|
||||
* @param {import('../models/LibraryFolder')} targetFolder
|
||||
*/
|
||||
async function handleMoveLibraryItem(libraryItem, targetLibrary, targetFolder) {
|
||||
async function handleMoveLibraryItem(libraryItem, targetLibrary, targetFolder, newItemFolderName = null) {
|
||||
const oldPath = libraryItem.path
|
||||
const oldLibraryId = libraryItem.libraryId
|
||||
|
||||
// Calculate new paths
|
||||
const itemFolderName = Path.basename(libraryItem.path)
|
||||
const itemFolderName = newItemFolderName || Path.basename(libraryItem.path)
|
||||
const newPath = Path.join(targetFolder.path, itemFolderName)
|
||||
const newRelPath = itemFolderName
|
||||
|
||||
|
|
@ -1588,6 +1588,122 @@ class LibraryItemController {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST: /api/items/:id/consolidate
|
||||
* Rename book folder to Author - Title and move to library root
|
||||
*
|
||||
* @param {LibraryItemControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async consolidate(req, res) {
|
||||
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'
|
||||
const newFolderName = `${author} - ${title}`
|
||||
const sanitizedFolderName = sanitizeFilename(newFolderName)
|
||||
|
||||
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]
|
||||
|
||||
try {
|
||||
await handleMoveLibraryItem(req.libraryItem, library, targetFolder, sanitizedFolderName)
|
||||
|
||||
// Recursively remove empty parent directories
|
||||
let parentDir = Path.dirname(req.libraryItem.path)
|
||||
while (parentDir && parentDir !== targetFolder.path && parentDir !== Path.dirname(parentDir)) {
|
||||
try {
|
||||
const files = await fs.readdir(parentDir)
|
||||
if (files.length === 0) {
|
||||
await fs.remove(parentDir)
|
||||
parentDir = Path.dirname(parentDir)
|
||||
} else {
|
||||
break
|
||||
}
|
||||
} catch (err) {
|
||||
Logger.error(`[LibraryItemController] Failed to cleanup parent directory "${parentDir}"`, err)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
libraryItem: req.libraryItem.toOldJSONExpanded()
|
||||
})
|
||||
} catch (error) {
|
||||
Logger.error(`[LibraryItemController] Failed to consolidate item "${req.libraryItem.media.title}"`, error)
|
||||
return res.status(500).send(error.message || 'Failed to consolidate item')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST: /api/items/batch/consolidate
|
||||
* Consolidate multiple library items
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async batchConsolidate(req, res) {
|
||||
const { libraryItemIds } = req.body
|
||||
if (!Array.isArray(libraryItemIds) || !libraryItemIds.length) {
|
||||
return res.status(400).send('Invalid request')
|
||||
}
|
||||
|
||||
const libraryItems = await Database.libraryItemModel.findAllExpandedWhere({
|
||||
id: libraryItemIds
|
||||
})
|
||||
|
||||
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' })
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const author = libraryItem.media.authors?.[0]?.name || 'Unknown Author'
|
||||
const title = libraryItem.media.title || 'Unknown Title'
|
||||
const newFolderName = `${author} - ${title}`
|
||||
const sanitizedFolderName = sanitizeFilename(newFolderName)
|
||||
|
||||
const library = await Database.libraryModel.findByIdWithFolders(libraryItem.libraryId)
|
||||
const currentLibraryFolder = library.libraryFolders.find((lf) => libraryItem.path.startsWith(lf.path)) || library.libraryFolders[0]
|
||||
|
||||
const oldPath = libraryItem.path
|
||||
await handleMoveLibraryItem(libraryItem, library, currentLibraryFolder, sanitizedFolderName)
|
||||
|
||||
// Recursively remove empty parent directories
|
||||
let parentDir = Path.dirname(oldPath)
|
||||
while (parentDir && parentDir !== currentLibraryFolder.path && parentDir !== Path.dirname(parentDir)) {
|
||||
try {
|
||||
const files = await fs.readdir(parentDir)
|
||||
if (files.length === 0) {
|
||||
await fs.remove(parentDir)
|
||||
parentDir = Path.dirname(parentDir)
|
||||
} else {
|
||||
break
|
||||
}
|
||||
} catch (err) {
|
||||
Logger.error(`[LibraryItemController] Failed to cleanup parent directory "${parentDir}"`, err)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
results.push({ id: libraryItem.id, success: true })
|
||||
} catch (error) {
|
||||
Logger.error(`[LibraryItemController] Batch Consolidate: Failed to consolidate "${libraryItem.media?.title}"`, error)
|
||||
results.push({ id: libraryItem.id, success: false, error: error.message })
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ results })
|
||||
}
|
||||
|
||||
/**
|
||||
* POST: /api/items/batch/merge
|
||||
|
|
@ -1736,7 +1852,7 @@ class LibraryItemController {
|
|||
}
|
||||
|
||||
// Rescan the target folder
|
||||
// If moved to folder, tell scanner
|
||||
// If moved to folder, tell scanner
|
||||
if (isPrimaryInRoot) {
|
||||
// We changed the structure of primary item
|
||||
await LibraryItemScanner.scanLibraryItem(primaryItem.id, {
|
||||
|
|
@ -1765,6 +1881,7 @@ class LibraryItemController {
|
|||
|
||||
res.json({
|
||||
success: failIds.length === 0,
|
||||
mergedItemId: primaryItem.id,
|
||||
successIds,
|
||||
failIds,
|
||||
errors: failedItems
|
||||
|
|
|
|||
|
|
@ -107,6 +107,7 @@ class ApiRouter {
|
|||
this.router.post('/items/batch/scan', LibraryItemController.batchScan.bind(this))
|
||||
this.router.post('/items/batch/move', LibraryItemController.batchMove.bind(this))
|
||||
this.router.post('/items/batch/merge', LibraryItemController.batchMerge.bind(this))
|
||||
this.router.post('/items/batch/consolidate', LibraryItemController.batchConsolidate.bind(this))
|
||||
|
||||
this.router.get('/items/:id', LibraryItemController.middleware.bind(this), LibraryItemController.findOne.bind(this))
|
||||
this.router.delete('/items/:id', LibraryItemController.middleware.bind(this), LibraryItemController.delete.bind(this))
|
||||
|
|
@ -130,6 +131,7 @@ class ApiRouter {
|
|||
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/move', LibraryItemController.middleware.bind(this), LibraryItemController.move.bind(this))
|
||||
this.router.post('/items/:id/consolidate', LibraryItemController.middleware.bind(this), LibraryItemController.consolidate.bind(this))
|
||||
|
||||
//
|
||||
// User Routes
|
||||
|
|
@ -471,9 +473,7 @@ class ApiRouter {
|
|||
|
||||
const transaction = await Database.sequelize.transaction()
|
||||
try {
|
||||
const where = [
|
||||
sequelize.where(sequelize.literal('(SELECT count(*) FROM bookAuthors ba WHERE ba.authorId = author.id)'), 0)
|
||||
]
|
||||
const where = [sequelize.where(sequelize.literal('(SELECT count(*) FROM bookAuthors ba WHERE ba.authorId = author.id)'), 0)]
|
||||
|
||||
if (!force) {
|
||||
where.push({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue