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 ## 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 POST /api/items/:id/move
``` ```
**Request Body:** ### Batch Move
```
POST /api/items/batch/move
```
**Request Body (Single):**
```json ```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`) **Permissions:** Requires delete permission (`canDelete`)
**Validations:** **Validations:**
@ -28,9 +46,9 @@ POST /api/items/:id/move
- Target library must exist - Target library must exist
- Target library must have same `mediaType` as source (book ↔ book, podcast ↔ podcast) - Target library must have same `mediaType` as source (book ↔ book, podcast ↔ podcast)
- Cannot move to the same library - 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 ### Backend
| File | Line Range | Description | | File | Description |
| --------------------------------------------- | ---------- | ------------------ | | --------------------------------------------- | ------------------------------------------------------------------ |
| `server/controllers/LibraryItemController.js` | ~1160-1289 | `move()` method | | `server/controllers/LibraryItemController.js` | Implementation of `handleMoveLibraryItem`, `move`, and `batchMove` |
| `server/routers/ApiRouter.js` | 129 | Route registration | | `server/routers/ApiRouter.js` | Route registration for single and batch move |
### Frontend ### Frontend
| File | Description | | File | Description |
| ------------------------------------------------------ | ---------------------------------------------------------------------- | | ------------------------------------------------------ | ------------------------------------------------ |
| `client/components/modals/item/MoveToLibraryModal.vue` | **NEW** - Modal component | | `client/components/modals/item/MoveToLibraryModal.vue` | Modal component (handles single and batch modes) |
| `client/store/globals.js` | State: `showMoveToLibraryModal`, Mutation: `setShowMoveToLibraryModal` | | `client/components/app/Appbar.vue` | Added "Move to library" to batch context menu |
| `client/components/cards/LazyBookCard.vue` | Menu item `openMoveToLibraryModal` in `moreMenuItems` | | `client/store/globals.js` | State management for move modal visibility |
| `client/pages/item/_id/index.vue` | Added "Move to library" to context menu | | `client/components/cards/LazyBookCard.vue` | Single item context menu integration |
| `client/layouts/default.vue` | Added `<modals-item-move-to-library-modal />` | | `client/pages/item/_id/index.vue` | Single item page context menu integration |
| `client/strings/en-us.json` | Localization strings | | `client/layouts/default.vue` | Modal registration |
| `client/strings/en-us.json` | Localization strings |
### Localization Strings Added ### Localization Strings Added
- `ButtonMove`, `ButtonMoveToLibrary`, `ButtonReScan` - `ToastItemsMoved`, `ToastItemsMoveFailed`
- `LabelMoveToLibrary`, `LabelMovingItem` - `LabelMovingItems`
- `LabelSelectTargetLibrary`, `LabelSelectTargetFolder` - (Legacy) `ToastItemMoved`, `ToastItemMoveFailed`, `LabelMovingItem`, etc.
- `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).
--- ---
## Implementation Details ## Implementation Details
### Backend Flow ### Shared Moving Logic (`handleMoveLibraryItem`)
1. Validate `targetLibraryId` is provided 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`.
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
### Frontend Flow Steps performed for each item:
1. User clicks "⋮" menu on book card 1. Fetch target library with folders
2. "Move to library" option appears (if `userCanDelete`) 2. Select target folder (first if not specified)
3. Click triggers `openMoveToLibraryModal()` 3. Calculate new path: `targetFolder.path + itemFolderName`
4. Store commits: `setSelectedLibraryItem`, `setShowMoveToLibraryModal` 4. Check destination doesn't exist
5. Modal shows compatible libraries (same mediaType, different id) 5. Move files using `fs.move(oldPath, newPath)`
6. User selects library (and folder if multiple) 6. Update database: `libraryId`, `libraryFolderId`, `path`, `relPath`
7. POST to `/api/items/:id/move` 7. Update `libraryFiles` paths
8. Success: toast + close modal; Error: show error toast 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 ## Testing
1. Create 2+ libraries of same type 1. **Single Move**: Verify via context menu on a book card.
2. Add an audiobook to one library 2. **Batch Move**:
3. Open context menu → "Move to library" - Select multiple items using checkboxes
4. Select target library → Click Move - Use "Move to library" in the top batch bar ⋮ menu
5. Verify item moved in UI and filesystem - 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 ## Known Limitations / Future Work
- Does not support moving to different folder within same library - Does not support moving to different folder within same library.
- No confirmation dialog (could be added) - Rollback is per-item; a failure in a batch move does not roll back successfully moved previous items.
- No batch move support yet - No overall progress bar for large batch moves (it's sequential and blocking).
- Unit tests not yet added to `test/server/controllers/LibraryItemController.test.js`

View file

@ -36,6 +36,192 @@ const ShareManager = require('../managers/ShareManager')
* @typedef {RequestWithUser & RequestEntityObject & RequestLibraryFileObject} LibraryItemControllerRequestWithFile * @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 { class LibraryItemController {
constructor() {} constructor() {}
@ -863,7 +1049,7 @@ class LibraryItemController {
throw new Error(`Incompatible media type: ${sourceLibrary.mediaType} vs ${targetLibrary.mediaType}`) throw new Error(`Incompatible media type: ${sourceLibrary.mediaType} vs ${targetLibrary.mediaType}`)
} }
await this.moveLibraryItem(libraryItem, targetLibrary, targetFolder) await handleMoveLibraryItem(libraryItem, targetLibrary, targetFolder)
successCount++ successCount++
} catch (err) { } catch (err) {
Logger.error(`[LibraryItemController] Batch move failed for item "${libraryItem.media.title}"`, err) Logger.error(`[LibraryItemController] Batch move failed for item "${libraryItem.media.title}"`, err)
@ -1240,192 +1426,6 @@ class LibraryItemController {
res.sendStatus(200) 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 * POST: /api/items/:id/move
* Move a library item to a different library * Move a library item to a different library
@ -1485,7 +1485,7 @@ class LibraryItemController {
} }
try { try {
await this.moveLibraryItem(req.libraryItem, targetLibrary, targetFolder) await handleMoveLibraryItem(req.libraryItem, targetLibrary, targetFolder)
res.json({ res.json({
success: true, success: true,