Open media item share sessions shown on listening sessions page, create device info for share sessions

This commit is contained in:
advplyr 2024-06-30 16:36:00 -05:00
parent d7ace4d1dc
commit 8e286a6070
8 changed files with 104 additions and 40 deletions

View File

@ -80,8 +80,8 @@
</div> </div>
</div> </div>
<div class="w-full md:w-1/3"> <div class="w-full md:w-1/3">
<p class="font-semibold uppercase text-xs text-gray-400 tracking-wide mb-2 mt-6 md:mt-0">{{ $strings.LabelUser }}</p> <p v-if="!isMediaItemShareSession" class="font-semibold uppercase text-xs text-gray-400 tracking-wide mb-2 mt-6 md:mt-0">{{ $strings.LabelUser }}</p>
<p class="mb-1 text-xs">{{ _session.userId }}</p> <p v-if="!isMediaItemShareSession" class="mb-1 text-xs">{{ _session.userId }}</p>
<p class="font-semibold uppercase text-xs text-gray-400 tracking-wide mt-6 mb-2">{{ $strings.LabelMediaPlayer }}</p> <p class="font-semibold uppercase text-xs text-gray-400 tracking-wide mt-6 mb-2">{{ $strings.LabelMediaPlayer }}</p>
<p class="mb-1">{{ playMethodName }}</p> <p class="mb-1">{{ playMethodName }}</p>
@ -99,8 +99,8 @@
</div> </div>
<div class="flex items-center"> <div class="flex items-center">
<ui-btn v-if="!isOpenSession" small color="error" @click.stop="deleteSessionClick">{{ $strings.ButtonDelete }}</ui-btn> <ui-btn v-if="!isOpenSession && !isMediaItemShareSession" small color="error" @click.stop="deleteSessionClick">{{ $strings.ButtonDelete }}</ui-btn>
<ui-btn v-else small color="error" @click.stop="closeSessionClick">Close Open Session</ui-btn> <ui-btn v-else-if="!isMediaItemShareSession" small color="error" @click.stop="closeSessionClick">Close Open Session</ui-btn>
</div> </div>
</div> </div>
</modals-modal> </modals-modal>
@ -166,6 +166,9 @@ export default {
}, },
isOpenSession() { isOpenSession() {
return !!this._session.open return !!this._session.open
},
isMediaItemShareSession() {
return this._session.mediaPlayer === 'web-share'
} }
}, },
methods: { methods: {

View File

@ -100,7 +100,7 @@
</div> </div>
<p v-else class="text-white text-opacity-50">{{ $strings.MessageNoListeningSessions }}</p> <p v-else class="text-white text-opacity-50">{{ $strings.MessageNoListeningSessions }}</p>
<div class="w-full my-8 h-px bg-white/10" /> <div v-if="openListeningSessions.length" class="w-full my-8 h-px bg-white/10" />
<!-- open listening sessions table --> <!-- open listening sessions table -->
<p v-if="openListeningSessions.length" class="text-lg my-4">Open Listening Sessions</p> <p v-if="openListeningSessions.length" class="text-lg my-4">Open Listening Sessions</p>
@ -144,6 +144,45 @@
</tr> </tr>
</table> </table>
</div> </div>
<div v-if="openShareListeningSessions.length" class="w-full my-8 h-px bg-white/10" />
<!-- open share listening sessions table -->
<p v-if="openShareListeningSessions.length" class="text-lg my-4">Open Share Listening Sessions</p>
<div v-if="openShareListeningSessions.length" class="block max-w-full">
<table class="userSessionsTable">
<tr class="bg-primary bg-opacity-40">
<th class="w-48 min-w-48 text-left">{{ $strings.LabelItem }}</th>
<th class="w-20 min-w-20 text-left hidden md:table-cell">{{ $strings.LabelUser }}</th>
<th class="w-32 min-w-32 text-left hidden md:table-cell">{{ $strings.LabelPlayMethod }}</th>
<th class="w-32 min-w-32 text-left hidden sm:table-cell">{{ $strings.LabelDeviceInfo }}</th>
<th class="w-16 min-w-16">{{ $strings.LabelLastTime }}</th>
<th class="flex-grow hidden sm:table-cell">{{ $strings.LabelLastUpdate }}</th>
</tr>
<tr v-for="session in openShareListeningSessions" :key="`open-${session.id}`" class="cursor-pointer" @click="showSession(session)">
<td class="py-1 max-w-48">
<p class="text-xs text-gray-200 truncate">{{ session.displayTitle }}</p>
<p class="text-xs text-gray-400 truncate">{{ session.displayAuthor }}</p>
</td>
<td class="hidden md:table-cell"></td>
<td class="hidden md:table-cell">
<p class="text-xs">{{ getPlayMethodName(session.playMethod) }}</p>
</td>
<td class="hidden sm:table-cell max-w-32 min-w-32">
<p class="text-xs truncate" v-html="getDeviceInfoString(session.deviceInfo)" />
</td>
<td class="text-center hover:underline" @click.stop="clickCurrentTime(session)">
<p class="text-xs font-mono">{{ $secondsToTimestamp(session.currentTime) }}</p>
</td>
<td class="text-center hidden sm:table-cell">
<ui-tooltip v-if="session.updatedAt" direction="top" :text="$formatDatetime(session.updatedAt, dateFormat, timeFormat)">
<p class="text-xs text-gray-200">{{ $dateDistanceFromNow(session.updatedAt) }}</p>
</ui-tooltip>
</td>
</tr>
</table>
</div>
</app-settings-content> </app-settings-content>
<modals-listening-session-modal v-model="showSessionModal" :session="selectedSession" @removedSession="removedSession" @closedSession="closedSession" /> <modals-listening-session-modal v-model="showSessionModal" :session="selectedSession" @removedSession="removedSession" @closedSession="closedSession" />
@ -180,6 +219,7 @@ export default {
selectedSession: null, selectedSession: null,
listeningSessions: [], listeningSessions: [],
openListeningSessions: [], openListeningSessions: [],
openShareListeningSessions: [],
numPages: 0, numPages: 0,
total: 0, total: 0,
currentPage: 0, currentPage: 0,
@ -455,6 +495,7 @@ export default {
s.open = true s.open = true
return s return s
}) })
this.openShareListeningSessions = data.shareSessions || []
}, },
init() { init() {
this.loadSessions(0) this.loadSessions(0)

View File

@ -26,7 +26,7 @@ export default {
if (query.t && !isNaN(query.t)) { if (query.t && !isNaN(query.t)) {
endpoint += `?t=${query.t}` endpoint += `?t=${query.t}`
} }
const mediaItemShare = await app.$axios.$get(endpoint).catch((error) => { const mediaItemShare = await app.$axios.$get(endpoint, { timeout: 10000 }).catch((error) => {
console.error('Failed', error) console.error('Failed', error)
return null return null
}) })

View File

@ -81,7 +81,7 @@ class Server {
// Routers // Routers
this.apiRouter = new ApiRouter(this) this.apiRouter = new ApiRouter(this)
this.hlsRouter = new HlsRouter(this.auth, this.playbackSessionManager) this.hlsRouter = new HlsRouter(this.auth, this.playbackSessionManager)
this.publicRouter = new PublicRouter() this.publicRouter = new PublicRouter(this.playbackSessionManager)
Logger.logManager = new LogManager() Logger.logManager = new LogManager()

View File

@ -2,8 +2,10 @@ const Logger = require('../Logger')
const Database = require('../Database') const Database = require('../Database')
const { toNumber, isUUID } = require('../utils/index') const { toNumber, isUUID } = require('../utils/index')
const ShareManager = require('../managers/ShareManager')
class SessionController { class SessionController {
constructor() { } constructor() {}
async findOne(req, res) { async findOne(req, res) {
return res.json(req.playbackSession) return res.json(req.playbackSession)
@ -12,9 +14,9 @@ class SessionController {
/** /**
* GET: /api/sessions * GET: /api/sessions
* @this import('../routers/ApiRouter') * @this import('../routers/ApiRouter')
* *
* @param {import('express').Request} req * @param {import('express').Request} req
* @param {import('express').Response} res * @param {import('express').Response} res
*/ */
async getAllWithUserData(req, res) { async getAllWithUserData(req, res) {
if (!req.user.isAdminOrUp) { if (!req.user.isAdminOrUp) {
@ -68,15 +70,13 @@ class SessionController {
const { rows, count } = await Database.playbackSessionModel.findAndCountAll({ const { rows, count } = await Database.playbackSessionModel.findAndCountAll({
where, where,
include, include,
order: [ order: [[orderKey, orderDesc]],
[orderKey, orderDesc]
],
limit: itemsPerPage, limit: itemsPerPage,
offset: itemsPerPage * page offset: itemsPerPage * page
}) })
// Map playback sessions to old playback sessions // Map playback sessions to old playback sessions
const sessions = rows.map(session => { const sessions = rows.map((session) => {
const oldPlaybackSession = Database.playbackSessionModel.getOldPlaybackSession(session) const oldPlaybackSession = Database.playbackSessionModel.getOldPlaybackSession(session)
if (session.user) { if (session.user) {
return { return {
@ -112,15 +112,18 @@ class SessionController {
} }
const minifiedUserObjects = await Database.userModel.getMinifiedUserObjects() const minifiedUserObjects = await Database.userModel.getMinifiedUserObjects()
const openSessions = this.playbackSessionManager.sessions.map(se => { const openSessions = this.playbackSessionManager.sessions.map((se) => {
return { return {
...se.toJSON(), ...se.toJSON(),
user: minifiedUserObjects.find(u => u.id === se.userId) || null user: minifiedUserObjects.find((u) => u.id === se.userId) || null
} }
}) })
const shareSessions = ShareManager.openSharePlaybackSessions.map((se) => se.toJSON())
res.json({ res.json({
sessions: openSessions sessions: openSessions,
shareSessions
}) })
} }
@ -157,12 +160,12 @@ class SessionController {
/** /**
* POST: /api/sessions/batch/delete * POST: /api/sessions/batch/delete
* @this import('../routers/ApiRouter') * @this import('../routers/ApiRouter')
* *
* @typedef batchDeleteReqBody * @typedef batchDeleteReqBody
* @property {string[]} sessions * @property {string[]} sessions
* *
* @param {import('express').Request<{}, {}, batchDeleteReqBody, {}>} req * @param {import('express').Request<{}, {}, batchDeleteReqBody, {}>} req
* @param {import('express').Response} res * @param {import('express').Response} res
*/ */
async batchDelete(req, res) { async batchDelete(req, res) {
if (!req.user.isAdminOrUp) { if (!req.user.isAdminOrUp) {
@ -170,7 +173,7 @@ class SessionController {
return res.sendStatus(403) return res.sendStatus(403)
} }
// Validate session ids // Validate session ids
if (!req.body.sessions?.length || !Array.isArray(req.body.sessions) || req.body.sessions.some(s => !isUUID(s))) { if (!req.body.sessions?.length || !Array.isArray(req.body.sessions) || req.body.sessions.some((s) => !isUUID(s))) {
Logger.error(`[SessionController] Invalid request body. "sessions" array is required`, req.body) Logger.error(`[SessionController] Invalid request body. "sessions" array is required`, req.body)
return res.status(400).send('Invalid request body. "sessions" array of session id strings is required.') return res.status(400).send('Invalid request body. "sessions" array of session id strings is required.')
} }
@ -239,4 +242,4 @@ class SessionController {
next() next()
} }
} }
module.exports = new SessionController() module.exports = new SessionController()

View File

@ -1,4 +1,4 @@
const uuidv4 = require('uuid').v4 const uuid = require('uuid')
const Path = require('path') const Path = require('path')
const { Op } = require('sequelize') const { Op } = require('sequelize')
const Logger = require('../Logger') const Logger = require('../Logger')
@ -18,6 +18,8 @@ class ShareController {
* GET: /api/share/:slug * GET: /api/share/:slug
* Get media item share by slug * Get media item share by slug
* *
* @this {import('../routers/PublicRouter')}
*
* @param {import('express').Request} req * @param {import('express').Request} req
* @param {import('express').Response} res * @param {import('express').Response} res
*/ */
@ -28,7 +30,8 @@ class ShareController {
const mediaItemShare = ShareManager.findBySlug(slug) const mediaItemShare = ShareManager.findBySlug(slug)
if (!mediaItemShare) { if (!mediaItemShare) {
return res.status(404) Logger.warn(`[ShareController] Media item share not found with slug ${slug}`)
return res.sendStatus(404)
} }
if (mediaItemShare.expiresAt && mediaItemShare.expiresAt.valueOf() < Date.now()) { if (mediaItemShare.expiresAt && mediaItemShare.expiresAt.valueOf() < Date.now()) {
ShareManager.removeMediaItemShare(mediaItemShare.id) ShareManager.removeMediaItemShare(mediaItemShare.id)
@ -43,7 +46,10 @@ class ShareController {
return res.json(mediaItemShare) return res.json(mediaItemShare)
} else { } else {
Logger.info(`[ShareController] Share playback session not found with id ${req.cookies.share_session_id}`) Logger.info(`[ShareController] Share playback session not found with id ${req.cookies.share_session_id}`)
res.clearCookie('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')
}
} }
} }
@ -75,11 +81,18 @@ class ShareController {
startTime = 0 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() const newPlaybackSession = new PlaybackSession()
newPlaybackSession.setData(oldLibraryItem, null, 'web-public', null, startTime) newPlaybackSession.setData(oldLibraryItem, null, 'web-share', deviceInfo, startTime)
newPlaybackSession.audioTracks = publicTracks newPlaybackSession.audioTracks = publicTracks
newPlaybackSession.playMethod = PlayMethod.DIRECTPLAY newPlaybackSession.playMethod = PlayMethod.DIRECTPLAY
newPlaybackSession.shareSessionId = uuidv4() // New share session id newPlaybackSession.shareSessionId = shareSessionId
newPlaybackSession.mediaItemShareId = mediaItemShare.id newPlaybackSession.mediaItemShareId = mediaItemShare.id
newPlaybackSession.coverAspectRatio = oldLibraryItem.librarySettings.coverAspectRatio newPlaybackSession.coverAspectRatio = oldLibraryItem.librarySettings.coverAspectRatio
@ -119,7 +132,6 @@ class ShareController {
const playbackSession = ShareManager.findPlaybackSessionBySessionId(req.cookies.share_session_id) const playbackSession = ShareManager.findPlaybackSessionBySessionId(req.cookies.share_session_id)
if (!playbackSession || playbackSession.mediaItemShareId !== mediaItemShare.id) { if (!playbackSession || playbackSession.mediaItemShareId !== mediaItemShare.id) {
res.clearCookie('share_session_id')
return res.status(404).send('Share session not found') return res.status(404).send('Share session not found')
} }
@ -160,7 +172,6 @@ class ShareController {
const playbackSession = ShareManager.findPlaybackSessionBySessionId(req.cookies.share_session_id) const playbackSession = ShareManager.findPlaybackSessionBySessionId(req.cookies.share_session_id)
if (!playbackSession || playbackSession.mediaItemShareId !== mediaItemShare.id) { if (!playbackSession || playbackSession.mediaItemShareId !== mediaItemShare.id) {
res.clearCookie('share_session_id')
return res.status(404).send('Share session not found') return res.status(404).send('Share session not found')
} }
@ -211,7 +222,6 @@ class ShareController {
const playbackSession = ShareManager.findPlaybackSessionBySessionId(req.cookies.share_session_id) const playbackSession = ShareManager.findPlaybackSessionBySessionId(req.cookies.share_session_id)
if (!playbackSession || playbackSession.mediaItemShareId !== mediaItemShare.id) { if (!playbackSession || playbackSession.mediaItemShareId !== mediaItemShare.id) {
res.clearCookie('share_session_id')
return res.status(404).send('Share session not found') return res.status(404).send('Share session not found')
} }

View File

@ -35,14 +35,18 @@ class PlaybackSessionManager {
return session?.stream || null return session?.stream || null
} }
async getDeviceInfo(req) { /**
*
* @param {import('express').Request} req
* @param {Object} [clientDeviceInfo]
* @returns {Promise<DeviceInfo>}
*/
async getDeviceInfo(req, clientDeviceInfo = null) {
const ua = uaParserJs(req.headers['user-agent']) const ua = uaParserJs(req.headers['user-agent'])
const ip = requestIp.getClientIp(req) const ip = requestIp.getClientIp(req)
const clientDeviceInfo = req.body?.deviceInfo || null
const deviceInfo = new DeviceInfo() const deviceInfo = new DeviceInfo()
deviceInfo.setData(ip, ua, clientDeviceInfo, serverVersion, req.user.id) deviceInfo.setData(ip, ua, clientDeviceInfo, serverVersion, req.user?.id)
if (clientDeviceInfo?.deviceId) { if (clientDeviceInfo?.deviceId) {
const existingDevice = await Database.getDeviceByDeviceId(clientDeviceInfo.deviceId) const existingDevice = await Database.getDeviceByDeviceId(clientDeviceInfo.deviceId)
@ -66,7 +70,7 @@ class PlaybackSessionManager {
* @param {string} [episodeId] * @param {string} [episodeId]
*/ */
async startSessionRequest(req, res, episodeId) { async startSessionRequest(req, res, episodeId) {
const deviceInfo = await this.getDeviceInfo(req) const deviceInfo = await this.getDeviceInfo(req, req.body?.deviceInfo)
Logger.debug(`[PlaybackSessionManager] startSessionRequest for device ${deviceInfo.deviceDescription}`) Logger.debug(`[PlaybackSessionManager] startSessionRequest for device ${deviceInfo.deviceDescription}`)
const { user, libraryItem, body: options } = req const { user, libraryItem, body: options } = req
const session = await this.startSession(user, deviceInfo, libraryItem, episodeId, options) const session = await this.startSession(user, deviceInfo, libraryItem, episodeId, options)
@ -82,7 +86,7 @@ class PlaybackSessionManager {
} }
async syncLocalSessionsRequest(req, res) { async syncLocalSessionsRequest(req, res) {
const deviceInfo = await this.getDeviceInfo(req) const deviceInfo = await this.getDeviceInfo(req, req.body?.deviceInfo)
const user = req.user const user = req.user
const sessions = req.body.sessions || [] const sessions = req.body.sessions || []
@ -199,7 +203,7 @@ class PlaybackSessionManager {
} }
async syncLocalSessionRequest(req, res) { async syncLocalSessionRequest(req, res) {
const deviceInfo = await this.getDeviceInfo(req) const deviceInfo = await this.getDeviceInfo(req, req.body?.deviceInfo)
const user = req.user const user = req.user
const sessionJson = req.body const sessionJson = req.body
const result = await this.syncLocalSession(user, sessionJson, deviceInfo) const result = await this.syncLocalSession(user, sessionJson, deviceInfo)

View File

@ -2,7 +2,10 @@ const express = require('express')
const ShareController = require('../controllers/ShareController') const ShareController = require('../controllers/ShareController')
class PublicRouter { class PublicRouter {
constructor() { constructor(playbackSessionManager) {
/** @type {import('../managers/PlaybackSessionManager')} */
this.playbackSessionManager = playbackSessionManager
this.router = express() this.router = express()
this.router.disable('x-powered-by') this.router.disable('x-powered-by')
this.init() this.init()