mirror of
				https://github.com/advplyr/audiobookshelf.git
				synced 2025-10-27 11:18:14 +01:00 
			
		
		
		
	
		
			
				
	
	
		
			403 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			403 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
| const { Request, Response } = require('express')
 | |
| const uuid = require('uuid')
 | |
| 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 zipHelpers = require('../utils/zipHelpers')
 | |
| 
 | |
| const PlaybackSession = require('../objects/PlaybackSession')
 | |
| const ShareManager = require('../managers/ShareManager')
 | |
| 
 | |
| /**
 | |
|  * @typedef RequestUserObject
 | |
|  * @property {import('../models/User')} user
 | |
|  *
 | |
|  * @typedef {Request & RequestUserObject} RequestWithUser
 | |
|  */
 | |
| 
 | |
| class ShareController {
 | |
|   constructor() {}
 | |
| 
 | |
|   /**
 | |
|    * Public route
 | |
|    * GET: /api/share/:slug
 | |
|    * Get media item share by slug
 | |
|    *
 | |
|    * @this {import('../routers/PublicRouter')}
 | |
|    *
 | |
|    * @param {Request} req
 | |
|    * @param {Response} res
 | |
|    */
 | |
|   async getMediaItemShareBySlug(req, res) {
 | |
|     const { slug } = req.params
 | |
|     // Optional start time
 | |
|     let startTime = req.query.t && !isNaN(req.query.t) ? Math.max(0, parseInt(req.query.t)) : 0
 | |
| 
 | |
|     const mediaItemShare = ShareManager.findBySlug(slug)
 | |
|     if (!mediaItemShare) {
 | |
|       Logger.warn(`[ShareController] Media item share not found with slug ${slug}`)
 | |
|       return res.sendStatus(404)
 | |
|     }
 | |
|     if (mediaItemShare.expiresAt && mediaItemShare.expiresAt.valueOf() < Date.now()) {
 | |
|       ShareManager.removeMediaItemShare(mediaItemShare.id)
 | |
|       return res.status(404).send('Media item share not found')
 | |
|     }
 | |
| 
 | |
|     if (req.cookies.share_session_id) {
 | |
|       const playbackSession = ShareManager.findPlaybackSessionBySessionId(req.cookies.share_session_id)
 | |
| 
 | |
|       if (playbackSession) {
 | |
|         if (mediaItemShare.id === playbackSession.mediaItemShareId) {
 | |
|           Logger.debug(`[ShareController] Found share playback session ${req.cookies.share_session_id}`)
 | |
|           mediaItemShare.playbackSession = playbackSession.toJSONForClient()
 | |
|           return res.json(mediaItemShare)
 | |
|         } else {
 | |
|           // Changed media item share - close other session
 | |
|           Logger.debug(`[ShareController] Other playback session is already open for share session. Closing session "${playbackSession.displayTitle}"`)
 | |
|           ShareManager.closeSharePlaybackSession(playbackSession)
 | |
|         }
 | |
|       } else {
 | |
|         Logger.info(`[ShareController] Share playback session not found with id ${req.cookies.share_session_id}`)
 | |
|         if (!uuid.validate(req.cookies.share_session_id) || uuid.version(req.cookies.share_session_id) !== 4) {
 | |
|           Logger.warn(`[ShareController] Invalid share session id ${req.cookies.share_session_id}`)
 | |
|           res.clearCookie('share_session_id')
 | |
|         }
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     try {
 | |
|       const libraryItem = await Database.mediaItemShareModel.getMediaItemsLibraryItem(mediaItemShare.mediaItemId, mediaItemShare.mediaItemType)
 | |
|       if (!libraryItem) {
 | |
|         return res.status(404).send('Media item not found')
 | |
|       }
 | |
| 
 | |
|       let startOffset = 0
 | |
|       const publicTracks = libraryItem.media.includedAudioFiles.map((audioFile) => {
 | |
|         const audioTrack = {
 | |
|           index: audioFile.index,
 | |
|           startOffset,
 | |
|           duration: audioFile.duration,
 | |
|           title: audioFile.metadata.filename || '',
 | |
|           contentUrl: `${global.RouterBasePath}/public/share/${slug}/track/${audioFile.index}`,
 | |
|           mimeType: audioFile.mimeType,
 | |
|           codec: audioFile.codec || null,
 | |
|           metadata: structuredClone(audioFile.metadata)
 | |
|         }
 | |
|         startOffset += audioTrack.duration
 | |
|         return audioTrack
 | |
|       })
 | |
| 
 | |
|       if (startTime > startOffset) {
 | |
|         Logger.warn(`[ShareController] Start time ${startTime} is greater than total duration ${startOffset}`)
 | |
|         startTime = 0
 | |
|       }
 | |
| 
 | |
|       const shareSessionId = req.cookies.share_session_id || uuid.v4()
 | |
|       const clientDeviceInfo = {
 | |
|         clientName: 'Abs Web Share',
 | |
|         deviceId: shareSessionId
 | |
|       }
 | |
|       const deviceInfo = await this.playbackSessionManager.getDeviceInfo(req, clientDeviceInfo)
 | |
| 
 | |
|       const newPlaybackSession = new PlaybackSession()
 | |
|       newPlaybackSession.setData(libraryItem, null, 'web-share', deviceInfo, startTime)
 | |
|       newPlaybackSession.audioTracks = publicTracks
 | |
|       newPlaybackSession.playMethod = PlayMethod.DIRECTPLAY
 | |
|       newPlaybackSession.shareSessionId = shareSessionId
 | |
|       newPlaybackSession.mediaItemShareId = mediaItemShare.id
 | |
|       newPlaybackSession.coverAspectRatio = libraryItem.library.settings.coverAspectRatio
 | |
| 
 | |
|       mediaItemShare.playbackSession = newPlaybackSession.toJSONForClient()
 | |
|       ShareManager.addOpenSharePlaybackSession(newPlaybackSession)
 | |
| 
 | |
|       // 30 day cookie
 | |
|       res.cookie('share_session_id', newPlaybackSession.shareSessionId, { maxAge: 1000 * 60 * 60 * 24 * 30, httpOnly: true })
 | |
| 
 | |
|       res.json(mediaItemShare)
 | |
|     } catch (error) {
 | |
|       Logger.error(`[ShareController] Failed`, error)
 | |
|       res.status(500).send('Internal server error')
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Public route - requires share_session_id cookie
 | |
|    *
 | |
|    * GET: /api/share/:slug/cover
 | |
|    * Get media item share cover image
 | |
|    *
 | |
|    * @param {Request} req
 | |
|    * @param {Response} res
 | |
|    */
 | |
|   async getMediaItemShareCoverImage(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)
 | |
|     }
 | |
| 
 | |
|     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 coverPath = playbackSession.coverPath
 | |
|     if (!coverPath) {
 | |
|       return res.status(404).send('Cover image not found')
 | |
|     }
 | |
| 
 | |
|     if (global.XAccel) {
 | |
|       const encodedURI = encodeUriPath(global.XAccel + coverPath)
 | |
|       Logger.debug(`Use X-Accel to serve static file ${encodedURI}`)
 | |
|       return res.status(204).header({ 'X-Accel-Redirect': encodedURI }).send()
 | |
|     }
 | |
| 
 | |
|     res.sendFile(coverPath)
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Public route - requires share_session_id cookie
 | |
|    *
 | |
|    * GET: /api/share/:slug/track/:index
 | |
|    * Get media item share audio track
 | |
|    *
 | |
|    * @param {Request} req
 | |
|    * @param {Response} res
 | |
|    */
 | |
|   async getMediaItemShareAudioTrack(req, res) {
 | |
|     if (!req.cookies.share_session_id) {
 | |
|       return res.status(404).send('Share session not set')
 | |
|     }
 | |
| 
 | |
|     const { slug, index } = req.params
 | |
| 
 | |
|     const mediaItemShare = ShareManager.findBySlug(slug)
 | |
|     if (!mediaItemShare) {
 | |
|       return res.status(404)
 | |
|     }
 | |
| 
 | |
|     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 audioTrack = playbackSession.audioTracks.find((t) => t.index === parseInt(index))
 | |
|     if (!audioTrack) {
 | |
|       return res.status(404).send('Track not found')
 | |
|     }
 | |
|     const audioTrackPath = audioTrack.metadata.path
 | |
| 
 | |
|     if (global.XAccel) {
 | |
|       const encodedURI = encodeUriPath(global.XAccel + audioTrackPath)
 | |
|       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(audioTrackPath))
 | |
|     if (audioMimeType) {
 | |
|       res.setHeader('Content-Type', audioMimeType)
 | |
|     }
 | |
|     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
 | |
|    *
 | |
|    * PATCH: /api/share/:slug/progress
 | |
|    * Update media item share progress
 | |
|    *
 | |
|    * @param {Request} req
 | |
|    * @param {Response} res
 | |
|    */
 | |
|   async updateMediaItemShareProgress(req, res) {
 | |
|     if (!req.cookies.share_session_id) {
 | |
|       return res.status(404).send('Share session not set')
 | |
|     }
 | |
| 
 | |
|     const { slug } = req.params
 | |
|     const { currentTime } = req.body
 | |
|     if (currentTime === null || isNaN(currentTime) || currentTime < 0) {
 | |
|       return res.status(400).send('Invalid current time')
 | |
|     }
 | |
| 
 | |
|     const mediaItemShare = ShareManager.findBySlug(slug)
 | |
|     if (!mediaItemShare) {
 | |
|       return res.status(404)
 | |
|     }
 | |
| 
 | |
|     const playbackSession = ShareManager.findPlaybackSessionBySessionId(req.cookies.share_session_id)
 | |
|     if (!playbackSession || playbackSession.mediaItemShareId !== mediaItemShare.id) {
 | |
|       return res.status(404).send('Share session not found')
 | |
|     }
 | |
| 
 | |
|     playbackSession.currentTime = Math.min(currentTime, playbackSession.duration)
 | |
|     playbackSession.updatedAt = Date.now()
 | |
|     Logger.debug(`[ShareController] Update share playback session ${req.cookies.share_session_id} currentTime: ${playbackSession.currentTime}`)
 | |
|     res.sendStatus(204)
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * POST: /api/share/mediaitem
 | |
|    * Create a new media item share
 | |
|    *
 | |
|    * @param {RequestWithUser} req
 | |
|    * @param {Response} res
 | |
|    */
 | |
|   async createMediaItemShare(req, res) {
 | |
|     if (!req.user.isAdminOrUp) {
 | |
|       Logger.error(`[ShareController] Non-admin user "${req.user.username}" attempted to create item share`)
 | |
|       return res.sendStatus(403)
 | |
|     }
 | |
| 
 | |
|     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')
 | |
|     }
 | |
|     if (expiresAt === null || isNaN(expiresAt) || expiresAt < 0) {
 | |
|       return res.status(400).send('Invalid expiration date')
 | |
|     }
 | |
|     if (!['book', 'podcastEpisode'].includes(mediaItemType)) {
 | |
|       return res.status(400).send('Invalid media item type')
 | |
|     }
 | |
| 
 | |
|     try {
 | |
|       // Check if the media item share already exists by slug or mediaItemId
 | |
|       const existingMediaItemShare = await Database.mediaItemShareModel.findOne({
 | |
|         where: {
 | |
|           [Op.or]: [{ slug }, { mediaItemId }]
 | |
|         }
 | |
|       })
 | |
|       if (existingMediaItemShare) {
 | |
|         if (existingMediaItemShare.mediaItemId === mediaItemId) {
 | |
|           return res.status(409).send('Item is already shared')
 | |
|         } else {
 | |
|           return res.status(409).send('Slug is already in use')
 | |
|         }
 | |
|       }
 | |
| 
 | |
|       // Check that media item exists
 | |
|       const mediaItemModel = mediaItemType === 'book' ? Database.bookModel : Database.podcastEpisodeModel
 | |
|       const mediaItem = await mediaItemModel.findByPk(mediaItemId)
 | |
|       if (!mediaItem) {
 | |
|         return res.status(404).send('Media item not found')
 | |
|       }
 | |
| 
 | |
|       const mediaItemShare = await Database.mediaItemShareModel.create({
 | |
|         slug,
 | |
|         expiresAt: expiresAt || null,
 | |
|         mediaItemId,
 | |
|         mediaItemType,
 | |
|         userId: req.user.id,
 | |
|         isDownloadable
 | |
|       })
 | |
| 
 | |
|       ShareManager.openMediaItemShare(mediaItemShare)
 | |
| 
 | |
|       res.status(201).json(mediaItemShare?.toJSONForClient())
 | |
|     } catch (error) {
 | |
|       Logger.error(`[ShareController] Failed`, error)
 | |
|       res.status(500).send('Internal server error')
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * DELETE: /api/share/mediaitem/:id
 | |
|    * Delete media item share
 | |
|    *
 | |
|    * @param {RequestWithUser} req
 | |
|    * @param {Response} res
 | |
|    */
 | |
|   async deleteMediaItemShare(req, res) {
 | |
|     if (!req.user.isAdminOrUp) {
 | |
|       Logger.error(`[ShareController] Non-admin user "${req.user.username}" attempted to delete item share`)
 | |
|       return res.sendStatus(403)
 | |
|     }
 | |
| 
 | |
|     try {
 | |
|       const mediaItemShare = await Database.mediaItemShareModel.findByPk(req.params.id)
 | |
|       if (!mediaItemShare) {
 | |
|         return res.status(404).send('Media item share not found')
 | |
|       }
 | |
| 
 | |
|       ShareManager.removeMediaItemShare(mediaItemShare.id)
 | |
| 
 | |
|       await mediaItemShare.destroy()
 | |
|       res.sendStatus(204)
 | |
|     } catch (error) {
 | |
|       Logger.error(`[ShareController] Failed`, error)
 | |
|       res.status(500).send('Internal server error')
 | |
|     }
 | |
|   }
 | |
| }
 | |
| module.exports = new ShareController()
 |