mirror of
				https://github.com/advplyr/audiobookshelf.git
				synced 2025-10-31 02:17:01 -04: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
					
				| @ -19,12 +19,13 @@ | ||||
|           <ui-text-input v-model="currentShareUrl" show-copy readonly class="text-base h-10" /> | ||||
|         </div> | ||||
|         <div class="w-full py-2 px-1"> | ||||
|           <p v-if="currentShare.expiresAt" class="text-base">{{ $getString('MessageShareExpiresIn', [currentShareTimeRemaining]) }}</p> | ||||
|           <p v-if="currentShare.isDownloadable" class="text-sm mb-2">{{ $strings.LabelDownloadable }}</p> | ||||
|           <p v-if="currentShare.expiresAt">{{ $getString('MessageShareExpiresIn', [currentShareTimeRemaining]) }}</p> | ||||
|           <p v-else>{{ $strings.LabelPermanent }}</p> | ||||
|         </div> | ||||
|       </template> | ||||
|       <template v-else> | ||||
|         <div class="flex flex-col sm:flex-row items-center justify-between space-y-4 sm:space-y-0 sm:space-x-4 mb-4"> | ||||
|         <div class="flex flex-col sm:flex-row items-center justify-between space-y-4 sm:space-y-0 sm:space-x-4 mb-2"> | ||||
|           <div class="w-full sm:w-48"> | ||||
|             <label class="px-1 text-sm font-semibold block">{{ $strings.LabelSlug }}</label> | ||||
|             <ui-text-input v-model="newShareSlug" class="text-base h-10" /> | ||||
| @ -46,6 +47,15 @@ | ||||
|             </div> | ||||
|           </div> | ||||
|         </div> | ||||
|         <div class="flex items-center w-full md:w-1/2 mb-4"> | ||||
|           <p class="text-sm text-gray-300 py-1 px-1">{{ $strings.LabelDownloadable }}</p> | ||||
|           <ui-toggle-switch size="sm" v-model="isDownloadable" /> | ||||
|           <ui-tooltip :text="$strings.LabelShareDownloadableHelp"> | ||||
|             <p class="pl-4 text-sm"> | ||||
|               <span class="material-symbols icon-text text-sm">info</span> | ||||
|             </p> | ||||
|           </ui-tooltip> | ||||
|         </div> | ||||
|         <p class="text-sm text-gray-300 py-1 px-1" v-html="$getString('MessageShareURLWillBe', [demoShareUrl])" /> | ||||
|         <p class="text-sm text-gray-300 py-1 px-1" v-html="$getString('MessageShareExpirationWillBe', [expirationDateString])" /> | ||||
|       </template> | ||||
| @ -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 | ||||
|  | ||||
| @ -12,6 +12,10 @@ | ||||
|         <div class="w-full pt-16"> | ||||
|           <player-ui ref="audioPlayer" :chapters="chapters" :current-chapter="currentChapter" :paused="isPaused" :loading="!hasLoaded" :is-podcast="false" hide-bookmarks hide-sleep-timer @playPause="playPause" @jumpForward="jumpForward" @jumpBackward="jumpBackward" @setVolume="setVolume" @setPlaybackRate="setPlaybackRate" @seek="seek" /> | ||||
|         </div> | ||||
| 
 | ||||
|         <ui-tooltip v-if="mediaItemShare.isDownloadable" direction="bottom" :text="$strings.LabelDownload" class="absolute top-0 left-0 m-4"> | ||||
|           <button aria-label="Download" class="text-gray-300 hover:text-white" @click="downloadShareItem"><span class="material-symbols text-2xl sm:text-3xl">download</span></button> | ||||
|         </ui-tooltip> | ||||
|       </div> | ||||
|     </div> | ||||
|   </div> | ||||
| @ -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() { | ||||
|  | ||||
| @ -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", | ||||
|  | ||||
| @ -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)) | ||||
|   } | ||||
| } | ||||
|  | ||||
| @ -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 | ||||
|     }) | ||||
|   }) | ||||
| }) | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user