diff --git a/client/pages/share/_slug.vue b/client/pages/share/_slug.vue index 42ed6460..3110cf16 100644 --- a/client/pages/share/_slug.vue +++ b/client/pages/share/_slug.vue @@ -1,12 +1,20 @@ - {{ mediaItemShare.mediaItem.title }} + + {{ mediaItemShare.playbackSession?.displayTitle || 'N/A' }} + + + {{ !hasLoaded ? 'autorenew' : paused ? 'play_arrow' : 'pause' }} + + diff --git a/server/Database.js b/server/Database.js index 4732c960..1274bb4b 100644 --- a/server/Database.js +++ b/server/Database.js @@ -137,6 +137,11 @@ class Database { return this.models.customMetadataProvider } + /** @type {typeof import('./models/MediaItemShare')} */ + get mediaItemShareModel() { + return this.models.mediaItemShare + } + /** * Check if db file exists * @returns {boolean} diff --git a/server/controllers/ShareController.js b/server/controllers/ShareController.js index bd1a5ff5..02365fd6 100644 --- a/server/controllers/ShareController.js +++ b/server/controllers/ShareController.js @@ -1,7 +1,12 @@ +const Path = require('path') const { Op } = require('sequelize') const Logger = require('../Logger') const Database = require('../Database') +const { PlayMethod } = require('../utils/constants') +const { getAudioMimeTypeFromExtname, encodeUriPath } = require('../utils/fileUtils') + +const PlaybackSession = require('../objects/PlaybackSession') const ShareManager = require('../managers/ShareManager') class ShareController { @@ -9,7 +14,7 @@ class ShareController { /** * Public route - * GET: /api/share/mediaitem/:slug + * GET: /api/share/:slug * Get media item share by slug * * @param {import('express').Request} req @@ -28,13 +33,35 @@ class ShareController { } try { - const mediaItemModel = mediaItemShare.mediaItemType === 'book' ? Database.bookModel : Database.podcastEpisodeModel - mediaItemShare.mediaItem = await mediaItemModel.findByPk(mediaItemShare.mediaItemId) + const oldLibraryItem = await Database.mediaItemShareModel.getMediaItemsOldLibraryItem(mediaItemShare.mediaItemId, mediaItemShare.mediaItemType) - if (!mediaItemShare.mediaItem) { + if (!oldLibraryItem) { return res.status(404).send('Media item not found') } + let startOffset = 0 + const publicTracks = oldLibraryItem.media.includedAudioFiles.map((audioFile) => { + const audioTrack = { + index: audioFile.index, + startOffset, + duration: audioFile.duration, + title: audioFile.metadata.filename || '', + contentUrl: `${global.RouterBasePath}/public/share/${slug}/file/${audioFile.ino}`, + mimeType: audioFile.mimeType, + codec: audioFile.codec || null, + metadata: audioFile.metadata.clone() + } + startOffset += audioTrack.duration + return audioTrack + }) + + const newPlaybackSession = new PlaybackSession() + newPlaybackSession.setData(oldLibraryItem, null, 'web-public', null, 0) + newPlaybackSession.audioTracks = publicTracks + newPlaybackSession.playMethod = PlayMethod.DIRECTPLAY + + mediaItemShare.playbackSession = newPlaybackSession.toJSONForClient() + res.json(mediaItemShare) } catch (error) { Logger.error(`[ShareController] Failed`, error) @@ -42,6 +69,48 @@ class ShareController { } } + /** + * Public route + * GET: /api/share/:slug/file/:fileid + * Get media item share file + * + * @param {import('express').Request} req + * @param {import('express').Response} res + */ + async getMediaItemShareFile(req, res) { + const { slug, fileid } = req.params + + const mediaItemShare = ShareManager.findBySlug(slug) + if (!mediaItemShare) { + return res.status(404) + } + + /** @type {import('../models/LibraryItem')} */ + const libraryItem = await Database.libraryItemModel.findOne({ + where: { + mediaId: mediaItemShare.mediaItemId + } + }) + + const libraryFile = libraryItem?.libraryFiles.find((lf) => lf.ino === fileid) + if (!libraryFile) { + return res.status(404).send('File not found') + } + + if (global.XAccel) { + const encodedURI = encodeUriPath(global.XAccel + libraryFile.metadata.path) + Logger.debug(`Use X-Accel to serve static file ${encodedURI}`) + return res.status(204).header({ 'X-Accel-Redirect': encodedURI }).send() + } + + // Express does not set the correct mimetype for m4b files so use our defined mimetypes if available + const audioMimeType = getAudioMimeTypeFromExtname(Path.extname(libraryFile.metadata.path)) + if (audioMimeType) { + res.setHeader('Content-Type', audioMimeType) + } + res.sendFile(libraryFile.metadata.path) + } + /** * POST: /api/share/mediaitem * Create a new media item share @@ -69,7 +138,7 @@ class ShareController { try { // Check if the media item share already exists by slug or mediaItemId - const existingMediaItemShare = await Database.models.mediaItemShare.findOne({ + const existingMediaItemShare = await Database.mediaItemShareModel.findOne({ where: { [Op.or]: [{ slug }, { mediaItemId }] } @@ -89,7 +158,7 @@ class ShareController { return res.status(404).send('Media item not found') } - const mediaItemShare = await Database.models.mediaItemShare.create({ + const mediaItemShare = await Database.mediaItemShareModel.create({ slug, expiresAt: expiresAt || null, mediaItemId, @@ -120,7 +189,7 @@ class ShareController { } try { - const mediaItemShare = await Database.models.mediaItemShare.findByPk(req.params.id) + const mediaItemShare = await Database.mediaItemShareModel.findByPk(req.params.id) if (!mediaItemShare) { return res.status(404).send('Media item share not found') } diff --git a/server/models/MediaItemShare.js b/server/models/MediaItemShare.js index 74ec063e..9a6a1ac3 100644 --- a/server/models/MediaItemShare.js +++ b/server/models/MediaItemShare.js @@ -44,6 +44,41 @@ class MediaItemShare extends Model { } } + /** + * + * @param {string} mediaItemId + * @param {string} mediaItemType + * @returns {Promise} + */ + static async getMediaItemsOldLibraryItem(mediaItemId, mediaItemType) { + if (mediaItemType === 'book') { + const book = await this.sequelize.models.book.findByPk(mediaItemId, { + include: [ + { + model: this.sequelize.models.author, + through: { + attributes: [] + } + }, + { + model: this.sequelize.models.series, + through: { + attributes: ['sequence'] + } + }, + { + model: this.sequelize.models.libraryItem + } + ] + }) + const libraryItem = book.libraryItem + libraryItem.media = book + delete book.libraryItem + return this.sequelize.models.libraryItem.getOldLibraryItem(libraryItem) + } + return null + } + /** * * @param {import('sequelize').FindOptions} options diff --git a/server/objects/PlaybackSession.js b/server/objects/PlaybackSession.js index d2890a11..0d62738c 100644 --- a/server/objects/PlaybackSession.js +++ b/server/objects/PlaybackSession.js @@ -109,7 +109,7 @@ class PlaybackSession { currentTime: this.currentTime, startedAt: this.startedAt, updatedAt: this.updatedAt, - audioTracks: this.audioTracks.map((at) => at.toJSON()), + audioTracks: this.audioTracks.map((at) => at.toJSON?.() || { ...at }), videoTrack: this.videoTrack?.toJSON() || null, libraryItem: libraryItem?.toJSONExpanded() || null } diff --git a/server/routers/PublicRouter.js b/server/routers/PublicRouter.js index 623265b3..addf095b 100644 --- a/server/routers/PublicRouter.js +++ b/server/routers/PublicRouter.js @@ -10,6 +10,7 @@ class PublicRouter { init() { this.router.get('/share/:slug', ShareController.getMediaItemShareBySlug.bind(this)) + this.router.get('/share/:slug/file/:fileid', ShareController.getMediaItemShareFile.bind(this)) } } module.exports = PublicRouter
{{ mediaItemShare.mediaItem.title }}
{{ mediaItemShare.playbackSession?.displayTitle || 'N/A' }}