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:
advplyr 2024-06-29 15:05:35 -05:00
parent c1349e586a
commit 31146082f0
6 changed files with 229 additions and 33 deletions

View File

@ -1,10 +1,14 @@
<template> <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 h-full flex items-center justify-center">
<div class="w-full p-8"> <div class="w-full p-2 sm:p-4 md:p-8">
<p class="text-3xl font-semibold text-center mb-6">{{ mediaItemShare.playbackSession?.displayTitle || 'N/A' }}</p> <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" /> <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>
</div> </div>
@ -36,12 +40,25 @@ export default {
playerState: null, playerState: null,
playInterval: null, playInterval: null,
hasLoaded: false, hasLoaded: false,
totalDuration: 0 totalDuration: 0,
windowWidth: 0,
windowHeight: 0,
listeningTimeSinceSync: 0
} }
}, },
computed: { 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() { audioTracks() {
return (this.mediaItemShare.playbackSession?.audioTracks || []).map((track) => { return (this.playbackSession.audioTracks || []).map((track) => {
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === 'development') {
track.contentUrl = `${process.env.serverUrl}${track.contentUrl}` track.contentUrl = `${process.env.serverUrl}${track.contentUrl}`
} }
@ -56,7 +73,24 @@ export default {
return !this.isPlaying return !this.isPlaying
}, },
chapters() { 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: { methods: {
@ -102,11 +136,29 @@ export default {
this.$refs.audioPlayer.setDuration(this.totalDuration) 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() { startPlayInterval() {
let lastTick = Date.now()
clearInterval(this.playInterval) clearInterval(this.playInterval)
this.playInterval = setInterval(() => { this.playInterval = setInterval(() => {
if (this.localAudioPlayer) { if (!this.localAudioPlayer) return
this.setCurrentTime(this.localAudioPlayer.getCurrentTime())
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) }, 1000)
}, },
@ -115,7 +167,6 @@ export default {
this.playInterval = null this.playInterval = null
}, },
playerStateChange(state) { playerStateChange(state) {
console.log('Player state change', state)
this.playerState = state this.playerState = state
if (state === 'LOADED' || state === 'PLAYING') { if (state === 'LOADED' || state === 'PLAYING') {
this.setDuration() this.setDuration()
@ -158,17 +209,28 @@ export default {
this.$eventBus.$emit('player-hotkey', name) this.$eventBus.$emit('player-hotkey', name)
e.preventDefault() e.preventDefault()
} }
},
resize() {
this.windowWidth = window.innerWidth
this.windowHeight = window.innerHeight
} }
}, },
mounted() { mounted() {
this.resize()
window.addEventListener('resize', this.resize)
window.addEventListener('keydown', this.keyDown) window.addEventListener('keydown', this.keyDown)
console.log('Loaded media item share', this.mediaItemShare) if (process.env.NODE_ENV === 'development') {
this.localAudioPlayer.set(null, this.audioTracks, false, 0, false) console.log('Loaded media item share', this.mediaItemShare)
}
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('stateChange', this.playerStateChange.bind(this))
this.localAudioPlayer.on('timeupdate', this.playerTimeUpdate.bind(this)) this.localAudioPlayer.on('timeupdate', this.playerTimeUpdate.bind(this))
}, },
beforeDestroy() { beforeDestroy() {
window.removeEventListener('resize', this.resize)
window.removeEventListener('keydown', this.keyDown) window.removeEventListener('keydown', this.keyDown)
this.localAudioPlayer.off('stateChange', this.playerStateChange) this.localAudioPlayer.off('stateChange', this.playerStateChange)

View File

@ -1,3 +1,4 @@
const uuidv4 = require('uuid').v4
const Path = require('path') const Path = require('path')
const { Op } = require('sequelize') const { Op } = require('sequelize')
const Logger = require('../Logger') const Logger = require('../Logger')
@ -32,6 +33,18 @@ class ShareController {
return res.status(404).send('Media item share not found') 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 { try {
const oldLibraryItem = await Database.mediaItemShareModel.getMediaItemsOldLibraryItem(mediaItemShare.mediaItemId, mediaItemShare.mediaItemType) const oldLibraryItem = await Database.mediaItemShareModel.getMediaItemsOldLibraryItem(mediaItemShare.mediaItemId, mediaItemShare.mediaItemType)
@ -46,7 +59,7 @@ class ShareController {
startOffset, startOffset,
duration: audioFile.duration, duration: audioFile.duration,
title: audioFile.metadata.filename || '', 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, mimeType: audioFile.mimeType,
codec: audioFile.codec || null, codec: audioFile.codec || null,
metadata: audioFile.metadata.clone() metadata: audioFile.metadata.clone()
@ -59,8 +72,15 @@ class ShareController {
newPlaybackSession.setData(oldLibraryItem, null, 'web-public', null, 0) newPlaybackSession.setData(oldLibraryItem, null, 'web-public', null, 0)
newPlaybackSession.audioTracks = publicTracks newPlaybackSession.audioTracks = publicTracks
newPlaybackSession.playMethod = PlayMethod.DIRECTPLAY newPlaybackSession.playMethod = PlayMethod.DIRECTPLAY
newPlaybackSession.shareSessionId = uuidv4() // New share session id
newPlaybackSession.mediaItemShareId = mediaItemShare.id
newPlaybackSession.coverAspectRatio = oldLibraryItem.librarySettings.coverAspectRatio
mediaItemShare.playbackSession = newPlaybackSession.toJSONForClient() 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) res.json(mediaItemShare)
} catch (error) { } catch (error) {
@ -70,45 +90,127 @@ class ShareController {
} }
/** /**
* Public route * Public route - requires share_session_id cookie
* GET: /api/share/:slug/file/:fileid *
* Get media item share file * GET: /api/share/:slug/cover
* Get media item share cover image
* *
* @param {import('express').Request} req * @param {import('express').Request} req
* @param {import('express').Response} res * @param {import('express').Response} res
*/ */
async getMediaItemShareFile(req, res) { async getMediaItemShareCoverImage(req, res) {
const { slug, fileid } = req.params if (!req.cookies.share_session_id) {
return res.status(404).send('Share session not set')
}
const { slug } = req.params
const mediaItemShare = ShareManager.findBySlug(slug) const mediaItemShare = ShareManager.findBySlug(slug)
if (!mediaItemShare) { if (!mediaItemShare) {
return res.status(404) return res.status(404)
} }
/** @type {import('../models/LibraryItem')} */ const playbackSession = ShareManager.findPlaybackSessionBySessionId(req.cookies.share_session_id)
const libraryItem = await Database.libraryItemModel.findOne({ if (!playbackSession || playbackSession.mediaItemShareId !== mediaItemShare.id) {
where: { res.clearCookie('share_session_id')
mediaId: mediaItemShare.mediaItemId return res.status(404).send('Share session not found')
} }
})
const libraryFile = libraryItem?.libraryFiles.find((lf) => lf.ino === fileid) const coverPath = playbackSession.coverPath
if (!libraryFile) { if (!coverPath) {
return res.status(404).send('File not found') return res.status(404).send('Cover image not found')
} }
if (global.XAccel) { 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}`) Logger.debug(`Use X-Accel to serve static file ${encodedURI}`)
return res.status(204).header({ 'X-Accel-Redirect': encodedURI }).send() 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 // 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) { if (audioMimeType) {
res.setHeader('Content-Type', 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)
} }
/** /**

View File

@ -12,12 +12,23 @@ class ShareManager {
constructor() { constructor() {
/** @type {OpenMediaItemShareObject[]} */ /** @type {OpenMediaItemShareObject[]} */
this.openMediaItemShares = [] this.openMediaItemShares = []
/** @type {import('../objects/PlaybackSession')[]} */
this.openSharePlaybackSessions = []
} }
init() { init() {
this.loadMediaItemShares() 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 * Find an open media item share by media item ID
* @param {string} mediaItemId * @param {string} mediaItemId
@ -52,6 +63,14 @@ class ShareManager {
return null 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 * Load all media item shares from the database
* Remove expired & schedule active * Remove expired & schedule active
@ -123,6 +142,7 @@ class ShareManager {
} }
this.openMediaItemShares = this.openMediaItemShares.filter((s) => s.id !== mediaItemShareId) this.openMediaItemShares = this.openMediaItemShares.filter((s) => s.id !== mediaItemShareId)
this.openSharePlaybackSessions = this.openSharePlaybackSessions.filter((s) => s.mediaItemShareId !== mediaItemShareId)
await this.destroyMediaItemShare(mediaItemShareId) await this.destroyMediaItemShare(mediaItemShareId)
} }

View File

@ -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 const libraryItem = book.libraryItem
libraryItem.media = book libraryItem.media = book
delete book.libraryItem 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 return null
} }

View File

@ -43,6 +43,10 @@ class PlaybackSession {
this.audioTracks = [] this.audioTracks = []
this.videoTrack = null this.videoTrack = null
this.stream = null this.stream = null
// Used for share sessions
this.shareSessionId = null
this.mediaItemShareId = null
this.coverAspectRatio = null
if (session) { if (session) {
this.construct(session) this.construct(session)

View File

@ -10,7 +10,9 @@ class PublicRouter {
init() { init() {
this.router.get('/share/:slug', ShareController.getMediaItemShareBySlug.bind(this)) 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 module.exports = PublicRouter