Allow items to be moved between libraries

This commit is contained in:
Tiberiu Ichim 2026-02-06 14:14:25 +02:00
parent a627dd5009
commit 37626b8d60
9 changed files with 450 additions and 1 deletions

View file

@ -23,5 +23,6 @@
},
"[vue]": {
"editor.defaultFormatter": "octref.vetur"
}
},
"makefile.configureOnOpen": false
}

View file

@ -0,0 +1,115 @@
# Move to Library Feature Documentation
**Date:** 2026-02-06
## Overview
This feature allows users to move audiobooks (and podcasts) between libraries of the same type via a context menu option.
## API Endpoint
```
POST /api/items/:id/move
```
**Request Body:**
```json
{
"targetLibraryId": "uuid-of-target-library",
"targetFolderId": "uuid-of-target-folder" // optional, uses first folder if not provided
}
```
**Permissions:** Requires delete permission (`canDelete`)
**Validations:**
- Target library must exist
- Target library must have same `mediaType` as source (book ↔ book, podcast ↔ podcast)
- Cannot move to the same library
- Destination path must not already exist
**Response:** Returns updated library item JSON on success
---
## Files Modified
### Backend
| File | Line Range | Description |
| --------------------------------------------- | ---------- | ------------------ |
| `server/controllers/LibraryItemController.js` | ~1160-1289 | `move()` method |
| `server/routers/ApiRouter.js` | 129 | Route registration |
### Frontend
| File | Description |
| ------------------------------------------------------ | ---------------------------------------------------------------------- |
| `client/components/modals/item/MoveToLibraryModal.vue` | **NEW** - Modal component |
| `client/store/globals.js` | State: `showMoveToLibraryModal`, Mutation: `setShowMoveToLibraryModal` |
| `client/components/cards/LazyBookCard.vue` | Menu item `openMoveToLibraryModal` in `moreMenuItems` |
| `client/layouts/default.vue` | Added `<modals-item-move-to-library-modal />` |
| `client/strings/en-us.json` | Localization strings |
### Localization Strings Added
- `ButtonMove`, `ButtonMoveToLibrary`
- `LabelMoveToLibrary`, `LabelMovingItem`
- `LabelSelectTargetLibrary`, `LabelSelectTargetFolder`
- `MessageNoCompatibleLibraries`
- `ToastItemMoved`, `ToastItemMoveFailed`
---
## Implementation Details
### Backend Flow
1. Validate `targetLibraryId` is provided
2. Check user has delete permission
3. Fetch target library with folders
4. Validate media type matches source library
5. Select target folder (first folder if not specified)
6. Calculate new path: `targetFolder.path + itemFolderName`
7. Check destination doesn't exist
8. Move files using `fs.move(oldPath, newPath)`
9. Update database: `libraryId`, `libraryFolderId`, `path`, `relPath`
10. Update `libraryFiles` paths
11. Update `audioFiles` paths in Book model (for playback to work)
12. Update `ebookFile` path in Book model (if present)
13. Update `podcastEpisodes` audio file paths for Podcasts
14. Emit socket events: `item_removed` (old library), `item_added` (new library)
15. Reset filter data for both libraries
16. On error: rollback file move if possible
### Frontend Flow
1. User clicks "⋮" menu on book card
2. "Move to library" option appears (if `userCanDelete`)
3. Click triggers `openMoveToLibraryModal()`
4. Store commits: `setSelectedLibraryItem`, `setShowMoveToLibraryModal`
5. Modal shows compatible libraries (same mediaType, different id)
6. User selects library (and folder if multiple)
7. POST to `/api/items/:id/move`
8. Success: toast + close modal; Error: show error toast
---
## Testing
1. Create 2+ libraries of same type
2. Add an audiobook to one library
3. Open context menu → "Move to library"
4. Select target library → Click Move
5. Verify item moved in UI and filesystem
---
## Known Limitations / Future Work
- Does not support moving to different folder within same library
- No confirmation dialog (could be added)
- No batch move support yet
- Unit tests not yet added to `test/server/controllers/LibraryItemController.test.js`

View file

@ -601,6 +601,10 @@ export default {
}
if (this.userCanDelete) {
items.push({
func: 'openMoveToLibraryModal',
text: this.$strings.ButtonMoveToLibrary
})
items.push({
func: 'deleteLibraryItem',
text: this.$strings.ButtonDelete
@ -904,6 +908,10 @@ export default {
this.store.commit('setSelectedLibraryItem', this.libraryItem)
this.store.commit('globals/setShareModal', this.mediaItemShare)
},
openMoveToLibraryModal() {
this.store.commit('setSelectedLibraryItem', this.libraryItem)
this.store.commit('globals/setShowMoveToLibraryModal', true)
},
deleteLibraryItem() {
const payload = {
message: this.$strings.MessageConfirmDeleteLibraryItem,

View file

@ -0,0 +1,152 @@
<template>
<modals-modal ref="modal" v-model="show" name="move-to-library" :width="400" :height="'unset'" :processing="processing">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="text-3xl text-white truncate">{{ $strings.LabelMoveToLibrary }}</p>
</div>
</template>
<div class="px-6 py-8 w-full text-sm rounded-lg bg-bg shadow-lg border border-black-300 overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
<template v-if="libraryItem">
<div class="w-full mb-4">
<p class="text-gray-300 mb-2">{{ $strings.LabelMovingItem }}:</p>
<p class="text-lg font-semibold text-white">{{ itemTitle }}</p>
</div>
<template v-if="targetLibraries.length">
<div class="w-full mb-4">
<label class="px-1 text-sm font-semibold block mb-1">{{ $strings.LabelSelectTargetLibrary }}</label>
<ui-dropdown v-model="selectedLibraryId" :items="libraryOptions" />
</div>
<div v-if="selectedLibraryFolders.length > 1" class="w-full mb-4">
<label class="px-1 text-sm font-semibold block mb-1">{{ $strings.LabelSelectTargetFolder }}</label>
<ui-dropdown v-model="selectedFolderId" :items="folderOptions" />
</div>
</template>
<template v-else>
<div class="w-full py-4">
<p class="text-warning text-center">{{ $strings.MessageNoCompatibleLibraries }}</p>
</div>
</template>
</template>
<div class="flex items-center pt-4">
<div class="grow" />
<ui-btn v-if="targetLibraries.length" color="success" :disabled="!selectedLibraryId" small @click="moveItem">{{ $strings.ButtonMove }}</ui-btn>
</div>
</div>
</modals-modal>
</template>
<script>
export default {
data() {
return {
processing: false,
selectedLibraryId: null,
selectedFolderId: null
}
},
watch: {
show: {
handler(newVal) {
if (newVal) {
this.init()
}
}
},
selectedLibraryId() {
// Reset folder selection when library changes
if (this.selectedLibraryFolders.length) {
this.selectedFolderId = this.selectedLibraryFolders[0].id
} else {
this.selectedFolderId = null
}
}
},
computed: {
show: {
get() {
return this.$store.state.globals.showMoveToLibraryModal
},
set(val) {
this.$store.commit('globals/setShowMoveToLibraryModal', val)
}
},
libraryItem() {
return this.$store.state.selectedLibraryItem
},
itemTitle() {
return this.libraryItem?.media?.title || this.libraryItem?.media?.metadata?.title || ''
},
currentLibraryId() {
return this.libraryItem?.libraryId
},
currentMediaType() {
// Get media type from the current library
const currentLibrary = this.$store.state.libraries.libraries.find((l) => l.id === this.currentLibraryId)
return currentLibrary?.mediaType || 'book'
},
targetLibraries() {
// Filter libraries to only show compatible ones (same media type, different library)
return this.$store.state.libraries.libraries.filter((l) => l.mediaType === this.currentMediaType && l.id !== this.currentLibraryId)
},
libraryOptions() {
return this.targetLibraries.map((lib) => ({
text: lib.name,
value: lib.id
}))
},
selectedLibrary() {
return this.targetLibraries.find((l) => l.id === this.selectedLibraryId)
},
selectedLibraryFolders() {
return this.selectedLibrary?.folders || []
},
folderOptions() {
return this.selectedLibraryFolders.map((folder) => ({
text: folder.fullPath,
value: folder.id
}))
}
},
methods: {
async moveItem() {
if (!this.selectedLibraryId) return
const payload = {
targetLibraryId: this.selectedLibraryId
}
if (this.selectedFolderId && this.selectedLibraryFolders.length > 1) {
payload.targetFolderId = this.selectedFolderId
}
this.processing = true
try {
const response = await this.$axios.$post(`/api/items/${this.libraryItem.id}/move`, payload)
if (response.success) {
this.$toast.success(this.$strings.ToastItemMoved)
this.show = false
}
} catch (error) {
console.error('Failed to move item', error)
const errorMsg = error.response?.data || this.$strings.ToastItemMoveFailed
this.$toast.error(errorMsg)
} finally {
this.processing = false
}
},
init() {
this.selectedLibraryId = null
this.selectedFolderId = null
// Pre-select first available library if any
if (this.targetLibraries.length) {
this.selectedLibraryId = this.targetLibraries[0].id
}
}
},
mounted() {}
}
</script>

View file

@ -21,6 +21,7 @@
<modals-rssfeed-open-close-modal />
<modals-raw-cover-preview-modal />
<modals-share-modal />
<modals-item-move-to-library-modal />
<prompt-confirm />
<readers-reader />
</div>

View file

@ -27,6 +27,7 @@ export const state = () => ({
isCasting: false, // Actively casting
isChromecastInitialized: false, // Script loadeds
showBatchQuickMatchModal: false,
showMoveToLibraryModal: false,
dateFormats: [
{
text: 'MM/DD/YYYY',
@ -204,6 +205,9 @@ export const mutations = {
setShowBatchQuickMatchModal(state, val) {
state.showBatchQuickMatchModal = val
},
setShowMoveToLibraryModal(state, val) {
state.showMoveToLibraryModal = val
},
resetSelectedMediaItems(state) {
state.selectedMediaItems = []
},

View file

@ -29,6 +29,8 @@
"ButtonCreate": "Create",
"ButtonCreateBackup": "Create Backup",
"ButtonDelete": "Delete",
"ButtonMove": "Move",
"ButtonMoveToLibrary": "Move to library",
"ButtonDownloadQueue": "Queue",
"ButtonEdit": "Edit",
"ButtonEditChapters": "Edit Chapters",
@ -465,6 +467,8 @@
"LabelMissing": "Missing",
"LabelMissingEbook": "Has no ebook",
"LabelMissingSupplementaryEbook": "Has no supplementary ebook",
"LabelMoveToLibrary": "Move to Library",
"LabelMovingItem": "Moving item",
"LabelMobileRedirectURIs": "Allowed Mobile Redirect URIs",
"LabelMobileRedirectURIsDescription": "This is a whitelist of valid redirect URIs for mobile apps. The default one is <code>audiobookshelf://oauth</code>, which you can remove or supplement with additional URIs for third-party app integration. Using an asterisk (<code>*</code>) 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",

View file

@ -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

View file

@ -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