diff --git a/client/components/modals/ShareModal.vue b/client/components/modals/ShareModal.vue
index d0487fd386..5b37988476 100644
--- a/client/components/modals/ShareModal.vue
+++ b/client/components/modals/ShareModal.vue
@@ -19,12 +19,13 @@
-
{{ $getString('MessageShareExpiresIn', [currentShareTimeRemaining]) }}
+
{{ $strings.LabelDownloadable }}
+
{{ $getString('MessageShareExpiresIn', [currentShareTimeRemaining]) }}
{{ $strings.LabelPermanent }}
-
+
@@ -46,6 +47,15 @@
+
+
{{ $strings.LabelDownloadable }}
+
+
+
+ info
+
+
+
@@ -81,7 +91,8 @@ export default {
text: this.$strings.LabelDays,
value: 'days'
}
- ]
+ ],
+ isDownloadable: false
}
},
watch: {
@@ -172,7 +183,8 @@ export default {
slug: this.newShareSlug,
mediaItemType: 'book',
mediaItemId: this.libraryItem.media.id,
- expiresAt: this.expireDurationSeconds ? Date.now() + this.expireDurationSeconds * 1000 : 0
+ expiresAt: this.expireDurationSeconds ? Date.now() + this.expireDurationSeconds * 1000 : 0,
+ isDownloadable: this.isDownloadable
}
this.processing = true
this.$axios
diff --git a/client/pages/share/_slug.vue b/client/pages/share/_slug.vue
index 7ddb994c9c..6bce2f8aa3 100644
--- a/client/pages/share/_slug.vue
+++ b/client/pages/share/_slug.vue
@@ -12,6 +12,10 @@
+
+
+
+
@@ -63,6 +67,9 @@ export default {
if (!this.playbackSession.coverPath) return `${this.$config.routerBasePath}/book_placeholder.jpg`
return `${this.$config.routerBasePath}/public/share/${this.mediaItemShare.slug}/cover`
},
+ downloadUrl() {
+ return `${process.env.serverUrl}/public/share/${this.mediaItemShare.slug}/download`
+ },
audioTracks() {
return (this.playbackSession.audioTracks || []).map((track) => {
track.relativeContentUrl = track.contentUrl
@@ -247,6 +254,9 @@ export default {
},
playerFinished() {
console.log('Player finished')
+ },
+ downloadShareItem() {
+ this.$downloadFile(this.downloadUrl)
}
},
mounted() {
diff --git a/client/strings/en-us.json b/client/strings/en-us.json
index a029fadb95..eee94abf8c 100644
--- a/client/strings/en-us.json
+++ b/client/strings/en-us.json
@@ -300,6 +300,7 @@
"LabelDiscover": "Discover",
"LabelDownload": "Download",
"LabelDownloadNEpisodes": "Download {0} episodes",
+ "LabelDownloadable": "Downloadable",
"LabelDuration": "Duration",
"LabelDurationComparisonExactMatch": "(exact match)",
"LabelDurationComparisonLonger": "({0} longer)",
@@ -588,6 +589,7 @@
"LabelSettingsStoreMetadataWithItemHelp": "By default metadata files are stored in /metadata/items, enabling this setting will store metadata files in your library item folders",
"LabelSettingsTimeFormat": "Time Format",
"LabelShare": "Share",
+ "LabelShareDownloadableHelp": "Allows users with the share link to download a zip file of the library item.",
"LabelShareOpen": "Share Open",
"LabelShareURL": "Share URL",
"LabelShowAll": "Show All",
diff --git a/server/controllers/ShareController.js b/server/controllers/ShareController.js
index e1568c0dbe..93c6e9fbcf 100644
--- a/server/controllers/ShareController.js
+++ b/server/controllers/ShareController.js
@@ -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)
diff --git a/server/migrations/changelog.md b/server/migrations/changelog.md
index f49924327a..c2de4693b8 100644
--- a/server/migrations/changelog.md
+++ b/server/migrations/changelog.md
@@ -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 |
diff --git a/server/migrations/v2.17.6-share-add-isdownloadable.js b/server/migrations/v2.17.6-share-add-isdownloadable.js
new file mode 100644
index 0000000000..9434d28449
--- /dev/null
+++ b/server/migrations/v2.17.6-share-add-isdownloadable.js
@@ -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} - 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} - 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 }
diff --git a/server/models/MediaItemShare.js b/server/models/MediaItemShare.js
index 38b8dbbf4b..2d7b3896a7 100644
--- a/server/models/MediaItemShare.js
+++ b/server/models/MediaItemShare.js
@@ -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,
diff --git a/server/routers/PublicRouter.js b/server/routers/PublicRouter.js
index 98ac4955a6..107edf99cd 100644
--- a/server/routers/PublicRouter.js
+++ b/server/routers/PublicRouter.js
@@ -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))
}
}
diff --git a/test/server/migrations/v2.17.6-share-add-isdownloadable.test.js b/test/server/migrations/v2.17.6-share-add-isdownloadable.test.js
new file mode 100644
index 0000000000..6c778b1487
--- /dev/null
+++ b/test/server/migrations/v2.17.6-share-add-isdownloadable.test.js
@@ -0,0 +1,68 @@
+const chai = require('chai')
+const sinon = require('sinon')
+const { expect } = chai
+
+const { DataTypes } = require('sequelize')
+
+const { up, down } = require('../../../server/migrations/v2.17.6-share-add-isdownloadable')
+
+describe('Migration v2.17.6-share-add-isDownloadable', () => {
+ let queryInterface, logger
+
+ beforeEach(() => {
+ queryInterface = {
+ addColumn: sinon.stub().resolves(),
+ removeColumn: sinon.stub().resolves(),
+ tableExists: sinon.stub().resolves(true),
+ describeTable: sinon.stub().resolves({ isDownloadable: undefined }),
+ sequelize: {
+ Sequelize: {
+ DataTypes: {
+ BOOLEAN: DataTypes.BOOLEAN
+ }
+ }
+ }
+ }
+
+ logger = {
+ info: sinon.stub(),
+ error: sinon.stub()
+ }
+ })
+
+ describe('up', () => {
+ it('should add the isDownloadable column to mediaItemShares table', async () => {
+ await up({ context: { queryInterface, logger } })
+
+ expect(queryInterface.addColumn.calledOnce).to.be.true
+ expect(
+ queryInterface.addColumn.calledWith('mediaItemShares', 'isDownloadable', {
+ type: DataTypes.BOOLEAN,
+ defaultValue: false,
+ allowNull: false
+ })
+ ).to.be.true
+
+ expect(logger.info.calledWith('[2.17.6 migration] UPGRADE BEGIN: 2.17.6-share-add-isdownloadable')).to.be.true
+ expect(logger.info.calledWith('[2.17.6 migration] Adding isDownloadable column to mediaItemShares table')).to.be.true
+ expect(logger.info.calledWith('[2.17.6 migration] Added isDownloadable column to mediaItemShares table')).to.be.true
+ expect(logger.info.calledWith('[2.17.6 migration] UPGRADE END: 2.17.6-share-add-isdownloadable')).to.be.true
+ })
+ })
+
+ describe('down', () => {
+ it('should remove the isDownloadable column from mediaItemShares table', async () => {
+ queryInterface.describeTable.resolves({ isDownloadable: true })
+
+ await down({ context: { queryInterface, logger } })
+
+ expect(queryInterface.removeColumn.calledOnce).to.be.true
+ expect(queryInterface.removeColumn.calledWith('mediaItemShares', 'isDownloadable')).to.be.true
+
+ expect(logger.info.calledWith('[2.17.6 migration] DOWNGRADE BEGIN: 2.17.6-share-add-isdownloadable')).to.be.true
+ expect(logger.info.calledWith('[2.17.6 migration] Removing isDownloadable column from mediaItemShares table')).to.be.true
+ expect(logger.info.calledWith('[2.17.6 migration] Removed isDownloadable column from mediaItemShares table')).to.be.true
+ expect(logger.info.calledWith('[2.17.6 migration] DOWNGRADE END: 2.17.6-share-add-isdownloadable')).to.be.true
+ })
+ })
+})