mirror of
				https://github.com/advplyr/audiobookshelf.git
				synced 2025-10-27 11:18:14 +01:00 
			
		
		
		
	Merge pull request #4133 from Vito0912/feat/downloadMultiple
Adds the option to download selected books
This commit is contained in:
		
						commit
						7d0f61663e
					
				| @ -180,6 +180,15 @@ export default { | |||||||
|         action: 'rescan' |         action: 'rescan' | ||||||
|       }) |       }) | ||||||
| 
 | 
 | ||||||
|  |       // The limit of 50 is introduced because of the URL length. Each id has 36 chars, so 36 * 40 = 1440 | ||||||
|  |       // + 40 , separators = 1480 chars + base path 280 chars = 1760 chars. This keeps the URL under 2000 chars even with longer domains | ||||||
|  |       if (this.selectedMediaItems.length <= 40) { | ||||||
|  |         options.push({ | ||||||
|  |           text: this.$strings.LabelDownload, | ||||||
|  |           action: 'download' | ||||||
|  |         }) | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|       return options |       return options | ||||||
|     } |     } | ||||||
|   }, |   }, | ||||||
| @ -215,6 +224,8 @@ export default { | |||||||
|         this.batchAutoMatchClick() |         this.batchAutoMatchClick() | ||||||
|       } else if (action === 'rescan') { |       } else if (action === 'rescan') { | ||||||
|         this.batchRescan() |         this.batchRescan() | ||||||
|  |       } else if (action === 'download') { | ||||||
|  |         this.batchDownload() | ||||||
|       } |       } | ||||||
|     }, |     }, | ||||||
|     async batchRescan() { |     async batchRescan() { | ||||||
| @ -241,6 +252,11 @@ export default { | |||||||
|       } |       } | ||||||
|       this.$store.commit('globals/setConfirmPrompt', payload) |       this.$store.commit('globals/setConfirmPrompt', payload) | ||||||
|     }, |     }, | ||||||
|  |     async batchDownload() { | ||||||
|  |       const libraryItemIds = this.selectedMediaItems.map((i) => i.id) | ||||||
|  |       console.log('Downloading library items', libraryItemIds) | ||||||
|  |       this.$downloadFile(`/api/libraries/${this.$store.state.libraries.currentLibraryId}/download?token=${this.$store.getters['user/getToken']}&ids=${libraryItemIds.join(',')}`) | ||||||
|  |     }, | ||||||
|     async playSelectedItems() { |     async playSelectedItems() { | ||||||
|       this.$store.commit('setProcessingBatch', true) |       this.$store.commit('setProcessingBatch', true) | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -23,6 +23,7 @@ const RssFeedManager = require('../managers/RssFeedManager') | |||||||
| const libraryFilters = require('../utils/queries/libraryFilters') | const libraryFilters = require('../utils/queries/libraryFilters') | ||||||
| const libraryItemsPodcastFilters = require('../utils/queries/libraryItemsPodcastFilters') | const libraryItemsPodcastFilters = require('../utils/queries/libraryItemsPodcastFilters') | ||||||
| const authorFilters = require('../utils/queries/authorFilters') | const authorFilters = require('../utils/queries/authorFilters') | ||||||
|  | const zipHelpers = require('../utils/zipHelpers') | ||||||
| 
 | 
 | ||||||
| /** | /** | ||||||
|  * @typedef RequestUserObject |  * @typedef RequestUserObject | ||||||
| @ -1419,6 +1420,52 @@ class LibraryController { | |||||||
|     }) |     }) | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   /** | ||||||
|  |    * GET: /api/library/:id/download | ||||||
|  |    * Downloads multiple library items | ||||||
|  |    * | ||||||
|  |    * @param {LibraryControllerRequest} 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 || typeof req.query.ids !== 'string') { | ||||||
|  |       res.status(400).send('Invalid request. ids must be a string') | ||||||
|  |       return | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     const itemIds = req.query.ids.split(',') | ||||||
|  | 
 | ||||||
|  |     const libraryItems = await Database.libraryItemModel.findAll({ | ||||||
|  |       attributes: ['id', 'libraryId', 'path', 'isFile'], | ||||||
|  |       where: { | ||||||
|  |         id: itemIds | ||||||
|  |       } | ||||||
|  |     }) | ||||||
|  | 
 | ||||||
|  |     Logger.info(`[LibraryController] User "${req.user.username}" requested download for items "${itemIds}"`) | ||||||
|  | 
 | ||||||
|  |     const filename = `LibraryItems-${Date.now()}.zip` | ||||||
|  |     const pathObjects = libraryItems.map((li) => ({ path: li.path, isFile: li.isFile })) | ||||||
|  | 
 | ||||||
|  |     if (!pathObjects.length) { | ||||||
|  |       Logger.warn(`[LibraryController] No library items found for ids "${itemIds}"`) | ||||||
|  |       return res.status(404).send('Library items not found') | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     try { | ||||||
|  |       await zipHelpers.zipDirectoriesPipe(pathObjects, filename, res) | ||||||
|  |       Logger.info(`[LibraryController] Downloaded ${pathObjects.length} items "${filename}"`) | ||||||
|  |     } catch (error) { | ||||||
|  |       Logger.error(`[LibraryController] Download failed for items "${filename}" at ${pathObjects.map((po) => po.path).join(', ')}`, error) | ||||||
|  |       zipHelpers.handleDownloadError(error, res) | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   /** |   /** | ||||||
|    * |    * | ||||||
|    * @param {RequestWithUser} req |    * @param {RequestWithUser} req | ||||||
|  | |||||||
| @ -94,6 +94,7 @@ class ApiRouter { | |||||||
|     this.router.post('/libraries/order', LibraryController.reorder.bind(this)) |     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.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/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
 |     // Item Routes
 | ||||||
|  | |||||||
| @ -1,3 +1,5 @@ | |||||||
|  | const Path = require('path') | ||||||
|  | const { Response } = require('express') | ||||||
| const Logger = require('../Logger') | const Logger = require('../Logger') | ||||||
| const archiver = require('../libs/archiver') | const archiver = require('../libs/archiver') | ||||||
| 
 | 
 | ||||||
| @ -50,3 +52,86 @@ module.exports.zipDirectoryPipe = (path, filename, res) => { | |||||||
|     archive.finalize() |     archive.finalize() | ||||||
|   }) |   }) | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Creates a zip archive containing multiple directories and streams it to the response. | ||||||
|  |  * | ||||||
|  |  * @param {{ path: string, isFile: boolean }[]} pathObjects | ||||||
|  |  * @param {string} filename - Name of the zip file to be sent as attachment. | ||||||
|  |  * @param {Response} res - Response object to pipe the archive data to. | ||||||
|  |  * @returns {Promise<void>} - Promise that resolves when the zip operation completes. | ||||||
|  |  */ | ||||||
|  | module.exports.zipDirectoriesPipe = (pathObjects, 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
 | ||||||
|  |     pathObjects.forEach((pathObject) => { | ||||||
|  |       if (!pathObject.isFile) { | ||||||
|  |         // Add the directory to the archive with its name as the root folder
 | ||||||
|  |         archive.directory(pathObject.path, Path.basename(pathObject.path)) | ||||||
|  |       } else { | ||||||
|  |         archive.file(pathObject.path, { name: Path.basename(pathObject.path) }) | ||||||
|  |       } | ||||||
|  |     }) | ||||||
|  | 
 | ||||||
|  |     archive.finalize() | ||||||
|  |   }) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Handles errors that occur during the download process. | ||||||
|  |  * | ||||||
|  |  * @param {*} error | ||||||
|  |  * @param {Response} res | ||||||
|  |  * @returns {*} | ||||||
|  |  */ | ||||||
|  | module.exports.handleDownloadError = (error, res) => { | ||||||
|  |   if (!res.headersSent) { | ||||||
|  |     if (error.code === 'ENOENT') { | ||||||
|  |       return res.status(404).send('File not found') | ||||||
|  |     } else { | ||||||
|  |       return res.status(500).send('Download failed') | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | |||||||
		Loading…
	
		Reference in New Issue
	
	Block a user