mirror of
				https://github.com/advplyr/audiobookshelf.git
				synced 2025-10-31 10:27: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" /> |           <ui-text-input v-model="currentShareUrl" show-copy readonly class="text-base h-10" /> | ||||||
|         </div> |         </div> | ||||||
|         <div class="w-full py-2 px-1"> |         <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> |           <p v-else>{{ $strings.LabelPermanent }}</p> | ||||||
|         </div> |         </div> | ||||||
|       </template> |       </template> | ||||||
|       <template v-else> |       <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"> |           <div class="w-full sm:w-48"> | ||||||
|             <label class="px-1 text-sm font-semibold block">{{ $strings.LabelSlug }}</label> |             <label class="px-1 text-sm font-semibold block">{{ $strings.LabelSlug }}</label> | ||||||
|             <ui-text-input v-model="newShareSlug" class="text-base h-10" /> |             <ui-text-input v-model="newShareSlug" class="text-base h-10" /> | ||||||
| @ -46,6 +47,15 @@ | |||||||
|             </div> |             </div> | ||||||
|           </div> |           </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('MessageShareURLWillBe', [demoShareUrl])" /> | ||||||
|         <p class="text-sm text-gray-300 py-1 px-1" v-html="$getString('MessageShareExpirationWillBe', [expirationDateString])" /> |         <p class="text-sm text-gray-300 py-1 px-1" v-html="$getString('MessageShareExpirationWillBe', [expirationDateString])" /> | ||||||
|       </template> |       </template> | ||||||
| @ -81,7 +91,8 @@ export default { | |||||||
|           text: this.$strings.LabelDays, |           text: this.$strings.LabelDays, | ||||||
|           value: 'days' |           value: 'days' | ||||||
|         } |         } | ||||||
|       ] |       ], | ||||||
|  |       isDownloadable: false | ||||||
|     } |     } | ||||||
|   }, |   }, | ||||||
|   watch: { |   watch: { | ||||||
| @ -172,7 +183,8 @@ export default { | |||||||
|         slug: this.newShareSlug, |         slug: this.newShareSlug, | ||||||
|         mediaItemType: 'book', |         mediaItemType: 'book', | ||||||
|         mediaItemId: this.libraryItem.media.id, |         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.processing = true | ||||||
|       this.$axios |       this.$axios | ||||||
|  | |||||||
| @ -12,6 +12,10 @@ | |||||||
|         <div class="w-full pt-16"> |         <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" /> |           <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> |         </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> |     </div> | ||||||
|   </div> |   </div> | ||||||
| @ -63,6 +67,9 @@ export default { | |||||||
|       if (!this.playbackSession.coverPath) return `${this.$config.routerBasePath}/book_placeholder.jpg` |       if (!this.playbackSession.coverPath) return `${this.$config.routerBasePath}/book_placeholder.jpg` | ||||||
|       return `${this.$config.routerBasePath}/public/share/${this.mediaItemShare.slug}/cover` |       return `${this.$config.routerBasePath}/public/share/${this.mediaItemShare.slug}/cover` | ||||||
|     }, |     }, | ||||||
|  |     downloadUrl() { | ||||||
|  |       return `${process.env.serverUrl}/public/share/${this.mediaItemShare.slug}/download` | ||||||
|  |     }, | ||||||
|     audioTracks() { |     audioTracks() { | ||||||
|       return (this.playbackSession.audioTracks || []).map((track) => { |       return (this.playbackSession.audioTracks || []).map((track) => { | ||||||
|         track.relativeContentUrl = track.contentUrl |         track.relativeContentUrl = track.contentUrl | ||||||
| @ -247,6 +254,9 @@ export default { | |||||||
|     }, |     }, | ||||||
|     playerFinished() { |     playerFinished() { | ||||||
|       console.log('Player finished') |       console.log('Player finished') | ||||||
|  |     }, | ||||||
|  |     downloadShareItem() { | ||||||
|  |       this.$downloadFile(this.downloadUrl) | ||||||
|     } |     } | ||||||
|   }, |   }, | ||||||
|   mounted() { |   mounted() { | ||||||
|  | |||||||
| @ -300,6 +300,7 @@ | |||||||
|   "LabelDiscover": "Discover", |   "LabelDiscover": "Discover", | ||||||
|   "LabelDownload": "Download", |   "LabelDownload": "Download", | ||||||
|   "LabelDownloadNEpisodes": "Download {0} episodes", |   "LabelDownloadNEpisodes": "Download {0} episodes", | ||||||
|  |   "LabelDownloadable": "Downloadable", | ||||||
|   "LabelDuration": "Duration", |   "LabelDuration": "Duration", | ||||||
|   "LabelDurationComparisonExactMatch": "(exact match)", |   "LabelDurationComparisonExactMatch": "(exact match)", | ||||||
|   "LabelDurationComparisonLonger": "({0} longer)", |   "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", |   "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", |   "LabelSettingsTimeFormat": "Time Format", | ||||||
|   "LabelShare": "Share", |   "LabelShare": "Share", | ||||||
|  |   "LabelShareDownloadableHelp": "Allows users with the share link to download a zip file of the library item.", | ||||||
|   "LabelShareOpen": "Share Open", |   "LabelShareOpen": "Share Open", | ||||||
|   "LabelShareURL": "Share URL", |   "LabelShareURL": "Share URL", | ||||||
|   "LabelShowAll": "Show All", |   "LabelShowAll": "Show All", | ||||||
|  | |||||||
| @ -7,6 +7,7 @@ const Database = require('../Database') | |||||||
| 
 | 
 | ||||||
| const { PlayMethod } = require('../utils/constants') | const { PlayMethod } = require('../utils/constants') | ||||||
| const { getAudioMimeTypeFromExtname, encodeUriPath } = require('../utils/fileUtils') | const { getAudioMimeTypeFromExtname, encodeUriPath } = require('../utils/fileUtils') | ||||||
|  | const zipHelpers = require('../utils/zipHelpers') | ||||||
| 
 | 
 | ||||||
| const PlaybackSession = require('../objects/PlaybackSession') | const PlaybackSession = require('../objects/PlaybackSession') | ||||||
| const ShareManager = require('../managers/ShareManager') | const ShareManager = require('../managers/ShareManager') | ||||||
| @ -210,6 +211,65 @@ class ShareController { | |||||||
|     res.sendFile(audioTrackPath) |     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 |    * Public route - requires share_session_id cookie | ||||||
|    * |    * | ||||||
| @ -259,7 +319,7 @@ class ShareController { | |||||||
|       return res.sendStatus(403) |       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') { |     if (!slug?.trim?.() || typeof mediaItemType !== 'string' || typeof mediaItemId !== 'string') { | ||||||
|       return res.status(400).send('Missing or invalid required fields') |       return res.status(400).send('Missing or invalid required fields') | ||||||
| @ -298,7 +358,8 @@ class ShareController { | |||||||
|         expiresAt: expiresAt || null, |         expiresAt: expiresAt || null, | ||||||
|         mediaItemId, |         mediaItemId, | ||||||
|         mediaItemType, |         mediaItemType, | ||||||
|         userId: req.user.id |         userId: req.user.id, | ||||||
|  |         isDownloadable | ||||||
|       }) |       }) | ||||||
| 
 | 
 | ||||||
|       ShareManager.openMediaItemShare(mediaItemShare) |       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.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.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.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 {Object} extraData | ||||||
|  * @property {Date} createdAt |  * @property {Date} createdAt | ||||||
|  * @property {Date} updatedAt |  * @property {Date} updatedAt | ||||||
|  |  * @property {boolean} isDownloadable | ||||||
|  * |  * | ||||||
|  * @typedef {MediaItemShareObject & MediaItemShare} MediaItemShareModel |  * @typedef {MediaItemShareObject & MediaItemShare} MediaItemShareModel | ||||||
|  */ |  */ | ||||||
| @ -25,11 +26,40 @@ const { DataTypes, Model } = require('sequelize') | |||||||
|  * @property {Date} expiresAt |  * @property {Date} expiresAt | ||||||
|  * @property {Date} createdAt |  * @property {Date} createdAt | ||||||
|  * @property {Date} updatedAt |  * @property {Date} updatedAt | ||||||
|  |  * @property {boolean} isDownloadable | ||||||
|  */ |  */ | ||||||
| 
 | 
 | ||||||
| class MediaItemShare extends Model { | class MediaItemShare extends Model { | ||||||
|   constructor(values, options) { |   constructor(values, options) { | ||||||
|     super(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() { |   toJSONForClient() { | ||||||
| @ -40,7 +70,8 @@ class MediaItemShare extends Model { | |||||||
|       slug: this.slug, |       slug: this.slug, | ||||||
|       expiresAt: this.expiresAt, |       expiresAt: this.expiresAt, | ||||||
|       createdAt: this.createdAt, |       createdAt: this.createdAt, | ||||||
|       updatedAt: this.updatedAt |       updatedAt: this.updatedAt, | ||||||
|  |       isDownloadable: this.isDownloadable | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
| @ -114,7 +145,8 @@ class MediaItemShare extends Model { | |||||||
|         slug: DataTypes.STRING, |         slug: DataTypes.STRING, | ||||||
|         pash: DataTypes.STRING, |         pash: DataTypes.STRING, | ||||||
|         expiresAt: DataTypes.DATE, |         expiresAt: DataTypes.DATE, | ||||||
|         extraData: DataTypes.JSON |         extraData: DataTypes.JSON, | ||||||
|  |         isDownloadable: DataTypes.BOOLEAN | ||||||
|       }, |       }, | ||||||
|       { |       { | ||||||
|         sequelize, |         sequelize, | ||||||
|  | |||||||
| @ -15,6 +15,7 @@ class PublicRouter { | |||||||
|     this.router.get('/share/:slug', ShareController.getMediaItemShareBySlug.bind(this)) |     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/track/:index', ShareController.getMediaItemShareAudioTrack.bind(this)) | ||||||
|     this.router.get('/share/:slug/cover', ShareController.getMediaItemShareCoverImage.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)) |     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