Multi move

This commit is contained in:
Tiberiu Ichim 2026-02-06 14:51:54 +02:00
parent e433cf9c05
commit fb206e8198
5 changed files with 256 additions and 109 deletions

View file

@ -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')
}
}

View file

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