{{ $strings.LabelMoveToLibrary }}
+{{ $strings.LabelMovingItem }}:
+{{ itemTitle }}
+{{ $strings.MessageNoCompatibleLibraries }}
+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",
@@ -572,6 +576,8 @@
"LabelSelectEpisodesShowing": "Select {0} episodes showing",
"LabelSelectUser": "Select user",
"LabelSelectUsers": "Select users",
+ "LabelSelectTargetLibrary": "Select target library",
+ "LabelSelectTargetFolder": "Select target folder",
"LabelSendEbookToDevice": "Send Ebook to...",
"LabelSequence": "Sequence",
"LabelSerial": "Serial",
@@ -850,6 +856,7 @@
"MessageNoEpisodes": "No Episodes",
"MessageNoFoldersAvailable": "No Folders Available",
"MessageNoGenres": "No Genres",
+ "MessageNoCompatibleLibraries": "No other compatible libraries available",
"MessageNoIssues": "No Issues",
"MessageNoItems": "No Items",
"MessageNoItemsFound": "No items found",
@@ -1056,6 +1063,8 @@
"ToastItemCoverUpdateSuccess": "Item cover updated",
"ToastItemDeletedFailed": "Failed to delete item",
"ToastItemDeletedSuccess": "Deleted item",
+ "ToastItemMoved": "Item moved successfully",
+ "ToastItemMoveFailed": "Failed to move item",
"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 5247dbb06..2442cddfc 100644
--- a/server/controllers/LibraryItemController.js
+++ b/server/controllers/LibraryItemController.js
@@ -1157,6 +1157,164 @@ class LibraryItemController {
res.sendStatus(200)
}
+ /**
+ * 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.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')
+ }
+
+ // Calculate new paths
+ const itemFolderName = Path.basename(req.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}`)
+ }
+
+ 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}"`)
+ 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()
+
+ // Update library files paths
+ if (req.libraryItem.libraryFiles?.length) {
+ req.libraryItem.libraryFiles = req.libraryItem.libraryFiles.map((lf) => {
+ lf.metadata.path = lf.metadata.path.replace(oldPath, newPath)
+ return lf
+ })
+ req.libraryItem.changed('libraryFiles', true)
+ await req.libraryItem.save()
+ }
+
+ // Update media file paths (audioFiles, ebookFile for books; podcastEpisodes for podcasts)
+ if (req.libraryItem.isBook) {
+ // Update audioFiles paths
+ if (req.libraryItem.media.audioFiles?.length) {
+ req.libraryItem.media.audioFiles = req.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)
+ }
+ // 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)
+ }
+ await req.libraryItem.media.save()
+ } else if (req.libraryItem.isPodcast) {
+ // Update podcast episode audio file paths
+ for (const episode of req.libraryItem.media.podcastEpisodes || []) {
+ if (episode.audioFile?.metadata?.path) {
+ episode.audioFile.metadata.path = episode.audioFile.metadata.path.replace(oldPath, newPath)
+ episode.changed('audioFile', true)
+ await episode.save()
+ }
+ }
+ }
+
+ // Emit socket events for UI updates
+ SocketAuthority.emitter('item_removed', {
+ id: req.libraryItem.id,
+ libraryId: oldLibraryId
+ })
+ SocketAuthority.libraryItemEmitter('item_added', req.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}"`)
+
+ 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')
+ }
+ }
+
/**
*
* @param {RequestWithUser} req
diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js
index db04bf5ec..4efe76e5a 100644
--- a/server/routers/ApiRouter.js
+++ b/server/routers/ApiRouter.js
@@ -126,6 +126,7 @@ class ApiRouter {
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/move', LibraryItemController.middleware.bind(this), LibraryItemController.move.bind(this))
//
// User Routes