Add consolidate feature

This commit is contained in:
Tiberiu Ichim 2026-02-13 14:15:18 +02:00
parent 56eca37304
commit 96707200b8
8 changed files with 294 additions and 13 deletions

View file

@ -9,7 +9,7 @@ const Database = require('../Database')
const zipHelpers = require('../utils/zipHelpers')
const { reqSupportsWebp } = require('../utils/index')
const { ScanResult, AudioMimeType } = require('../utils/constants')
const { getAudioMimeTypeFromExtname, encodeUriPath } = require('../utils/fileUtils')
const { getAudioMimeTypeFromExtname, encodeUriPath, sanitizeFilename } = require('../utils/fileUtils')
const LibraryItemScanner = require('../scanner/LibraryItemScanner')
const AudioFileScanner = require('../scanner/AudioFileScanner')
const Scanner = require('../scanner/Scanner')
@ -47,12 +47,12 @@ const ShareManager = require('../managers/ShareManager')
* @param {import('../models/Library')} targetLibrary
* @param {import('../models/LibraryFolder')} targetFolder
*/
async function handleMoveLibraryItem(libraryItem, targetLibrary, targetFolder) {
async function handleMoveLibraryItem(libraryItem, targetLibrary, targetFolder, newItemFolderName = null) {
const oldPath = libraryItem.path
const oldLibraryId = libraryItem.libraryId
// Calculate new paths
const itemFolderName = Path.basename(libraryItem.path)
const itemFolderName = newItemFolderName || Path.basename(libraryItem.path)
const newPath = Path.join(targetFolder.path, itemFolderName)
const newRelPath = itemFolderName
@ -1588,6 +1588,122 @@ class LibraryItemController {
}
}
/**
* POST: /api/items/:id/consolidate
* Rename book folder to Author - Title and move to library root
*
* @param {LibraryItemControllerRequest} req
* @param {Response} res
*/
async consolidate(req, res) {
if (!req.libraryItem.isBook) {
return res.status(400).send('Consolidate only available for books')
}
if (req.libraryItem.isFile) {
return res.status(400).send('Consolidate only available for books in a folder')
}
const author = req.libraryItem.media.authors?.[0]?.name || 'Unknown Author'
const title = req.libraryItem.media.title || 'Unknown Title'
const newFolderName = `${author} - ${title}`
const sanitizedFolderName = sanitizeFilename(newFolderName)
const library = await Database.libraryModel.findByIdWithFolders(req.libraryItem.libraryId)
// Find the library folder that currently contains this item
const targetFolder = library.libraryFolders.find((f) => req.libraryItem.path.startsWith(f.path)) || library.libraryFolders[0]
try {
await handleMoveLibraryItem(req.libraryItem, library, targetFolder, sanitizedFolderName)
// Recursively remove empty parent directories
let parentDir = Path.dirname(req.libraryItem.path)
while (parentDir && parentDir !== targetFolder.path && parentDir !== Path.dirname(parentDir)) {
try {
const files = await fs.readdir(parentDir)
if (files.length === 0) {
await fs.remove(parentDir)
parentDir = Path.dirname(parentDir)
} else {
break
}
} catch (err) {
Logger.error(`[LibraryItemController] Failed to cleanup parent directory "${parentDir}"`, err)
break
}
}
res.json({
success: true,
libraryItem: req.libraryItem.toOldJSONExpanded()
})
} catch (error) {
Logger.error(`[LibraryItemController] Failed to consolidate item "${req.libraryItem.media.title}"`, error)
return res.status(500).send(error.message || 'Failed to consolidate item')
}
}
/**
* POST: /api/items/batch/consolidate
* Consolidate multiple library items
*
* @param {RequestWithUser} req
* @param {Response} res
*/
async batchConsolidate(req, res) {
const { libraryItemIds } = req.body
if (!Array.isArray(libraryItemIds) || !libraryItemIds.length) {
return res.status(400).send('Invalid request')
}
const libraryItems = await Database.libraryItemModel.findAllExpandedWhere({
id: libraryItemIds
})
const results = []
for (const libraryItem of libraryItems) {
if (libraryItem.mediaType !== 'book' || libraryItem.isFile) {
results.push({ id: libraryItem.id, success: false, error: 'Not a book in a folder' })
continue
}
try {
const author = libraryItem.media.authors?.[0]?.name || 'Unknown Author'
const title = libraryItem.media.title || 'Unknown Title'
const newFolderName = `${author} - ${title}`
const sanitizedFolderName = sanitizeFilename(newFolderName)
const library = await Database.libraryModel.findByIdWithFolders(libraryItem.libraryId)
const currentLibraryFolder = library.libraryFolders.find((lf) => libraryItem.path.startsWith(lf.path)) || library.libraryFolders[0]
const oldPath = libraryItem.path
await handleMoveLibraryItem(libraryItem, library, currentLibraryFolder, sanitizedFolderName)
// Recursively remove empty parent directories
let parentDir = Path.dirname(oldPath)
while (parentDir && parentDir !== currentLibraryFolder.path && parentDir !== Path.dirname(parentDir)) {
try {
const files = await fs.readdir(parentDir)
if (files.length === 0) {
await fs.remove(parentDir)
parentDir = Path.dirname(parentDir)
} else {
break
}
} catch (err) {
Logger.error(`[LibraryItemController] Failed to cleanup parent directory "${parentDir}"`, err)
break
}
}
results.push({ id: libraryItem.id, success: true })
} catch (error) {
Logger.error(`[LibraryItemController] Batch Consolidate: Failed to consolidate "${libraryItem.media?.title}"`, error)
results.push({ id: libraryItem.id, success: false, error: error.message })
}
}
res.json({ results })
}
/**
* POST: /api/items/batch/merge
@ -1736,7 +1852,7 @@ class LibraryItemController {
}
// Rescan the target folder
// If moved to folder, tell scanner
// If moved to folder, tell scanner
if (isPrimaryInRoot) {
// We changed the structure of primary item
await LibraryItemScanner.scanLibraryItem(primaryItem.id, {
@ -1765,6 +1881,7 @@ class LibraryItemController {
res.json({
success: failIds.length === 0,
mergedItemId: primaryItem.id,
successIds,
failIds,
errors: failedItems

View file

@ -107,6 +107,7 @@ class ApiRouter {
this.router.post('/items/batch/scan', LibraryItemController.batchScan.bind(this))
this.router.post('/items/batch/move', LibraryItemController.batchMove.bind(this))
this.router.post('/items/batch/merge', LibraryItemController.batchMerge.bind(this))
this.router.post('/items/batch/consolidate', LibraryItemController.batchConsolidate.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))
@ -130,6 +131,7 @@ class ApiRouter {
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))
this.router.post('/items/:id/consolidate', LibraryItemController.middleware.bind(this), LibraryItemController.consolidate.bind(this))
//
// User Routes
@ -471,9 +473,7 @@ class ApiRouter {
const transaction = await Database.sequelize.transaction()
try {
const where = [
sequelize.where(sequelize.literal('(SELECT count(*) FROM bookAuthors ba WHERE ba.authorId = author.id)'), 0)
]
const where = [sequelize.where(sequelize.literal('(SELECT count(*) FROM bookAuthors ba WHERE ba.authorId = author.id)'), 0)]
if (!force) {
where.push({