diff --git a/client/components/app/Appbar.vue b/client/components/app/Appbar.vue index f74134041..d33bb65f1 100644 --- a/client/components/app/Appbar.vue +++ b/client/components/app/Appbar.vue @@ -189,6 +189,14 @@ export default { }) } + // Move to library option - only show if user has delete permission (same as delete) + if (this.userCanDelete) { + options.push({ + text: this.$strings.LabelMoveToLibrary, + action: 'move-to-library' + }) + } + return options } }, @@ -226,8 +234,14 @@ export default { this.batchRescan() } else if (action === 'download') { this.batchDownload() + } else if (action === 'move-to-library') { + this.batchMoveToLibrary() } }, + batchMoveToLibrary() { + // Open the move to library modal - it will pick up items from selectedMediaItems + this.$store.commit('globals/setShowMoveToLibraryModal', true) + }, async batchRescan() { const payload = { message: this.$getString('MessageConfirmReScanLibraryItems', [this.selectedMediaItems.length]), diff --git a/client/components/modals/item/MoveToLibraryModal.vue b/client/components/modals/item/MoveToLibraryModal.vue index ddac463ab..26ff5f222 100644 --- a/client/components/modals/item/MoveToLibraryModal.vue +++ b/client/components/modals/item/MoveToLibraryModal.vue @@ -6,10 +6,11 @@
{{ $strings.LabelMovingItem }}:
-{{ itemTitle }}
+{{ isBatchMode ? $strings.LabelMovingItems : $strings.LabelMovingItem }}:
+{{ $getString('MessageItemsSelected', [selectedItems.length]) }}
+{{ itemTitle }}
audiobookshelf://oauth, which you can remove or supplement with additional URIs for third-party app integration. Using an asterisk (*) as the sole entry permits any URI.",
"LabelMore": "More",
@@ -1065,6 +1066,8 @@
"ToastItemDeletedSuccess": "Deleted item",
"ToastItemMoved": "Item moved successfully",
"ToastItemMoveFailed": "Failed to move item",
+ "ToastItemsMoved": "{0} item(s) moved successfully",
+ "ToastItemsMoveFailed": "{0} item(s) failed to move",
"ToastItemDetailsUpdateSuccess": "Item details updated",
"ToastItemMarkedAsFinishedFailed": "Failed to mark as Finished",
"ToastItemMarkedAsFinishedSuccess": "Item marked as Finished",
diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js
index 2de867e2c..0ddb7eeb7 100644
--- a/server/controllers/LibraryItemController.js
+++ b/server/controllers/LibraryItemController.js
@@ -797,6 +797,89 @@ class LibraryItemController {
await Database.resetLibraryIssuesFilterData(libraryId)
}
+ /**
+ * POST: /api/items/batch/move
+ * Move multiple library items to a different library
+ *
+ * @this {import('../routers/ApiRouter')}
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
+ async batchMove(req, res) {
+ if (!req.user.canDelete) {
+ Logger.warn(`[LibraryItemController] User "${req.user.username}" attempted to batch move items without permission`)
+ return res.sendStatus(403)
+ }
+
+ const { libraryItemIds, targetLibraryId, targetFolderId } = req.body
+
+ if (!libraryItemIds?.length || !Array.isArray(libraryItemIds)) {
+ return res.status(400).send('libraryItemIds must be an array')
+ }
+ if (!targetLibraryId) {
+ return res.status(400).send('targetLibraryId is required')
+ }
+
+ const targetLibrary = await Database.libraryModel.findByIdWithFolders(targetLibraryId)
+ if (!targetLibrary) {
+ return res.status(404).send('Target library not found')
+ }
+
+ let targetFolder = null
+ if (targetFolderId) {
+ targetFolder = targetLibrary.libraryFolders.find((f) => f.id === targetFolderId)
+ if (!targetFolder) {
+ return res.status(400).send('Target folder not found in library')
+ }
+ } else {
+ targetFolder = targetLibrary.libraryFolders[0]
+ }
+ if (!targetFolder) {
+ return res.status(400).send('Target library has no folders')
+ }
+
+ const libraryItems = await Database.libraryItemModel.findAllExpandedWhere({
+ id: libraryItemIds
+ })
+
+ if (!libraryItems.length) {
+ return res.sendStatus(404)
+ }
+
+ let successCount = 0
+ let failCount = 0
+ const errors = []
+
+ for (const libraryItem of libraryItems) {
+ try {
+ if (libraryItem.libraryId === targetLibrary.id) {
+ Logger.warn(`[LibraryItemController] Item "${libraryItem.media.title}" is already in library ${targetLibrary.id}`)
+ continue
+ }
+
+ const sourceLibrary = await Database.libraryModel.findByPk(libraryItem.libraryId)
+ if (sourceLibrary.mediaType !== targetLibrary.mediaType) {
+ throw new Error(`Incompatible media type: ${sourceLibrary.mediaType} vs ${targetLibrary.mediaType}`)
+ }
+
+ await this.moveLibraryItem(libraryItem, targetLibrary, targetFolder)
+ successCount++
+ } catch (err) {
+ Logger.error(`[LibraryItemController] Batch move failed for item "${libraryItem.media.title}"`, err)
+ failCount++
+ errors.push({ id: libraryItem.id, title: libraryItem.media.title, error: err.message })
+ }
+ }
+
+ res.json({
+ success: true,
+ successCount,
+ failCount,
+ errors
+ })
+ }
+
/**
* POST: /api/items/:id/scan
*
@@ -1158,121 +1241,71 @@ class LibraryItemController {
}
/**
- * POST: /api/items/:id/move
- * Move a library item to a different library
+ * Internal helper to move a single library item to a target library/folder
*
- * @param {LibraryItemControllerRequest} req
- * @param {Response} res
+ * @param {import('../models/LibraryItem')} libraryItem
+ * @param {import('../models/Library')} targetLibrary
+ * @param {import('../models/LibraryFolder')} targetFolder
*/
- async move(req, res) {
- // Permission check - require delete permission (implies write access)
- if (!req.user.canDelete) {
- Logger.warn(`[LibraryItemController] User "${req.user.username}" attempted to move item without permission`)
- return res.sendStatus(403)
- }
-
- const { targetLibraryId, targetFolderId } = req.body
-
- if (!targetLibraryId) {
- return res.status(400).send('Target library ID is required')
- }
-
- // Get target library with folders
- const targetLibrary = await Database.libraryModel.findByIdWithFolders(targetLibraryId)
- if (!targetLibrary) {
- return res.status(404).send('Target library not found')
- }
-
- // Validate media type compatibility
- const sourceLibrary = await Database.libraryModel.findByIdWithFolders(req.libraryItem.libraryId)
- if (!sourceLibrary) {
- Logger.error(`[LibraryItemController] Source library not found for item ${req.libraryItem.id}`)
- return res.status(500).send('Source library not found')
- }
-
- if (sourceLibrary.mediaType !== targetLibrary.mediaType) {
- return res.status(400).send(`Cannot move ${sourceLibrary.mediaType} to ${targetLibrary.mediaType} library`)
- }
-
- // Don't allow moving to same library
- if (sourceLibrary.id === targetLibrary.id) {
- return res.status(400).send('Item is already in this library')
- }
-
- // Determine target folder
- let targetFolder = null
- if (targetFolderId) {
- targetFolder = targetLibrary.libraryFolders.find((f) => f.id === targetFolderId)
- if (!targetFolder) {
- return res.status(400).send('Target folder not found in library')
- }
- } else {
- // Use first folder if not specified
- targetFolder = targetLibrary.libraryFolders[0]
- }
-
- if (!targetFolder) {
- return res.status(400).send('Target library has no folders')
- }
+ async moveLibraryItem(libraryItem, targetLibrary, targetFolder) {
+ const oldPath = libraryItem.path
+ const oldLibraryId = libraryItem.libraryId
// Calculate new paths
- const itemFolderName = Path.basename(req.libraryItem.path)
+ const itemFolderName = Path.basename(libraryItem.path)
const newPath = Path.join(targetFolder.path, itemFolderName)
const newRelPath = itemFolderName
// Check if destination already exists
const destinationExists = await fs.pathExists(newPath)
if (destinationExists) {
- return res.status(400).send(`Destination already exists: ${newPath}`)
+ throw new Error(`Destination already exists: ${newPath}`)
}
- const oldPath = req.libraryItem.path
- const oldLibraryId = req.libraryItem.libraryId
-
try {
// Move files on disk
- Logger.info(`[LibraryItemController] Moving item "${req.libraryItem.media.title}" from "${oldPath}" to "${newPath}"`)
+ Logger.info(`[LibraryItemController] Moving item "${libraryItem.media.title}" from "${oldPath}" to "${newPath}"`)
await fs.move(oldPath, newPath)
// Update library item in database
- req.libraryItem.libraryId = targetLibrary.id
- req.libraryItem.libraryFolderId = targetFolder.id
- req.libraryItem.path = newPath
- req.libraryItem.relPath = newRelPath
- req.libraryItem.changed('updatedAt', true)
- await req.libraryItem.save()
+ libraryItem.libraryId = targetLibrary.id
+ libraryItem.libraryFolderId = targetFolder.id
+ libraryItem.path = newPath
+ libraryItem.relPath = newRelPath
+ libraryItem.changed('updatedAt', true)
+ await libraryItem.save()
// Update library files paths
- if (req.libraryItem.libraryFiles?.length) {
- req.libraryItem.libraryFiles = req.libraryItem.libraryFiles.map((lf) => {
+ if (libraryItem.libraryFiles?.length) {
+ libraryItem.libraryFiles = libraryItem.libraryFiles.map((lf) => {
lf.metadata.path = lf.metadata.path.replace(oldPath, newPath)
return lf
})
- req.libraryItem.changed('libraryFiles', true)
- await req.libraryItem.save()
+ libraryItem.changed('libraryFiles', true)
+ await libraryItem.save()
}
// Update media file paths (audioFiles, ebookFile for books; podcastEpisodes for podcasts)
- if (req.libraryItem.isBook) {
+ if (libraryItem.isBook) {
// Update audioFiles paths
- if (req.libraryItem.media.audioFiles?.length) {
- req.libraryItem.media.audioFiles = req.libraryItem.media.audioFiles.map((af) => {
+ 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)
}
return af
})
- req.libraryItem.media.changed('audioFiles', true)
+ libraryItem.media.changed('audioFiles', true)
}
// Update ebookFile path
- if (req.libraryItem.media.ebookFile?.metadata?.path) {
- req.libraryItem.media.ebookFile.metadata.path = req.libraryItem.media.ebookFile.metadata.path.replace(oldPath, newPath)
- req.libraryItem.media.changed('ebookFile', true)
+ if (libraryItem.media.ebookFile?.metadata?.path) {
+ libraryItem.media.ebookFile.metadata.path = libraryItem.media.ebookFile.metadata.path.replace(oldPath, newPath)
+ libraryItem.media.changed('ebookFile', true)
}
- await req.libraryItem.media.save()
- } else if (req.libraryItem.isPodcast) {
+ await libraryItem.media.save()
+ } else if (libraryItem.isPodcast) {
// Update podcast episode audio file paths
- for (const episode of req.libraryItem.media.podcastEpisodes || []) {
+ for (const episode of libraryItem.media.podcastEpisodes || []) {
if (episode.audioFile?.metadata?.path) {
episode.audioFile.metadata.path = episode.audioFile.metadata.path.replace(oldPath, newPath)
episode.changed('audioFile', true)
@@ -1282,10 +1315,10 @@ class LibraryItemController {
}
// Handle Series and Authors when moving a book
- if (req.libraryItem.isBook) {
+ if (libraryItem.isBook) {
// Handle Series
const bookSeries = await Database.bookSeriesModel.findAll({
- where: { bookId: req.libraryItem.media.id }
+ where: { bookId: libraryItem.media.id }
})
for (const bs of bookSeries) {
const sourceSeries = await Database.seriesModel.findByPk(bs.seriesId)
@@ -1314,7 +1347,7 @@ class LibraryItemController {
// Handle Authors
const bookAuthors = await Database.bookAuthorModel.findAll({
- where: { bookId: req.libraryItem.media.id }
+ where: { bookId: libraryItem.media.id }
})
for (const ba of bookAuthors) {
const sourceAuthor = await Database.authorModel.findByPk(ba.authorId)
@@ -1367,35 +1400,99 @@ class LibraryItemController {
// Emit socket events for UI updates
SocketAuthority.emitter('item_removed', {
- id: req.libraryItem.id,
+ id: libraryItem.id,
libraryId: oldLibraryId
})
- SocketAuthority.libraryItemEmitter('item_added', req.libraryItem)
+ SocketAuthority.libraryItemEmitter('item_added', libraryItem)
// Reset library filter data for both libraries
await Database.resetLibraryIssuesFilterData(oldLibraryId)
await Database.resetLibraryIssuesFilterData(targetLibrary.id)
- Logger.info(`[LibraryItemController] Successfully moved item "${req.libraryItem.media.title}" to library "${targetLibrary.name}"`)
+ Logger.info(`[LibraryItemController] Successfully moved item "${libraryItem.media.title}" to library "${targetLibrary.name}"`)
+ } catch (error) {
+ Logger.error(`[LibraryItemController] Failed to move item "${libraryItem.media.title}"`, error)
+
+ // Attempt to rollback file move if database update failed
+ if (await fs.pathExists(newPath)) {
+ try {
+ await fs.move(newPath, oldPath)
+ Logger.info(`[LibraryItemController] Rolled back file move for item "${libraryItem.media.title}"`)
+ } catch (rollbackError) {
+ Logger.error(`[LibraryItemController] Failed to rollback file move`, rollbackError)
+ }
+ }
+ throw error
+ }
+ }
+
+ /**
+ * POST: /api/items/:id/move
+ * Move a library item to a different library
+ *
+ * @param {LibraryItemControllerRequest} req
+ * @param {Response} res
+ */
+ async move(req, res) {
+ // Permission check - require delete permission (implies write access)
+ if (!req.user.canDelete) {
+ Logger.warn(`[LibraryItemController] User "${req.user.username}" attempted to move item without permission`)
+ return res.sendStatus(403)
+ }
+
+ const { targetLibraryId, targetFolderId } = req.body
+
+ if (!targetLibraryId) {
+ return res.status(400).send('Target library ID is required')
+ }
+
+ // Get target library with folders
+ const targetLibrary = await Database.libraryModel.findByIdWithFolders(targetLibraryId)
+ if (!targetLibrary) {
+ return res.status(404).send('Target library not found')
+ }
+
+ // Validate media type compatibility
+ const sourceLibrary = await Database.libraryModel.findByPk(req.libraryItem.libraryId)
+ if (!sourceLibrary) {
+ Logger.error(`[LibraryItemController] Source library not found for item ${req.libraryItem.id}`)
+ return res.status(500).send('Source library not found')
+ }
+
+ if (sourceLibrary.mediaType !== targetLibrary.mediaType) {
+ return res.status(400).send(`Cannot move ${sourceLibrary.mediaType} to ${targetLibrary.mediaType} library`)
+ }
+
+ // Don't allow moving to same library
+ if (sourceLibrary.id === targetLibrary.id) {
+ return res.status(400).send('Item is already in this library')
+ }
+
+ // Determine target folder
+ let targetFolder = null
+ if (targetFolderId) {
+ targetFolder = targetLibrary.libraryFolders.find((f) => f.id === targetFolderId)
+ if (!targetFolder) {
+ return res.status(400).send('Target folder not found in library')
+ }
+ } else {
+ // Use first folder if not specified
+ targetFolder = targetLibrary.libraryFolders[0]
+ }
+
+ if (!targetFolder) {
+ return res.status(400).send('Target library has no folders')
+ }
+
+ try {
+ await this.moveLibraryItem(req.libraryItem, targetLibrary, targetFolder)
res.json({
success: true,
libraryItem: req.libraryItem.toOldJSONExpanded()
})
} catch (error) {
- Logger.error(`[LibraryItemController] Failed to move item "${req.libraryItem.media.title}"`, error)
-
- // Attempt to rollback file move if database update failed
- if (await fs.pathExists(newPath)) {
- try {
- await fs.move(newPath, oldPath)
- Logger.info(`[LibraryItemController] Rolled back file move for item "${req.libraryItem.media.title}"`)
- } catch (rollbackError) {
- Logger.error(`[LibraryItemController] Failed to rollback file move`, rollbackError)
- }
- }
-
- return res.status(500).send('Failed to move item')
+ return res.status(500).send(error.message || 'Failed to move item')
}
}
diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js
index 4efe76e5a..27761e9a8 100644
--- a/server/routers/ApiRouter.js
+++ b/server/routers/ApiRouter.js
@@ -104,6 +104,7 @@ class ApiRouter {
this.router.post('/items/batch/get', LibraryItemController.batchGet.bind(this))
this.router.post('/items/batch/quickmatch', LibraryItemController.batchQuickMatch.bind(this))
this.router.post('/items/batch/scan', LibraryItemController.batchScan.bind(this))
+ this.router.post('/items/batch/move', LibraryItemController.batchMove.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))