diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js index 3585dc51..f51b4974 100644 --- a/server/controllers/LibraryController.js +++ b/server/controllers/LibraryController.js @@ -23,6 +23,7 @@ const RssFeedManager = require('../managers/RssFeedManager') const libraryFilters = require('../utils/queries/libraryFilters') const libraryItemsPodcastFilters = require('../utils/queries/libraryItemsPodcastFilters') const authorFilters = require('../utils/queries/authorFilters') +const zipHelpers = require('../utils/zipHelpers') /** * @typedef RequestUserObject @@ -528,7 +529,7 @@ class LibraryController { Logger.error(`[LibraryController] Non-admin user "${req.user.username}" attempted to delete library`) return res.sendStatus(403) } - + // Remove library watcher Watcher.removeLibrary(req.library) @@ -1419,6 +1420,51 @@ class LibraryController { }) } + /** + * GET: /api/library/:id/download + * Downloads multiple library items + * + * @param {LibraryItemControllerRequest} req + * @param {Response} res + */ + async downloadMultiple(req, res) { + if (!req.user.canDownload) { + Logger.warn(`User "${req.user.username}" attempted to download without permission`) + return res.sendStatus(403) + } + + if(req.query.ids === undefined) { + res.status(400).send('Library not found') + } + + const itemIds = req.query.ids.split(',') + + const libraryItems = await Database.libraryItemModel.findAll({ + attributes: ['id', 'libraryId', 'path'], + where: { + id: itemIds, + libraryId: req.params.id, + mediaType: 'book' + } + }) + + Logger.info(`[LibraryItemController] User "${req.user.username}" requested download for items "${itemIds}"`) + + const filename = `LibraryItems-${Date.now()}.zip` + const libraryItemPaths = libraryItems.map((li) => li.path) + + console.log(libraryItemPaths) + + try { + await zipHelpers.zipDirectoriesPipe(libraryItemPaths, filename, res) + Logger.info(`[LibraryItemController] Downloaded item "${filename}" at "${libraryItemPaths}"`) + } catch (error) { + Logger.error(`[LibraryItemController] Download failed for item "${filename}" at "${libraryItemPaths}"`, error) + //LibraryItemController.handleDownloadError(error, res) + } + } + + /** * * @param {RequestWithUser} req diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index 6779d0af..78a5291d 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -94,6 +94,7 @@ class ApiRouter { this.router.post('/libraries/order', LibraryController.reorder.bind(this)) this.router.post('/libraries/:id/remove-metadata', LibraryController.middleware.bind(this), LibraryController.removeAllMetadataFiles.bind(this)) this.router.get('/libraries/:id/podcast-titles', LibraryController.middleware.bind(this), LibraryController.getPodcastTitles.bind(this)) + this.router.get('/libraries/:id/download', LibraryController.middleware.bind(this), LibraryController.downloadMultiple.bind(this)) // // Item Routes diff --git a/server/utils/zipHelpers.js b/server/utils/zipHelpers.js index 44b65296..1145f3a0 100644 --- a/server/utils/zipHelpers.js +++ b/server/utils/zipHelpers.js @@ -1,5 +1,6 @@ const Logger = require('../Logger') const archiver = require('../libs/archiver') +const { lstatSync } = require('node:fs') module.exports.zipDirectoryPipe = (path, filename, res) => { return new Promise((resolve, reject) => { @@ -50,3 +51,75 @@ module.exports.zipDirectoryPipe = (path, filename, res) => { archive.finalize() }) } + +/** + * Creates a zip archive containing multiple directories and streams it to the response. + * + * @param {string[]} paths - Array of directory paths to include in the zip archive. + * @param {string} filename - Name of the zip file to be sent as attachment. + * @param {object} res - Response object to pipe the archive data to. + * @returns {Promise} - Promise that resolves when the zip operation completes. + */ +module.exports.zipDirectoriesPipe = (paths, filename, res) => { + return new Promise((resolve, reject) => { + // create a file to stream archive data to + res.attachment(filename) + + const archive = archiver('zip', { + zlib: { level: 0 } // Sets the compression level. + }) + + // listen for all archive data to be written + // 'close' event is fired only when a file descriptor is involved + res.on('close', () => { + Logger.info(archive.pointer() + ' total bytes') + Logger.debug('archiver has been finalized and the output file descriptor has closed.') + resolve() + }) + + // This event is fired when the data source is drained no matter what was the data source. + // It is not part of this library but rather from the NodeJS Stream API. + // @see: https://nodejs.org/api/stream.html#stream_event_end + res.on('end', () => { + Logger.debug('Data has been drained') + }) + + // good practice to catch warnings (ie stat failures and other non-blocking errors) + archive.on('warning', function (err) { + if (err.code === 'ENOENT') { + // log warning + Logger.warn(`[DownloadManager] Archiver warning: ${err.message}`) + } else { + // throw error + Logger.error(`[DownloadManager] Archiver error: ${err.message}`) + // throw err + reject(err) + } + }) + archive.on('error', function (err) { + Logger.error(`[DownloadManager] Archiver error: ${err.message}`) + reject(err) + }) + + // pipe archive data to the file + archive.pipe(res) + + // Add each path as a directory in the zip + paths.forEach(path => { + + const paths = path.split('/') + + // Check if path is file or directory + if (lstatSync(path).isDirectory()) { + const dirName = path.split('/').pop() + + // Add the directory to the archive with its name as the root folder + archive.directory(path, dirName); + } else { + archive.file(path, { name: paths[paths.length - 1] }); + } + }); + + archive.finalize() + }) +}