Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat/download via share link #3666

Open
wants to merge 10 commits into
base: master
Choose a base branch
from
15 changes: 13 additions & 2 deletions client/components/modals/ShareModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,15 @@
</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])" />
<div class="flex items-center w-full md:w-1/2">
<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>
</template>
<div class="flex items-center pt-6">
<div class="flex-grow" />
Expand Down Expand Up @@ -81,7 +90,8 @@ export default {
text: this.$strings.LabelDays,
value: 'days'
}
]
],
isDownloadable: false
}
},
watch: {
Expand Down Expand Up @@ -172,7 +182,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
Expand Down
10 changes: 10 additions & 0 deletions client/pages/share/_slug.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down Expand Up @@ -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}/api/items/${this.playbackSession.libraryItemId}/download?share=${this.mediaItemShare.slug}`
},
audioTracks() {
return (this.playbackSession.audioTracks || []).map((track) => {
track.relativeContentUrl = track.contentUrl
Expand Down Expand Up @@ -247,6 +254,9 @@ export default {
},
playerFinished() {
console.log('Player finished')
},
downloadShareItem() {
this.$downloadFile(this.downloadUrl)
}
},
mounted() {
Expand Down
2 changes: 2 additions & 0 deletions client/strings/en-us.json
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,7 @@
"LabelDiscover": "Discover",
"LabelDownload": "Download",
"LabelDownloadNEpisodes": "Download {0} episodes",
"LabelDownloadable": "Downloadable",
"LabelDuration": "Duration",
"LabelDurationComparisonExactMatch": "(exact match)",
"LabelDurationComparisonLonger": "({0} longer)",
Expand Down Expand Up @@ -584,6 +585,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",
Expand Down
60 changes: 38 additions & 22 deletions server/controllers/LibraryItemController.js
Original file line number Diff line number Diff line change
Expand Up @@ -157,33 +157,49 @@ class LibraryItemController {
* @param {Response} res
*/
async download(req, res) {
if (!req.user.canDownload) {
Logger.warn(`User "${req.user.username}" attempted to download without permission`)
return res.sendStatus(403)
const handleDownload = async (req, res) => {
const libraryItemPath = req.libraryItem.path
const itemTitle = req.libraryItem.media.metadata.title

Logger.info(`[LibraryItemController] User "${req.user.username}" requested download for item "${itemTitle}" at "${libraryItemPath}"`)

try {
// If library item is a single file in root dir then no need to zip
if (req.libraryItem.isFile) {
// Express does not set the correct mimetype for m4b files so use our defined mimetypes if available
const audioMimeType = getAudioMimeTypeFromExtname(Path.extname(libraryItemPath))
if (audioMimeType) {
res.setHeader('Content-Type', audioMimeType)
}
await new Promise((resolve, reject) => res.download(libraryItemPath, req.libraryItem.relPath, (error) => (error ? reject(error) : resolve())))
} else {
const filename = `${itemTitle}.zip`
await zipHelpers.zipDirectoryPipe(libraryItemPath, filename, res)
}
Logger.info(`[LibraryItemController] Downloaded item "${itemTitle}" at "${libraryItemPath}"`)
} catch (error) {
Logger.error(`[LibraryItemController] Download failed for item "${itemTitle}" at "${libraryItemPath}"`, error)
res.status(500).send('Failed to download the item')
}
}
const libraryItemPath = req.libraryItem.path
const itemTitle = req.libraryItem.media.metadata.title

Logger.info(`[LibraryItemController] User "${req.user.username}" requested download for item "${itemTitle}" at "${libraryItemPath}"`)

try {
// If library item is a single file in root dir then no need to zip
if (req.libraryItem.isFile) {
// Express does not set the correct mimetype for m4b files so use our defined mimetypes if available
const audioMimeType = getAudioMimeTypeFromExtname(Path.extname(libraryItemPath))
if (audioMimeType) {
res.setHeader('Content-Type', audioMimeType)
if (req.query.share) {
// Find matching MediaItemShare based on slug
const mediaItemShare = await ShareManager.findBySlug(req.query.share)
if (mediaItemShare) {
// If the isDownloadable bool is true, download the file
if (mediaItemShare.isDownloadable) {
return handleDownload(req, res)
}
await new Promise((resolve, reject) => res.download(libraryItemPath, req.libraryItem.relPath, (error) => (error ? reject(error) : resolve())))
} else {
const filename = `${itemTitle}.zip`
await zipHelpers.zipDirectoryPipe(libraryItemPath, filename, res)
}
Logger.info(`[LibraryItemController] Downloaded item "${itemTitle}" at "${libraryItemPath}"`)
} catch (error) {
Logger.error(`[LibraryItemController] Download failed for item "${itemTitle}" at "${libraryItemPath}"`, error)
LibraryItemController.handleDownloadError(error, res)
}

if (!req.user.canDownload) {
Logger.warn(`User "${req.user.username}" attempted to download without permission`)
return res.sendStatus(403)
}

return handleDownload(req, res)
}

/**
Expand Down
5 changes: 3 additions & 2 deletions server/controllers/ShareController.js
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,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')
Expand Down Expand Up @@ -298,7 +298,8 @@ class ShareController {
expiresAt: expiresAt || null,
mediaItemId,
mediaItemType,
userId: req.user.id
userId: req.user.id,
isDownloadable
})

ShareManager.openMediaItemShare(mediaItemShare)
Expand Down
32 changes: 32 additions & 0 deletions server/migrations/v2.17.3-share-add-isdownloadable.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
'use strict'

const { DataTypes } = require('sequelize')

/**
* @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.
*/

/**
* 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.
*/
module.exports = {
up: async ({ context: { queryInterface, logger } }) => {
await queryInterface.addColumn('mediaItemShares', 'isDownloadable', {
type: DataTypes.BOOLEAN,
defaultValue: false,
allowNull: false
})
},

down: async ({ context: { queryInterface, logger } }) => {
await queryInterface.removeColumn('mediaItemShares', 'isDownloadable')
}
}
8 changes: 6 additions & 2 deletions server/models/MediaItemShare.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand All @@ -25,6 +26,7 @@ const { DataTypes, Model } = require('sequelize')
* @property {Date} expiresAt
* @property {Date} createdAt
* @property {Date} updatedAt
* @property {boolean} isDownloadable
*/

class MediaItemShare extends Model {
Expand All @@ -40,7 +42,8 @@ class MediaItemShare extends Model {
slug: this.slug,
expiresAt: this.expiresAt,
createdAt: this.createdAt,
updatedAt: this.updatedAt
updatedAt: this.updatedAt,
isDownloadable: this.isDownloadable
}
}

Expand Down Expand Up @@ -114,7 +117,8 @@ class MediaItemShare extends Model {
slug: DataTypes.STRING,
pash: DataTypes.STRING,
expiresAt: DataTypes.DATE,
extraData: DataTypes.JSON
extraData: DataTypes.JSON,
isDownloadable: DataTypes.BOOLEAN
},
{
sequelize,
Expand Down
42 changes: 42 additions & 0 deletions test/server/migrations/v2.17.3-share-add-isdownloadable.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
const chai = require('chai')
const sinon = require('sinon')
const { expect } = chai

const { DataTypes } = require('sequelize')

const { up, down } = require('../../../server/migrations/v2.17.3-share-add-isdownloadable')

describe('Migration v2.17.3-share-add-isDownloadable', () => {
let queryInterface

beforeEach(() => {
queryInterface = {
addColumn: sinon.stub().resolves(),
removeColumn: sinon.stub().resolves()
}
})

describe('up', () => {
it('should add the isDownloadable column to mediaItemShares table', async () => {
await up({ context: { queryInterface } })

expect(queryInterface.addColumn.calledOnce).to.be.true
expect(
queryInterface.addColumn.calledWith('mediaItemShares', 'isDownloadable', {
type: DataTypes.BOOLEAN,
defaultValue: false,
allowNull: false
})
).to.be.true
})
})

describe('down', () => {
it('should remove the isDownloadable column from mediaItemShares table', async () => {
await down({ context: { queryInterface } })

expect(queryInterface.removeColumn.calledOnce).to.be.true
expect(queryInterface.removeColumn.calledWith('mediaItemShares', 'isDownloadable')).to.be.true
})
})
})
Loading