mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-08-01 16:54:59 +02:00
Feat/download via share link (#3666)
* Adds share download endpoint * Adds Downloadable toggle to share modal --------- Co-authored-by: advplyr <advplyr@protonmail.com>
This commit is contained in:
parent
e0c674d9a9
commit
4cdc2a8c28
9 changed files with 263 additions and 8 deletions
|
@ -7,6 +7,7 @@ const Database = require('../Database')
|
|||
|
||||
const { PlayMethod } = require('../utils/constants')
|
||||
const { getAudioMimeTypeFromExtname, encodeUriPath } = require('../utils/fileUtils')
|
||||
const zipHelpers = require('../utils/zipHelpers')
|
||||
|
||||
const PlaybackSession = require('../objects/PlaybackSession')
|
||||
const ShareManager = require('../managers/ShareManager')
|
||||
|
@ -210,6 +211,65 @@ class ShareController {
|
|||
res.sendFile(audioTrackPath)
|
||||
}
|
||||
|
||||
/**
|
||||
* Public route - requires share_session_id cookie
|
||||
*
|
||||
* GET: /api/share/:slug/download
|
||||
* Downloads media item share
|
||||
*
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async downloadMediaItemShare(req, res) {
|
||||
if (!req.cookies.share_session_id) {
|
||||
return res.status(404).send('Share session not set')
|
||||
}
|
||||
|
||||
const { slug } = req.params
|
||||
const mediaItemShare = ShareManager.findBySlug(slug)
|
||||
if (!mediaItemShare) {
|
||||
return res.status(404)
|
||||
}
|
||||
if (!mediaItemShare.isDownloadable) {
|
||||
return res.status(403).send('Download is not allowed for this item')
|
||||
}
|
||||
|
||||
const playbackSession = ShareManager.findPlaybackSessionBySessionId(req.cookies.share_session_id)
|
||||
if (!playbackSession || playbackSession.mediaItemShareId !== mediaItemShare.id) {
|
||||
return res.status(404).send('Share session not found')
|
||||
}
|
||||
|
||||
const libraryItem = await Database.libraryItemModel.findByPk(playbackSession.libraryItemId, {
|
||||
attributes: ['id', 'path', 'relPath', 'isFile']
|
||||
})
|
||||
if (!libraryItem) {
|
||||
return res.status(404).send('Library item not found')
|
||||
}
|
||||
|
||||
const itemPath = libraryItem.path
|
||||
const itemTitle = playbackSession.displayTitle
|
||||
|
||||
Logger.info(`[ShareController] Requested download for book "${itemTitle}" at "${itemPath}"`)
|
||||
|
||||
try {
|
||||
if (libraryItem.isFile) {
|
||||
const audioMimeType = getAudioMimeTypeFromExtname(Path.extname(itemPath))
|
||||
if (audioMimeType) {
|
||||
res.setHeader('Content-Type', audioMimeType)
|
||||
}
|
||||
await new Promise((resolve, reject) => res.download(itemPath, libraryItem.relPath, (error) => (error ? reject(error) : resolve())))
|
||||
} else {
|
||||
const filename = `${itemTitle}.zip`
|
||||
await zipHelpers.zipDirectoryPipe(itemPath, filename, res)
|
||||
}
|
||||
|
||||
Logger.info(`[ShareController] Downloaded item "${itemTitle}" at "${itemPath}"`)
|
||||
} catch (error) {
|
||||
Logger.error(`[ShareController] Download failed for item "${itemTitle}" at "${itemPath}"`, error)
|
||||
res.status(500).send('Failed to download the item')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Public route - requires share_session_id cookie
|
||||
*
|
||||
|
@ -259,7 +319,7 @@ class ShareController {
|
|||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
const { slug, expiresAt, mediaItemType, mediaItemId } = req.body
|
||||
const { slug, expiresAt, mediaItemType, mediaItemId, isDownloadable } = req.body
|
||||
|
||||
if (!slug?.trim?.() || typeof mediaItemType !== 'string' || typeof mediaItemId !== 'string') {
|
||||
return res.status(400).send('Missing or invalid required fields')
|
||||
|
@ -298,7 +358,8 @@ class ShareController {
|
|||
expiresAt: expiresAt || null,
|
||||
mediaItemId,
|
||||
mediaItemType,
|
||||
userId: req.user.id
|
||||
userId: req.user.id,
|
||||
isDownloadable
|
||||
})
|
||||
|
||||
ShareManager.openMediaItemShare(mediaItemShare)
|
||||
|
|
|
@ -11,3 +11,4 @@ Please add a record of every database migration that you create to this file. Th
|
|||
| v2.17.3 | v2.17.3-fk-constraints | Changes the foreign key constraints for tables due to sequelize bug dropping constraints in v2.17.0 migration |
|
||||
| v2.17.4 | v2.17.4-use-subfolder-for-oidc-redirect-uris | Save subfolder to OIDC redirect URIs to support existing installations |
|
||||
| v2.17.5 | v2.17.5-remove-host-from-feed-urls | removes the host (serverAddress) from URL columns in the feeds and feedEpisodes tables |
|
||||
| v2.17.6 | v2.17.6-share-add-isdownloadable | Adds the isDownloadable column to the mediaItemShares table |
|
||||
|
|
68
server/migrations/v2.17.6-share-add-isdownloadable.js
Normal file
68
server/migrations/v2.17.6-share-add-isdownloadable.js
Normal file
|
@ -0,0 +1,68 @@
|
|||
/**
|
||||
* @typedef MigrationContext
|
||||
* @property {import('sequelize').QueryInterface} queryInterface - a Sequelize QueryInterface object.
|
||||
* @property {import('../Logger')} logger - a Logger object.
|
||||
*
|
||||
* @typedef MigrationOptions
|
||||
* @property {MigrationContext} context - an object containing the migration context.
|
||||
*/
|
||||
|
||||
const migrationVersion = '2.17.6'
|
||||
const migrationName = `${migrationVersion}-share-add-isdownloadable`
|
||||
const loggerPrefix = `[${migrationVersion} migration]`
|
||||
|
||||
/**
|
||||
* This migration script adds the isDownloadable column to the mediaItemShares table.
|
||||
*
|
||||
* @param {MigrationOptions} options - an object containing the migration context.
|
||||
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
|
||||
*/
|
||||
async function up({ context: { queryInterface, logger } }) {
|
||||
logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`)
|
||||
|
||||
if (await queryInterface.tableExists('mediaItemShares')) {
|
||||
const tableDescription = await queryInterface.describeTable('mediaItemShares')
|
||||
if (!tableDescription.isDownloadable) {
|
||||
logger.info(`${loggerPrefix} Adding isDownloadable column to mediaItemShares table`)
|
||||
await queryInterface.addColumn('mediaItemShares', 'isDownloadable', {
|
||||
type: queryInterface.sequelize.Sequelize.DataTypes.BOOLEAN,
|
||||
defaultValue: false,
|
||||
allowNull: false
|
||||
})
|
||||
logger.info(`${loggerPrefix} Added isDownloadable column to mediaItemShares table`)
|
||||
} else {
|
||||
logger.info(`${loggerPrefix} isDownloadable column already exists in mediaItemShares table`)
|
||||
}
|
||||
} else {
|
||||
logger.info(`${loggerPrefix} mediaItemShares table does not exist`)
|
||||
}
|
||||
|
||||
logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* This migration script removes the isDownloadable column from the mediaItemShares table.
|
||||
*
|
||||
* @param {MigrationOptions} options - an object containing the migration context.
|
||||
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
|
||||
*/
|
||||
async function down({ context: { queryInterface, logger } }) {
|
||||
logger.info(`${loggerPrefix} DOWNGRADE BEGIN: ${migrationName}`)
|
||||
|
||||
if (await queryInterface.tableExists('mediaItemShares')) {
|
||||
const tableDescription = await queryInterface.describeTable('mediaItemShares')
|
||||
if (tableDescription.isDownloadable) {
|
||||
logger.info(`${loggerPrefix} Removing isDownloadable column from mediaItemShares table`)
|
||||
await queryInterface.removeColumn('mediaItemShares', 'isDownloadable')
|
||||
logger.info(`${loggerPrefix} Removed isDownloadable column from mediaItemShares table`)
|
||||
} else {
|
||||
logger.info(`${loggerPrefix} isDownloadable column does not exist in mediaItemShares table`)
|
||||
}
|
||||
} else {
|
||||
logger.info(`${loggerPrefix} mediaItemShares table does not exist`)
|
||||
}
|
||||
|
||||
logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`)
|
||||
}
|
||||
|
||||
module.exports = { up, down }
|
|
@ -12,6 +12,7 @@ const { DataTypes, Model } = require('sequelize')
|
|||
* @property {Object} extraData
|
||||
* @property {Date} createdAt
|
||||
* @property {Date} updatedAt
|
||||
* @property {boolean} isDownloadable
|
||||
*
|
||||
* @typedef {MediaItemShareObject & MediaItemShare} MediaItemShareModel
|
||||
*/
|
||||
|
@ -25,11 +26,40 @@ const { DataTypes, Model } = require('sequelize')
|
|||
* @property {Date} expiresAt
|
||||
* @property {Date} createdAt
|
||||
* @property {Date} updatedAt
|
||||
* @property {boolean} isDownloadable
|
||||
*/
|
||||
|
||||
class MediaItemShare extends Model {
|
||||
constructor(values, options) {
|
||||
super(values, options)
|
||||
|
||||
/** @type {UUIDV4} */
|
||||
this.id
|
||||
/** @type {UUIDV4} */
|
||||
this.mediaItemId
|
||||
/** @type {string} */
|
||||
this.mediaItemType
|
||||
/** @type {string} */
|
||||
this.slug
|
||||
/** @type {string} */
|
||||
this.pash
|
||||
/** @type {UUIDV4} */
|
||||
this.userId
|
||||
/** @type {Date} */
|
||||
this.expiresAt
|
||||
/** @type {Object} */
|
||||
this.extraData
|
||||
/** @type {Date} */
|
||||
this.createdAt
|
||||
/** @type {Date} */
|
||||
this.updatedAt
|
||||
/** @type {boolean} */
|
||||
this.isDownloadable
|
||||
|
||||
// Expanded properties
|
||||
|
||||
/** @type {import('./Book')|import('./PodcastEpisode')} */
|
||||
this.mediaItem
|
||||
}
|
||||
|
||||
toJSONForClient() {
|
||||
|
@ -40,7 +70,8 @@ class MediaItemShare extends Model {
|
|||
slug: this.slug,
|
||||
expiresAt: this.expiresAt,
|
||||
createdAt: this.createdAt,
|
||||
updatedAt: this.updatedAt
|
||||
updatedAt: this.updatedAt,
|
||||
isDownloadable: this.isDownloadable
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -114,7 +145,8 @@ class MediaItemShare extends Model {
|
|||
slug: DataTypes.STRING,
|
||||
pash: DataTypes.STRING,
|
||||
expiresAt: DataTypes.DATE,
|
||||
extraData: DataTypes.JSON
|
||||
extraData: DataTypes.JSON,
|
||||
isDownloadable: DataTypes.BOOLEAN
|
||||
},
|
||||
{
|
||||
sequelize,
|
||||
|
|
|
@ -15,6 +15,7 @@ class PublicRouter {
|
|||
this.router.get('/share/:slug', ShareController.getMediaItemShareBySlug.bind(this))
|
||||
this.router.get('/share/:slug/track/:index', ShareController.getMediaItemShareAudioTrack.bind(this))
|
||||
this.router.get('/share/:slug/cover', ShareController.getMediaItemShareCoverImage.bind(this))
|
||||
this.router.get('/share/:slug/download', ShareController.downloadMediaItemShare.bind(this))
|
||||
this.router.patch('/share/:slug/progress', ShareController.updateMediaItemShareProgress.bind(this))
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue