mirror of
				https://github.com/advplyr/audiobookshelf.git
				synced 2025-10-27 11:18:14 +01:00 
			
		
		
		
	Update:Media item share endpoints and audio player #1768
- Add endpoints for getting tracks, getting cover image and updating progress - Implement share session cookie and caching share playback session - Audio player UI/UX
This commit is contained in:
		
							parent
							
								
									c1349e586a
								
							
						
					
					
						commit
						31146082f0
					
				| @ -1,10 +1,14 @@ | ||||
| <template> | ||||
|   <div id="page-wrapper" class="w-full h-screen overflow-y-auto"> | ||||
|   <div id="page-wrapper" class="w-full h-screen max-h-screen overflow-hidden"> | ||||
|     <div class="w-full h-full flex items-center justify-center"> | ||||
|       <div class="w-full p-8"> | ||||
|         <p class="text-3xl font-semibold text-center mb-6">{{ mediaItemShare.playbackSession?.displayTitle || 'N/A' }}</p> | ||||
|       <div class="w-full p-2 sm:p-4 md:p-8"> | ||||
|         <div :style="{ width: coverWidth + 'px', height: coverHeight + 'px' }" class="mx-auto overflow-hidden rounded-xl my-2"> | ||||
|           <img :src="coverUrl" class="object-contain w-full h-full" /> | ||||
|         </div> | ||||
|         <p class="text-2xl md:text-3xl font-semibold text-center mb-1">{{ mediaItemShare.playbackSession.displayTitle || 'No title' }}</p> | ||||
|         <p v-if="mediaItemShare.playbackSession.displayAuthor" class="text-xl text-slate-400 font-semibold text-center mb-1">{{ mediaItemShare.playbackSession.displayAuthor }}</p> | ||||
| 
 | ||||
|         <div class="w-full py-8"> | ||||
|         <div class="w-full pt-16"> | ||||
|           <player-ui ref="audioPlayer" :chapters="chapters" :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> | ||||
| @ -36,12 +40,25 @@ export default { | ||||
|       playerState: null, | ||||
|       playInterval: null, | ||||
|       hasLoaded: false, | ||||
|       totalDuration: 0 | ||||
|       totalDuration: 0, | ||||
|       windowWidth: 0, | ||||
|       windowHeight: 0, | ||||
|       listeningTimeSinceSync: 0 | ||||
|     } | ||||
|   }, | ||||
|   computed: { | ||||
|     playbackSession() { | ||||
|       return this.mediaItemShare.playbackSession | ||||
|     }, | ||||
|     coverUrl() { | ||||
|       if (!this.playbackSession.coverPath) return `${this.$config.routerBasePath}/book_placeholder.jpg` | ||||
|       if (process.env.NODE_ENV === 'development') { | ||||
|         return `http://localhost:3333/public/share/${this.mediaItemShare.slug}/cover` | ||||
|       } | ||||
|       return `/public/share/${this.mediaItemShare.slug}/cover` | ||||
|     }, | ||||
|     audioTracks() { | ||||
|       return (this.mediaItemShare.playbackSession?.audioTracks || []).map((track) => { | ||||
|       return (this.playbackSession.audioTracks || []).map((track) => { | ||||
|         if (process.env.NODE_ENV === 'development') { | ||||
|           track.contentUrl = `${process.env.serverUrl}${track.contentUrl}` | ||||
|         } | ||||
| @ -56,7 +73,24 @@ export default { | ||||
|       return !this.isPlaying | ||||
|     }, | ||||
|     chapters() { | ||||
|       return this.mediaItemShare.playbackSession?.chapters || [] | ||||
|       return this.playbackSession.chapters || [] | ||||
|     }, | ||||
|     coverAspectRatio() { | ||||
|       const coverAspectRatio = this.playbackSession.coverAspectRatio | ||||
|       return coverAspectRatio === this.$constants.BookCoverAspectRatio.STANDARD ? 1.6 : 1 | ||||
|     }, | ||||
|     coverWidth() { | ||||
|       const availableCoverWidth = Math.min(450, this.windowWidth - 32) | ||||
|       const availableCoverHeight = Math.min(450, this.windowHeight - 250) | ||||
| 
 | ||||
|       const mostCoverHeight = availableCoverWidth * this.coverAspectRatio | ||||
|       if (mostCoverHeight > availableCoverHeight) { | ||||
|         return availableCoverHeight / this.coverAspectRatio | ||||
|       } | ||||
|       return availableCoverWidth | ||||
|     }, | ||||
|     coverHeight() { | ||||
|       return this.coverWidth * this.coverAspectRatio | ||||
|     } | ||||
|   }, | ||||
|   methods: { | ||||
| @ -102,11 +136,29 @@ export default { | ||||
|         this.$refs.audioPlayer.setDuration(this.totalDuration) | ||||
|       } | ||||
|     }, | ||||
|     sendProgressSync(currentTime) { | ||||
|       console.log('Sending progress sync for time', currentTime) | ||||
|       const progress = { | ||||
|         currentTime | ||||
|       } | ||||
|       this.$axios.$patch(`/public/share/${this.mediaItemShare.slug}/progress`, progress, { progress: false }).catch((error) => { | ||||
|         console.error('Failed to send progress sync', error) | ||||
|       }) | ||||
|     }, | ||||
|     startPlayInterval() { | ||||
|       let lastTick = Date.now() | ||||
|       clearInterval(this.playInterval) | ||||
|       this.playInterval = setInterval(() => { | ||||
|         if (this.localAudioPlayer) { | ||||
|           this.setCurrentTime(this.localAudioPlayer.getCurrentTime()) | ||||
|         if (!this.localAudioPlayer) return | ||||
| 
 | ||||
|         const currentTime = this.localAudioPlayer.getCurrentTime() | ||||
|         this.setCurrentTime(currentTime) | ||||
|         const exactTimeElapsed = (Date.now() - lastTick) / 1000 | ||||
|         lastTick = Date.now() | ||||
|         this.listeningTimeSinceSync += exactTimeElapsed | ||||
|         if (this.listeningTimeSinceSync >= 30) { | ||||
|           this.listeningTimeSinceSync = 0 | ||||
|           this.sendProgressSync(currentTime) | ||||
|         } | ||||
|       }, 1000) | ||||
|     }, | ||||
| @ -115,7 +167,6 @@ export default { | ||||
|       this.playInterval = null | ||||
|     }, | ||||
|     playerStateChange(state) { | ||||
|       console.log('Player state change', state) | ||||
|       this.playerState = state | ||||
|       if (state === 'LOADED' || state === 'PLAYING') { | ||||
|         this.setDuration() | ||||
| @ -158,17 +209,28 @@ export default { | ||||
|         this.$eventBus.$emit('player-hotkey', name) | ||||
|         e.preventDefault() | ||||
|       } | ||||
|     }, | ||||
|     resize() { | ||||
|       this.windowWidth = window.innerWidth | ||||
|       this.windowHeight = window.innerHeight | ||||
|     } | ||||
|   }, | ||||
|   mounted() { | ||||
|     this.resize() | ||||
|     window.addEventListener('resize', this.resize) | ||||
|     window.addEventListener('keydown', this.keyDown) | ||||
| 
 | ||||
|     if (process.env.NODE_ENV === 'development') { | ||||
|       console.log('Loaded media item share', this.mediaItemShare) | ||||
|     this.localAudioPlayer.set(null, this.audioTracks, false, 0, false) | ||||
|     } | ||||
| 
 | ||||
|     const startTime = this.playbackSession.currentTime || 0 | ||||
|     this.localAudioPlayer.set(null, this.audioTracks, false, startTime, false) | ||||
|     this.localAudioPlayer.on('stateChange', this.playerStateChange.bind(this)) | ||||
|     this.localAudioPlayer.on('timeupdate', this.playerTimeUpdate.bind(this)) | ||||
|   }, | ||||
|   beforeDestroy() { | ||||
|     window.removeEventListener('resize', this.resize) | ||||
|     window.removeEventListener('keydown', this.keyDown) | ||||
| 
 | ||||
|     this.localAudioPlayer.off('stateChange', this.playerStateChange) | ||||
|  | ||||
| @ -1,3 +1,4 @@ | ||||
| const uuidv4 = require('uuid').v4 | ||||
| const Path = require('path') | ||||
| const { Op } = require('sequelize') | ||||
| const Logger = require('../Logger') | ||||
| @ -32,6 +33,18 @@ class ShareController { | ||||
|       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) { | ||||
|         Logger.debug(`[ShareController] Found share playback session ${req.cookies.share_session_id}`) | ||||
|         mediaItemShare.playbackSession = playbackSession.toJSONForClient() | ||||
|         return res.json(mediaItemShare) | ||||
|       } else { | ||||
|         Logger.info(`[ShareController] Share playback session not found with id ${req.cookies.share_session_id}`) | ||||
|         res.clearCookie('share_session_id') | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     try { | ||||
|       const oldLibraryItem = await Database.mediaItemShareModel.getMediaItemsOldLibraryItem(mediaItemShare.mediaItemId, mediaItemShare.mediaItemType) | ||||
| 
 | ||||
| @ -46,7 +59,7 @@ class ShareController { | ||||
|           startOffset, | ||||
|           duration: audioFile.duration, | ||||
|           title: audioFile.metadata.filename || '', | ||||
|           contentUrl: `${global.RouterBasePath}/public/share/${slug}/file/${audioFile.ino}`, | ||||
|           contentUrl: `${global.RouterBasePath}/public/share/${slug}/track/${audioFile.index}`, | ||||
|           mimeType: audioFile.mimeType, | ||||
|           codec: audioFile.codec || null, | ||||
|           metadata: audioFile.metadata.clone() | ||||
| @ -59,8 +72,15 @@ class ShareController { | ||||
|       newPlaybackSession.setData(oldLibraryItem, null, 'web-public', null, 0) | ||||
|       newPlaybackSession.audioTracks = publicTracks | ||||
|       newPlaybackSession.playMethod = PlayMethod.DIRECTPLAY | ||||
|       newPlaybackSession.shareSessionId = uuidv4() // New share session id
 | ||||
|       newPlaybackSession.mediaItemShareId = mediaItemShare.id | ||||
|       newPlaybackSession.coverAspectRatio = oldLibraryItem.librarySettings.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) { | ||||
| @ -70,45 +90,127 @@ class ShareController { | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Public route | ||||
|    * GET: /api/share/:slug/file/:fileid | ||||
|    * Get media item share file | ||||
|    * Public route - requires share_session_id cookie | ||||
|    * | ||||
|    * GET: /api/share/:slug/cover | ||||
|    * Get media item share cover image | ||||
|    * | ||||
|    * @param {import('express').Request} req | ||||
|    * @param {import('express').Response} res | ||||
|    */ | ||||
|   async getMediaItemShareFile(req, res) { | ||||
|     const { slug, fileid } = req.params | ||||
|   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) | ||||
|     } | ||||
| 
 | ||||
|     /** @type {import('../models/LibraryItem')} */ | ||||
|     const libraryItem = await Database.libraryItemModel.findOne({ | ||||
|       where: { | ||||
|         mediaId: mediaItemShare.mediaItemId | ||||
|     const playbackSession = ShareManager.findPlaybackSessionBySessionId(req.cookies.share_session_id) | ||||
|     if (!playbackSession || playbackSession.mediaItemShareId !== mediaItemShare.id) { | ||||
|       res.clearCookie('share_session_id') | ||||
|       return res.status(404).send('Share session not found') | ||||
|     } | ||||
|     }) | ||||
| 
 | ||||
|     const libraryFile = libraryItem?.libraryFiles.find((lf) => lf.ino === fileid) | ||||
|     if (!libraryFile) { | ||||
|       return res.status(404).send('File 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 + libraryFile.metadata.path) | ||||
|       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 {import('express').Request} req | ||||
|    * @param {import('express').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) { | ||||
|       res.clearCookie('share_session_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(libraryFile.metadata.path)) | ||||
|     const audioMimeType = getAudioMimeTypeFromExtname(Path.extname(audioTrackPath)) | ||||
|     if (audioMimeType) { | ||||
|       res.setHeader('Content-Type', audioMimeType) | ||||
|     } | ||||
|     res.sendFile(libraryFile.metadata.path) | ||||
|     res.sendFile(audioTrackPath) | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Public route - requires share_session_id cookie | ||||
|    * | ||||
|    * PATCH: /api/share/:slug/progress | ||||
|    * Update media item share progress | ||||
|    * | ||||
|    * @param {import('express').Request} req | ||||
|    * @param {import('express').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) { | ||||
|       res.clearCookie('share_session_id') | ||||
|       return res.status(404).send('Share session not found') | ||||
|     } | ||||
| 
 | ||||
|     playbackSession.currentTime = Math.min(currentTime, playbackSession.duration) | ||||
|     Logger.debug(`[ShareController] Update share playback session ${req.cookies.share_session_id} currentTime: ${playbackSession.currentTime}`) | ||||
|     res.sendStatus(204) | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|  | ||||
| @ -12,12 +12,23 @@ class ShareManager { | ||||
|   constructor() { | ||||
|     /** @type {OpenMediaItemShareObject[]} */ | ||||
|     this.openMediaItemShares = [] | ||||
| 
 | ||||
|     /** @type {import('../objects/PlaybackSession')[]} */ | ||||
|     this.openSharePlaybackSessions = [] | ||||
|   } | ||||
| 
 | ||||
|   init() { | ||||
|     this.loadMediaItemShares() | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * @param {import('../objects/PlaybackSession')} playbackSession | ||||
|    */ | ||||
|   addOpenSharePlaybackSession(playbackSession) { | ||||
|     Logger.info(`[ShareManager] Adding new open share playback session ${playbackSession.shareSessionId}`) | ||||
|     this.openSharePlaybackSessions.push(playbackSession) | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Find an open media item share by media item ID | ||||
|    * @param {string} mediaItemId | ||||
| @ -52,6 +63,14 @@ class ShareManager { | ||||
|     return null | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * @param {string} shareSessionId | ||||
|    * @returns {import('../objects/PlaybackSession')} | ||||
|    */ | ||||
|   findPlaybackSessionBySessionId(shareSessionId) { | ||||
|     return this.openSharePlaybackSessions.find((s) => s.shareSessionId === shareSessionId) | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Load all media item shares from the database | ||||
|    * Remove expired & schedule active | ||||
| @ -123,6 +142,7 @@ class ShareManager { | ||||
|     } | ||||
| 
 | ||||
|     this.openMediaItemShares = this.openMediaItemShares.filter((s) => s.id !== mediaItemShareId) | ||||
|     this.openSharePlaybackSessions = this.openSharePlaybackSessions.filter((s) => s.mediaItemShareId !== mediaItemShareId) | ||||
|     await this.destroyMediaItemShare(mediaItemShareId) | ||||
|   } | ||||
| 
 | ||||
|  | ||||
| @ -67,14 +67,20 @@ class MediaItemShare extends Model { | ||||
|             } | ||||
|           }, | ||||
|           { | ||||
|             model: this.sequelize.models.libraryItem | ||||
|             model: this.sequelize.models.libraryItem, | ||||
|             include: { | ||||
|               model: this.sequelize.models.library, | ||||
|               attributes: ['settings'] | ||||
|             } | ||||
|           } | ||||
|         ] | ||||
|       }) | ||||
|       const libraryItem = book.libraryItem | ||||
|       libraryItem.media = book | ||||
|       delete book.libraryItem | ||||
|       return this.sequelize.models.libraryItem.getOldLibraryItem(libraryItem) | ||||
|       const oldLibraryItem = this.sequelize.models.libraryItem.getOldLibraryItem(libraryItem) | ||||
|       oldLibraryItem.librarySettings = libraryItem.library.settings | ||||
|       return oldLibraryItem | ||||
|     } | ||||
|     return null | ||||
|   } | ||||
|  | ||||
| @ -43,6 +43,10 @@ class PlaybackSession { | ||||
|     this.audioTracks = [] | ||||
|     this.videoTrack = null | ||||
|     this.stream = null | ||||
|     // Used for share sessions
 | ||||
|     this.shareSessionId = null | ||||
|     this.mediaItemShareId = null | ||||
|     this.coverAspectRatio = null | ||||
| 
 | ||||
|     if (session) { | ||||
|       this.construct(session) | ||||
|  | ||||
| @ -10,7 +10,9 @@ class PublicRouter { | ||||
| 
 | ||||
|   init() { | ||||
|     this.router.get('/share/:slug', ShareController.getMediaItemShareBySlug.bind(this)) | ||||
|     this.router.get('/share/:slug/file/:fileid', ShareController.getMediaItemShareFile.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.patch('/share/:slug/progress', ShareController.updateMediaItemShareProgress.bind(this)) | ||||
|   } | ||||
| } | ||||
| module.exports = PublicRouter | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user