mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-07-05 08:51:33 +00:00
Multi move
This commit is contained in:
parent
e433cf9c05
commit
fb206e8198
5 changed files with 256 additions and 109 deletions
|
|
@ -189,6 +189,14 @@ export default {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Move to library option - only show if user has delete permission (same as delete)
|
||||||
|
if (this.userCanDelete) {
|
||||||
|
options.push({
|
||||||
|
text: this.$strings.LabelMoveToLibrary,
|
||||||
|
action: 'move-to-library'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return options
|
return options
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -226,8 +234,14 @@ export default {
|
||||||
this.batchRescan()
|
this.batchRescan()
|
||||||
} else if (action === 'download') {
|
} else if (action === 'download') {
|
||||||
this.batchDownload()
|
this.batchDownload()
|
||||||
|
} else if (action === 'move-to-library') {
|
||||||
|
this.batchMoveToLibrary()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
batchMoveToLibrary() {
|
||||||
|
// Open the move to library modal - it will pick up items from selectedMediaItems
|
||||||
|
this.$store.commit('globals/setShowMoveToLibraryModal', true)
|
||||||
|
},
|
||||||
async batchRescan() {
|
async batchRescan() {
|
||||||
const payload = {
|
const payload = {
|
||||||
message: this.$getString('MessageConfirmReScanLibraryItems', [this.selectedMediaItems.length]),
|
message: this.$getString('MessageConfirmReScanLibraryItems', [this.selectedMediaItems.length]),
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,11 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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">
|
<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">
|
<template v-if="hasItems">
|
||||||
<div class="w-full mb-4">
|
<div class="w-full mb-4">
|
||||||
<p class="text-gray-300 mb-2">{{ $strings.LabelMovingItem }}:</p>
|
<p class="text-gray-300 mb-2">{{ isBatchMode ? $strings.LabelMovingItems : $strings.LabelMovingItem }}:</p>
|
||||||
<p class="text-lg font-semibold text-white">{{ itemTitle }}</p>
|
<p v-if="isBatchMode" class="text-lg font-semibold text-white">{{ $getString('MessageItemsSelected', [selectedItems.length]) }}</p>
|
||||||
|
<p v-else class="text-lg font-semibold text-white">{{ itemTitle }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-if="targetLibraries.length">
|
<template v-if="targetLibraries.length">
|
||||||
|
|
@ -33,7 +34,7 @@
|
||||||
|
|
||||||
<div class="flex items-center pt-4">
|
<div class="flex items-center pt-4">
|
||||||
<div class="grow" />
|
<div class="grow" />
|
||||||
<ui-btn v-if="targetLibraries.length" color="success" :disabled="!selectedLibraryId" small @click="moveItem">{{ $strings.ButtonMove }}</ui-btn>
|
<ui-btn v-if="targetLibraries.length && hasItems" color="success" :disabled="!selectedLibraryId" small @click="moveItems">{{ $strings.ButtonMove }}</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</modals-modal>
|
</modals-modal>
|
||||||
|
|
@ -74,13 +75,28 @@ export default {
|
||||||
this.$store.commit('globals/setShowMoveToLibraryModal', val)
|
this.$store.commit('globals/setShowMoveToLibraryModal', val)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
// Single item mode (from context menu on a single item)
|
||||||
libraryItem() {
|
libraryItem() {
|
||||||
return this.$store.state.selectedLibraryItem
|
return this.$store.state.selectedLibraryItem
|
||||||
},
|
},
|
||||||
|
// Batch mode (from batch selection)
|
||||||
|
selectedItems() {
|
||||||
|
return this.$store.state.globals.selectedMediaItems || []
|
||||||
|
},
|
||||||
|
isBatchMode() {
|
||||||
|
// Use batch mode if we have multiple selected items OR no single item selected
|
||||||
|
return this.selectedItems.length > 0 && !this.libraryItem
|
||||||
|
},
|
||||||
|
hasItems() {
|
||||||
|
return this.isBatchMode ? this.selectedItems.length > 0 : !!this.libraryItem
|
||||||
|
},
|
||||||
itemTitle() {
|
itemTitle() {
|
||||||
return this.libraryItem?.media?.title || this.libraryItem?.media?.metadata?.title || ''
|
return this.libraryItem?.media?.title || this.libraryItem?.media?.metadata?.title || ''
|
||||||
},
|
},
|
||||||
currentLibraryId() {
|
currentLibraryId() {
|
||||||
|
if (this.isBatchMode && this.selectedItems.length > 0) {
|
||||||
|
return this.selectedItems[0].libraryId
|
||||||
|
}
|
||||||
return this.libraryItem?.libraryId
|
return this.libraryItem?.libraryId
|
||||||
},
|
},
|
||||||
currentMediaType() {
|
currentMediaType() {
|
||||||
|
|
@ -112,7 +128,7 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
async moveItem() {
|
async moveItems() {
|
||||||
if (!this.selectedLibraryId) return
|
if (!this.selectedLibraryId) return
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
|
|
@ -125,13 +141,28 @@ export default {
|
||||||
|
|
||||||
this.processing = true
|
this.processing = true
|
||||||
try {
|
try {
|
||||||
const response = await this.$axios.$post(`/api/items/${this.libraryItem.id}/move`, payload)
|
if (this.isBatchMode) {
|
||||||
if (response.success) {
|
// Batch move
|
||||||
this.$toast.success(this.$strings.ToastItemMoved)
|
payload.libraryItemIds = this.selectedItems.map((i) => i.id)
|
||||||
this.show = false
|
const response = await this.$axios.$post('/api/items/batch/move', payload)
|
||||||
|
if (response.successCount > 0) {
|
||||||
|
this.$toast.success(this.$getString('ToastItemsMoved', [response.successCount]))
|
||||||
|
}
|
||||||
|
if (response.failCount > 0) {
|
||||||
|
this.$toast.warning(this.$getString('ToastItemsMoveFailed', [response.failCount]))
|
||||||
|
}
|
||||||
|
// Clear selection after batch move
|
||||||
|
this.$store.commit('globals/resetSelectedMediaItems')
|
||||||
|
} else {
|
||||||
|
// Single item move
|
||||||
|
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) {
|
} catch (error) {
|
||||||
console.error('Failed to move item', error)
|
console.error('Failed to move item(s)', error)
|
||||||
const errorMsg = error.response?.data || this.$strings.ToastItemMoveFailed
|
const errorMsg = error.response?.data || this.$strings.ToastItemMoveFailed
|
||||||
this.$toast.error(errorMsg)
|
this.$toast.error(errorMsg)
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -150,3 +181,4 @@ export default {
|
||||||
mounted() {}
|
mounted() {}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -469,6 +469,7 @@
|
||||||
"LabelMissingSupplementaryEbook": "Has no supplementary ebook",
|
"LabelMissingSupplementaryEbook": "Has no supplementary ebook",
|
||||||
"LabelMoveToLibrary": "Move to Library",
|
"LabelMoveToLibrary": "Move to Library",
|
||||||
"LabelMovingItem": "Moving item",
|
"LabelMovingItem": "Moving item",
|
||||||
|
"LabelMovingItems": "Moving items",
|
||||||
"LabelMobileRedirectURIs": "Allowed Mobile Redirect URIs",
|
"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.",
|
"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",
|
"LabelMore": "More",
|
||||||
|
|
@ -1065,6 +1066,8 @@
|
||||||
"ToastItemDeletedSuccess": "Deleted item",
|
"ToastItemDeletedSuccess": "Deleted item",
|
||||||
"ToastItemMoved": "Item moved successfully",
|
"ToastItemMoved": "Item moved successfully",
|
||||||
"ToastItemMoveFailed": "Failed to move item",
|
"ToastItemMoveFailed": "Failed to move item",
|
||||||
|
"ToastItemsMoved": "{0} item(s) moved successfully",
|
||||||
|
"ToastItemsMoveFailed": "{0} item(s) failed to move",
|
||||||
"ToastItemDetailsUpdateSuccess": "Item details updated",
|
"ToastItemDetailsUpdateSuccess": "Item details updated",
|
||||||
"ToastItemMarkedAsFinishedFailed": "Failed to mark as Finished",
|
"ToastItemMarkedAsFinishedFailed": "Failed to mark as Finished",
|
||||||
"ToastItemMarkedAsFinishedSuccess": "Item marked as Finished",
|
"ToastItemMarkedAsFinishedSuccess": "Item marked as Finished",
|
||||||
|
|
|
||||||
|
|
@ -797,6 +797,89 @@ class LibraryItemController {
|
||||||
await Database.resetLibraryIssuesFilterData(libraryId)
|
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
|
* POST: /api/items/:id/scan
|
||||||
*
|
*
|
||||||
|
|
@ -1158,121 +1241,71 @@ class LibraryItemController {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST: /api/items/:id/move
|
* Internal helper to move a single library item to a target library/folder
|
||||||
* Move a library item to a different library
|
|
||||||
*
|
*
|
||||||
* @param {LibraryItemControllerRequest} req
|
* @param {import('../models/LibraryItem')} libraryItem
|
||||||
* @param {Response} res
|
* @param {import('../models/Library')} targetLibrary
|
||||||
|
* @param {import('../models/LibraryFolder')} targetFolder
|
||||||
*/
|
*/
|
||||||
async move(req, res) {
|
async moveLibraryItem(libraryItem, targetLibrary, targetFolder) {
|
||||||
// Permission check - require delete permission (implies write access)
|
const oldPath = libraryItem.path
|
||||||
if (!req.user.canDelete) {
|
const oldLibraryId = libraryItem.libraryId
|
||||||
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
|
// Calculate new paths
|
||||||
const itemFolderName = Path.basename(req.libraryItem.path)
|
const itemFolderName = Path.basename(libraryItem.path)
|
||||||
const newPath = Path.join(targetFolder.path, itemFolderName)
|
const newPath = Path.join(targetFolder.path, itemFolderName)
|
||||||
const newRelPath = itemFolderName
|
const newRelPath = itemFolderName
|
||||||
|
|
||||||
// Check if destination already exists
|
// Check if destination already exists
|
||||||
const destinationExists = await fs.pathExists(newPath)
|
const destinationExists = await fs.pathExists(newPath)
|
||||||
if (destinationExists) {
|
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 {
|
try {
|
||||||
// Move files on disk
|
// 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)
|
await fs.move(oldPath, newPath)
|
||||||
|
|
||||||
// Update library item in database
|
// Update library item in database
|
||||||
req.libraryItem.libraryId = targetLibrary.id
|
libraryItem.libraryId = targetLibrary.id
|
||||||
req.libraryItem.libraryFolderId = targetFolder.id
|
libraryItem.libraryFolderId = targetFolder.id
|
||||||
req.libraryItem.path = newPath
|
libraryItem.path = newPath
|
||||||
req.libraryItem.relPath = newRelPath
|
libraryItem.relPath = newRelPath
|
||||||
req.libraryItem.changed('updatedAt', true)
|
libraryItem.changed('updatedAt', true)
|
||||||
await req.libraryItem.save()
|
await libraryItem.save()
|
||||||
|
|
||||||
// Update library files paths
|
// Update library files paths
|
||||||
if (req.libraryItem.libraryFiles?.length) {
|
if (libraryItem.libraryFiles?.length) {
|
||||||
req.libraryItem.libraryFiles = req.libraryItem.libraryFiles.map((lf) => {
|
libraryItem.libraryFiles = libraryItem.libraryFiles.map((lf) => {
|
||||||
lf.metadata.path = lf.metadata.path.replace(oldPath, newPath)
|
lf.metadata.path = lf.metadata.path.replace(oldPath, newPath)
|
||||||
return lf
|
return lf
|
||||||
})
|
})
|
||||||
req.libraryItem.changed('libraryFiles', true)
|
libraryItem.changed('libraryFiles', true)
|
||||||
await req.libraryItem.save()
|
await libraryItem.save()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update media file paths (audioFiles, ebookFile for books; podcastEpisodes for podcasts)
|
// Update media file paths (audioFiles, ebookFile for books; podcastEpisodes for podcasts)
|
||||||
if (req.libraryItem.isBook) {
|
if (libraryItem.isBook) {
|
||||||
// Update audioFiles paths
|
// Update audioFiles paths
|
||||||
if (req.libraryItem.media.audioFiles?.length) {
|
if (libraryItem.media.audioFiles?.length) {
|
||||||
req.libraryItem.media.audioFiles = req.libraryItem.media.audioFiles.map((af) => {
|
libraryItem.media.audioFiles = libraryItem.media.audioFiles.map((af) => {
|
||||||
if (af.metadata?.path) {
|
if (af.metadata?.path) {
|
||||||
af.metadata.path = af.metadata.path.replace(oldPath, newPath)
|
af.metadata.path = af.metadata.path.replace(oldPath, newPath)
|
||||||
}
|
}
|
||||||
return af
|
return af
|
||||||
})
|
})
|
||||||
req.libraryItem.media.changed('audioFiles', true)
|
libraryItem.media.changed('audioFiles', true)
|
||||||
}
|
}
|
||||||
// Update ebookFile path
|
// Update ebookFile path
|
||||||
if (req.libraryItem.media.ebookFile?.metadata?.path) {
|
if (libraryItem.media.ebookFile?.metadata?.path) {
|
||||||
req.libraryItem.media.ebookFile.metadata.path = req.libraryItem.media.ebookFile.metadata.path.replace(oldPath, newPath)
|
libraryItem.media.ebookFile.metadata.path = libraryItem.media.ebookFile.metadata.path.replace(oldPath, newPath)
|
||||||
req.libraryItem.media.changed('ebookFile', true)
|
libraryItem.media.changed('ebookFile', true)
|
||||||
}
|
}
|
||||||
await req.libraryItem.media.save()
|
await libraryItem.media.save()
|
||||||
} else if (req.libraryItem.isPodcast) {
|
} else if (libraryItem.isPodcast) {
|
||||||
// Update podcast episode audio file paths
|
// 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) {
|
if (episode.audioFile?.metadata?.path) {
|
||||||
episode.audioFile.metadata.path = episode.audioFile.metadata.path.replace(oldPath, newPath)
|
episode.audioFile.metadata.path = episode.audioFile.metadata.path.replace(oldPath, newPath)
|
||||||
episode.changed('audioFile', true)
|
episode.changed('audioFile', true)
|
||||||
|
|
@ -1282,10 +1315,10 @@ class LibraryItemController {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle Series and Authors when moving a book
|
// Handle Series and Authors when moving a book
|
||||||
if (req.libraryItem.isBook) {
|
if (libraryItem.isBook) {
|
||||||
// Handle Series
|
// Handle Series
|
||||||
const bookSeries = await Database.bookSeriesModel.findAll({
|
const bookSeries = await Database.bookSeriesModel.findAll({
|
||||||
where: { bookId: req.libraryItem.media.id }
|
where: { bookId: libraryItem.media.id }
|
||||||
})
|
})
|
||||||
for (const bs of bookSeries) {
|
for (const bs of bookSeries) {
|
||||||
const sourceSeries = await Database.seriesModel.findByPk(bs.seriesId)
|
const sourceSeries = await Database.seriesModel.findByPk(bs.seriesId)
|
||||||
|
|
@ -1314,7 +1347,7 @@ class LibraryItemController {
|
||||||
|
|
||||||
// Handle Authors
|
// Handle Authors
|
||||||
const bookAuthors = await Database.bookAuthorModel.findAll({
|
const bookAuthors = await Database.bookAuthorModel.findAll({
|
||||||
where: { bookId: req.libraryItem.media.id }
|
where: { bookId: libraryItem.media.id }
|
||||||
})
|
})
|
||||||
for (const ba of bookAuthors) {
|
for (const ba of bookAuthors) {
|
||||||
const sourceAuthor = await Database.authorModel.findByPk(ba.authorId)
|
const sourceAuthor = await Database.authorModel.findByPk(ba.authorId)
|
||||||
|
|
@ -1367,35 +1400,99 @@ class LibraryItemController {
|
||||||
|
|
||||||
// Emit socket events for UI updates
|
// Emit socket events for UI updates
|
||||||
SocketAuthority.emitter('item_removed', {
|
SocketAuthority.emitter('item_removed', {
|
||||||
id: req.libraryItem.id,
|
id: libraryItem.id,
|
||||||
libraryId: oldLibraryId
|
libraryId: oldLibraryId
|
||||||
})
|
})
|
||||||
SocketAuthority.libraryItemEmitter('item_added', req.libraryItem)
|
SocketAuthority.libraryItemEmitter('item_added', libraryItem)
|
||||||
|
|
||||||
// Reset library filter data for both libraries
|
// Reset library filter data for both libraries
|
||||||
await Database.resetLibraryIssuesFilterData(oldLibraryId)
|
await Database.resetLibraryIssuesFilterData(oldLibraryId)
|
||||||
await Database.resetLibraryIssuesFilterData(targetLibrary.id)
|
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({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
libraryItem: req.libraryItem.toOldJSONExpanded()
|
libraryItem: req.libraryItem.toOldJSONExpanded()
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(`[LibraryItemController] Failed to move item "${req.libraryItem.media.title}"`, error)
|
return res.status(500).send(error.message || 'Failed to move item')
|
||||||
|
|
||||||
// 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')
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,7 @@ class ApiRouter {
|
||||||
this.router.post('/items/batch/get', LibraryItemController.batchGet.bind(this))
|
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/quickmatch', LibraryItemController.batchQuickMatch.bind(this))
|
||||||
this.router.post('/items/batch/scan', LibraryItemController.batchScan.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.get('/items/:id', LibraryItemController.middleware.bind(this), LibraryItemController.findOne.bind(this))
|
||||||
this.router.delete('/items/:id', LibraryItemController.middleware.bind(this), LibraryItemController.delete.bind(this))
|
this.router.delete('/items/:id', LibraryItemController.middleware.bind(this), LibraryItemController.delete.bind(this))
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue