mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-05-09 01:16:46 +02: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