Multi move

This commit is contained in:
Tiberiu Ichim 2026-02-06 15:01:07 +02:00
parent fb206e8198
commit 6eb7551fba
2 changed files with 261 additions and 265 deletions

View file

@ -4,15 +4,23 @@
## Overview
This feature allows users to move audiobooks (and podcasts) between libraries of the same type via a context menu option.
This feature allows users to move audiobooks (and podcasts) between libraries of the same type via a context menu option. It supports both single-item moves and batch moves for multiple selected items.
## API Endpoint
## API Endpoints
### Single Item Move
```
POST /api/items/:id/move
```
**Request Body:**
### Batch Move
```
POST /api/items/batch/move
```
**Request Body (Single):**
```json
{
@ -21,6 +29,16 @@ POST /api/items/:id/move
}
```
**Request Body (Batch):**
```json
{
"libraryItemIds": ["uuid1", "uuid2"],
"targetLibraryId": "uuid-of-target-library",
"targetFolderId": "uuid-of-target-folder" // optional
}
```
**Permissions:** Requires delete permission (`canDelete`)
**Validations:**
@ -28,9 +46,9 @@ POST /api/items/:id/move
- 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
- Destination path must not already exist (checked per item)
**Response:** Returns updated library item JSON on success
**Response (Single):** Returns updated library item JSON on success **Response (Batch):** Returns summary of successes, failures, and error details
---
@ -38,99 +56,77 @@ POST /api/items/:id/move
### Backend
| File | Line Range | Description |
| --------------------------------------------- | ---------- | ------------------ |
| `server/controllers/LibraryItemController.js` | ~1160-1289 | `move()` method |
| `server/routers/ApiRouter.js` | 129 | Route registration |
| File | Description |
| --------------------------------------------- | ------------------------------------------------------------------ |
| `server/controllers/LibraryItemController.js` | Implementation of `handleMoveLibraryItem`, `move`, and `batchMove` |
| `server/routers/ApiRouter.js` | Route registration for single and batch move |
### 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/pages/item/_id/index.vue` | Added "Move to library" to context menu |
| `client/layouts/default.vue` | Added `<modals-item-move-to-library-modal />` |
| `client/strings/en-us.json` | Localization strings |
| File | Description |
| ------------------------------------------------------ | ------------------------------------------------ |
| `client/components/modals/item/MoveToLibraryModal.vue` | Modal component (handles single and batch modes) |
| `client/components/app/Appbar.vue` | Added "Move to library" to batch context menu |
| `client/store/globals.js` | State management for move modal visibility |
| `client/components/cards/LazyBookCard.vue` | Single item context menu integration |
| `client/pages/item/_id/index.vue` | Single item page context menu integration |
| `client/layouts/default.vue` | Modal registration |
| `client/strings/en-us.json` | Localization strings |
### Localization Strings Added
- `ButtonMove`, `ButtonMoveToLibrary`, `ButtonReScan`
- `LabelMoveToLibrary`, `LabelMovingItem`
- `LabelSelectTargetLibrary`, `LabelSelectTargetFolder`
- `MessageNoCompatibleLibraries`
- `ToastItemMoved`, `ToastItemMoveFailed`, `ToastRescanUpdated`, `ToastRescanUpToDate`, `ToastRescanFailed`
---
## Post-Move Rescan Feature
In addition to automated handling during moves, a manual "Re-scan" feature has been enhanced and exposed to users with move permissions.
### Why it's needed
If a book was moved before the recent logic enhancements, it might still point to authors or series in its _old_ library. The "Re-scan" action fixes this.
### Logic Improvements
- During a rescan, the system now validates that all linked authors and series belong to the library the book is currently in.
- If a link to an author/series in a different library is found, it is removed.
- The system then re-evaluates the file metadata and links the book to the correct author/series in its _current_ library (creating them if they don't exist).
- `ToastItemsMoved`, `ToastItemsMoveFailed`
- `LabelMovingItems`
- (Legacy) `ToastItemMoved`, `ToastItemMoveFailed`, `LabelMovingItem`, etc.
---
## Implementation Details
### Backend Flow
### Shared Moving Logic (`handleMoveLibraryItem`)
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. Handle Series and Authors:
- Moves/merges series and authors to target library
- Copies metadata (description, ASIN) and images if necessary
- Deletes source series/authors if they become empty
15. Emit socket events: `item_removed` (old library), `item_added` (new library)
16. Reset filter data for both libraries
17. On error: rollback file move if possible
To ensure consistency, the core logic is encapsulated in a standalone function `handleMoveLibraryItem` in `LibraryItemController.js`. This prevents "this" binding issues when called from `ApiRouter`.
### Frontend Flow
Steps performed for each item:
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
1. Fetch target library with folders
2. Select target folder (first if not specified)
3. Calculate new path: `targetFolder.path + itemFolderName`
4. Check destination doesn't exist
5. Move files using `fs.move(oldPath, newPath)`
6. Update database: `libraryId`, `libraryFolderId`, `path`, `relPath`
7. Update `libraryFiles` paths
8. Update media specific paths (`audioFiles`, `ebookFile`, `podcastEpisodes`)
9. Handle Series and Authors:
- Moves/merges series and authors to target library
- Copies metadata and images if necessary
- Deletes source series/authors if they become empty
10. Emit socket events: `item_removed` (old library), `item_added` (new library)
11. Reset filter data for both libraries
### Batch Move Strategy
The `batchMove` endpoint iterates through the provided IDs and calls `handleMoveLibraryItem` for each valid item. It maintains a success/fail count and collects error messages for the final response.
### Frontend Modal Behavior
The `MoveToLibraryModal` automatically detects if it's in batch mode by checking if `selectedMediaItems` has content and no single `selectedLibraryItem` is set. It dynamically adjusts its titles and labels (e.g., "Moving items" vs "Moving item").
---
## 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
1. **Single Move**: Verify via context menu on a book card.
2. **Batch Move**:
- Select multiple items using checkboxes
- Use "Move to library" in the top batch bar ⋮ menu
- Verify all items are moved correctly in the UI and filesystem.
3. **Incompatible Move**: Try moving a book to a podcast library (should be blocked).
---
## 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`
- Does not support moving to different folder within same library.
- Rollback is per-item; a failure in a batch move does not roll back successfully moved previous items.
- No overall progress bar for large batch moves (it's sequential and blocking).

View file

@ -36,6 +36,192 @@ const ShareManager = require('../managers/ShareManager')
* @typedef {RequestWithUser & RequestEntityObject & RequestLibraryFileObject} LibraryItemControllerRequestWithFile
*/
/**
* Internal helper to move a single library item to a target library/folder
*
* @param {import('../models/LibraryItem')} libraryItem
* @param {import('../models/Library')} targetLibrary
* @param {import('../models/LibraryFolder')} targetFolder
*/
async function handleMoveLibraryItem(libraryItem, targetLibrary, targetFolder) {
const oldPath = libraryItem.path
const oldLibraryId = libraryItem.libraryId
// Calculate new paths
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) {
throw new Error(`Destination already exists: ${newPath}`)
}
try {
// Move files on disk
Logger.info(`[LibraryItemController] Moving item "${libraryItem.media.title}" from "${oldPath}" to "${newPath}"`)
await fs.move(oldPath, newPath)
// Update library item in database
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 (libraryItem.libraryFiles?.length) {
libraryItem.libraryFiles = libraryItem.libraryFiles.map((lf) => {
lf.metadata.path = lf.metadata.path.replace(oldPath, newPath)
return lf
})
libraryItem.changed('libraryFiles', true)
await libraryItem.save()
}
// Update media file paths (audioFiles, ebookFile for books; podcastEpisodes for podcasts)
if (libraryItem.isBook) {
// Update audioFiles paths
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
})
libraryItem.media.changed('audioFiles', true)
}
// Update ebookFile path
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 libraryItem.media.save()
} else if (libraryItem.isPodcast) {
// Update podcast episode audio file paths
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)
await episode.save()
}
}
}
// Handle Series and Authors when moving a book
if (libraryItem.isBook) {
// Handle Series
const bookSeries = await Database.bookSeriesModel.findAll({
where: { bookId: libraryItem.media.id }
})
for (const bs of bookSeries) {
const sourceSeries = await Database.seriesModel.findByPk(bs.seriesId)
if (sourceSeries) {
const targetSeries = await Database.seriesModel.findOrCreateByNameAndLibrary(sourceSeries.name, targetLibrary.id)
// If target series doesn't have a description but source does, copy it
if (!targetSeries.description && sourceSeries.description) {
targetSeries.description = sourceSeries.description
await targetSeries.save()
}
// Update link
bs.seriesId = targetSeries.id
await bs.save()
// Check if source series is now empty
const sourceSeriesBooksCount = await Database.bookSeriesModel.count({ where: { seriesId: sourceSeries.id } })
if (sourceSeriesBooksCount === 0) {
Logger.info(`[LibraryItemController] Source series "${sourceSeries.name}" in library ${oldLibraryId} is now empty. Deleting.`)
await sourceSeries.destroy()
Database.removeSeriesFromFilterData(oldLibraryId, sourceSeries.id)
}
}
}
// Handle Authors
const bookAuthors = await Database.bookAuthorModel.findAll({
where: { bookId: libraryItem.media.id }
})
for (const ba of bookAuthors) {
const sourceAuthor = await Database.authorModel.findByPk(ba.authorId)
if (sourceAuthor) {
const targetAuthor = await Database.authorModel.findOrCreateByNameAndLibrary(sourceAuthor.name, targetLibrary.id)
// Copy description and ASIN if target doesn't have them
let targetAuthorUpdated = false
if (!targetAuthor.description && sourceAuthor.description) {
targetAuthor.description = sourceAuthor.description
targetAuthorUpdated = true
}
if (!targetAuthor.asin && sourceAuthor.asin) {
targetAuthor.asin = sourceAuthor.asin
targetAuthorUpdated = true
}
// Copy image if target doesn't have one
if (!targetAuthor.imagePath && sourceAuthor.imagePath && (await fs.pathExists(sourceAuthor.imagePath))) {
const ext = Path.extname(sourceAuthor.imagePath)
const newImagePath = Path.posix.join(Path.join(global.MetadataPath, 'authors'), targetAuthor.id + ext)
try {
await fs.copy(sourceAuthor.imagePath, newImagePath)
targetAuthor.imagePath = newImagePath
targetAuthorUpdated = true
} catch (err) {
Logger.error(`[LibraryItemController] Failed to copy author image`, err)
}
}
if (targetAuthorUpdated) await targetAuthor.save()
// Update link
ba.authorId = targetAuthor.id
await ba.save()
// Check if source author is now empty
const sourceAuthorBooksCount = await Database.bookAuthorModel.getCountForAuthor(sourceAuthor.id)
if (sourceAuthorBooksCount === 0) {
Logger.info(`[LibraryItemController] Source author "${sourceAuthor.name}" in library ${oldLibraryId} is now empty. Deleting.`)
if (sourceAuthor.imagePath) {
await fs.remove(sourceAuthor.imagePath).catch((err) => Logger.error(`[LibraryItemController] Failed to remove source author image`, err))
}
await sourceAuthor.destroy()
Database.removeAuthorFromFilterData(oldLibraryId, sourceAuthor.id)
}
}
}
}
// Emit socket events for UI updates
SocketAuthority.emitter('item_removed', {
id: libraryItem.id,
libraryId: oldLibraryId
})
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 "${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
}
}
class LibraryItemController {
constructor() {}
@ -863,7 +1049,7 @@ class LibraryItemController {
throw new Error(`Incompatible media type: ${sourceLibrary.mediaType} vs ${targetLibrary.mediaType}`)
}
await this.moveLibraryItem(libraryItem, targetLibrary, targetFolder)
await handleMoveLibraryItem(libraryItem, targetLibrary, targetFolder)
successCount++
} catch (err) {
Logger.error(`[LibraryItemController] Batch move failed for item "${libraryItem.media.title}"`, err)
@ -1240,192 +1426,6 @@ class LibraryItemController {
res.sendStatus(200)
}
/**
* Internal helper to move a single library item to a target library/folder
*
* @param {import('../models/LibraryItem')} libraryItem
* @param {import('../models/Library')} targetLibrary
* @param {import('../models/LibraryFolder')} targetFolder
*/
async moveLibraryItem(libraryItem, targetLibrary, targetFolder) {
const oldPath = libraryItem.path
const oldLibraryId = libraryItem.libraryId
// Calculate new paths
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) {
throw new Error(`Destination already exists: ${newPath}`)
}
try {
// Move files on disk
Logger.info(`[LibraryItemController] Moving item "${libraryItem.media.title}" from "${oldPath}" to "${newPath}"`)
await fs.move(oldPath, newPath)
// Update library item in database
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 (libraryItem.libraryFiles?.length) {
libraryItem.libraryFiles = libraryItem.libraryFiles.map((lf) => {
lf.metadata.path = lf.metadata.path.replace(oldPath, newPath)
return lf
})
libraryItem.changed('libraryFiles', true)
await libraryItem.save()
}
// Update media file paths (audioFiles, ebookFile for books; podcastEpisodes for podcasts)
if (libraryItem.isBook) {
// Update audioFiles paths
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
})
libraryItem.media.changed('audioFiles', true)
}
// Update ebookFile path
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 libraryItem.media.save()
} else if (libraryItem.isPodcast) {
// Update podcast episode audio file paths
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)
await episode.save()
}
}
}
// Handle Series and Authors when moving a book
if (libraryItem.isBook) {
// Handle Series
const bookSeries = await Database.bookSeriesModel.findAll({
where: { bookId: libraryItem.media.id }
})
for (const bs of bookSeries) {
const sourceSeries = await Database.seriesModel.findByPk(bs.seriesId)
if (sourceSeries) {
const targetSeries = await Database.seriesModel.findOrCreateByNameAndLibrary(sourceSeries.name, targetLibrary.id)
// If target series doesn't have a description but source does, copy it
if (!targetSeries.description && sourceSeries.description) {
targetSeries.description = sourceSeries.description
await targetSeries.save()
}
// Update link
bs.seriesId = targetSeries.id
await bs.save()
// Check if source series is now empty
const sourceSeriesBooksCount = await Database.bookSeriesModel.count({ where: { seriesId: sourceSeries.id } })
if (sourceSeriesBooksCount === 0) {
Logger.info(`[LibraryItemController] Source series "${sourceSeries.name}" in library ${oldLibraryId} is now empty. Deleting.`)
await sourceSeries.destroy()
Database.removeSeriesFromFilterData(oldLibraryId, sourceSeries.id)
}
}
}
// Handle Authors
const bookAuthors = await Database.bookAuthorModel.findAll({
where: { bookId: libraryItem.media.id }
})
for (const ba of bookAuthors) {
const sourceAuthor = await Database.authorModel.findByPk(ba.authorId)
if (sourceAuthor) {
const targetAuthor = await Database.authorModel.findOrCreateByNameAndLibrary(sourceAuthor.name, targetLibrary.id)
// Copy description and ASIN if target doesn't have them
let targetAuthorUpdated = false
if (!targetAuthor.description && sourceAuthor.description) {
targetAuthor.description = sourceAuthor.description
targetAuthorUpdated = true
}
if (!targetAuthor.asin && sourceAuthor.asin) {
targetAuthor.asin = sourceAuthor.asin
targetAuthorUpdated = true
}
// Copy image if target doesn't have one
if (!targetAuthor.imagePath && sourceAuthor.imagePath && (await fs.pathExists(sourceAuthor.imagePath))) {
const ext = Path.extname(sourceAuthor.imagePath)
const newImagePath = Path.posix.join(Path.join(global.MetadataPath, 'authors'), targetAuthor.id + ext)
try {
await fs.copy(sourceAuthor.imagePath, newImagePath)
targetAuthor.imagePath = newImagePath
targetAuthorUpdated = true
} catch (err) {
Logger.error(`[LibraryItemController] Failed to copy author image`, err)
}
}
if (targetAuthorUpdated) await targetAuthor.save()
// Update link
ba.authorId = targetAuthor.id
await ba.save()
// Check if source author is now empty
const sourceAuthorBooksCount = await Database.bookAuthorModel.getCountForAuthor(sourceAuthor.id)
if (sourceAuthorBooksCount === 0) {
Logger.info(`[LibraryItemController] Source author "${sourceAuthor.name}" in library ${oldLibraryId} is now empty. Deleting.`)
if (sourceAuthor.imagePath) {
await fs.remove(sourceAuthor.imagePath).catch((err) => Logger.error(`[LibraryItemController] Failed to remove source author image`, err))
}
await sourceAuthor.destroy()
Database.removeAuthorFromFilterData(oldLibraryId, sourceAuthor.id)
}
}
}
}
// Emit socket events for UI updates
SocketAuthority.emitter('item_removed', {
id: libraryItem.id,
libraryId: oldLibraryId
})
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 "${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
@ -1485,7 +1485,7 @@ class LibraryItemController {
}
try {
await this.moveLibraryItem(req.libraryItem, targetLibrary, targetFolder)
await handleMoveLibraryItem(req.libraryItem, targetLibrary, targetFolder)
res.json({
success: true,