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:
Greg Lorenzen 2024-12-29 14:52:57 -08:00 committed by GitHub
parent e0c674d9a9
commit 4cdc2a8c28
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 263 additions and 8 deletions

View File

@ -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

View File

@ -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() {

View File

@ -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",

View File

@ -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)

View File

@ -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 |

View 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 }

View File

@ -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,

View File

@ -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))
} }
} }

View File

@ -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
})
})
})