Update listening sessions per device and show open sessions

This commit is contained in:
advplyr 2023-04-08 18:01:24 -05:00
parent 8fca84e4bd
commit 25ca950dd0
11 changed files with 195 additions and 39 deletions

View File

@ -460,6 +460,13 @@ export default {
showFailedProgressSyncs() {
if (!isNaN(this.syncFailedToast)) this.$toast.dismiss(this.syncFailedToast)
this.syncFailedToast = this.$toast('Progress is not being synced. Restart playback', { timeout: false, type: 'error' })
},
sessionClosedEvent(sessionId) {
if (this.playerHandler.currentSessionId === sessionId) {
console.log('sessionClosedEvent closing current session', sessionId)
this.playerHandler.resetPlayer() // Closes player without reporting to server
this.$store.commit('setMediaPlaying', null)
}
}
},
mounted() {

View File

@ -98,7 +98,8 @@
</div>
<div class="flex items-center">
<ui-btn small color="error" @click.stop="deleteSessionClick">{{ $strings.ButtonDelete }}</ui-btn>
<ui-btn v-if="!isOpenSession" 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>
</div>
</div>
</modals-modal>
@ -157,6 +158,9 @@ export default {
},
timeFormat() {
return this.$store.state.serverSettings.timeFormat
},
isOpenSession() {
return !!this._session.open
}
},
methods: {
@ -188,6 +192,24 @@ export default {
var errMsg = error.response ? error.response.data || '' : ''
this.$toast.error(errMsg || this.$strings.ToastSessionDeleteFailed)
})
},
closeSessionClick() {
this.processing = true
this.$axios
.$post(`/api/session/${this._session.id}/close`)
.then(() => {
this.$toast.success('Session closed')
this.show = false
this.$emit('closedSession')
})
.catch((error) => {
console.error('Failed to close session', error)
const errMsg = error.response?.data || ''
this.$toast.error(errMsg || 'Failed to close open session')
})
.finally(() => {
this.processing = false
})
}
},
mounted() {}

View File

@ -299,8 +299,17 @@ export default {
userStreamUpdate(user) {
this.$store.commit('users/updateUserOnline', user)
},
userSessionClosed(sessionId) {
if (this.$refs.streamContainer) this.$refs.streamContainer.sessionClosedEvent(sessionId)
},
userMediaProgressUpdate(payload) {
this.$store.commit('user/updateMediaProgress', payload)
if (payload.data) {
if (this.$store.getters['getIsMediaStreaming'](payload.data.libraryItemId, payload.data.episodeId)) {
// TODO: Update currently open session if being played from another device
}
}
},
collectionAdded(collection) {
if (this.currentLibraryId !== collection.libraryId) return
@ -405,6 +414,7 @@ export default {
this.socket.on('user_online', this.userOnline)
this.socket.on('user_offline', this.userOffline)
this.socket.on('user_stream_update', this.userStreamUpdate)
this.socket.on('user_session_closed', this.userSessionClosed)
this.socket.on('user_item_progress_updated', this.userMediaProgressUpdate)
// Collection Listeners

View File

@ -52,9 +52,53 @@
</div>
</div>
<p v-else class="text-white text-opacity-50">{{ $strings.MessageNoListeningSessions }}</p>
<!-- open listening sessions table -->
<p v-if="openListeningSessions.length" class="text-lg mb-4 mt-8">Open Listening Sessions</p>
<div v-if="openListeningSessions.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-32 min-w-32">{{ $strings.LabelTimeListened }}</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 openListeningSessions" :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">
<p v-if="filteredUserUsername" class="text-xs">{{ filteredUserUsername }}</p>
<p v-else class="text-xs">{{ session.user ? session.user.username : 'N/A' }}</p>
</td>
<td class="hidden md:table-cell">
<p class="text-xs">{{ getPlayMethodName(session.playMethod) }}</p>
</td>
<td class="hidden sm:table-cell">
<p class="text-xs" v-html="getDeviceInfoString(session.deviceInfo)" />
</td>
<td class="text-center">
<p class="text-xs font-mono">{{ $elapsedPretty(session.timeListening) }}</p>
</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>
<modals-listening-session-modal v-model="showSessionModal" :session="selectedSession" @removedSession="removedSession" />
<modals-listening-session-modal v-model="showSessionModal" :session="selectedSession" @removedSession="removedSession" @closedSession="closedSession" />
</div>
</template>
@ -81,6 +125,7 @@ export default {
showSessionModal: false,
selectedSession: null,
listeningSessions: [],
openListeningSessions: [],
numPages: 0,
total: 0,
currentPage: 0,
@ -114,6 +159,9 @@ export default {
}
},
methods: {
closedSession() {
this.loadOpenSessions()
},
removedSession() {
// If on last page and this was the last session then load prev page
if (this.currentPage == this.numPages - 1) {
@ -222,7 +270,7 @@ export default {
async loadSessions(page) {
var userFilterQuery = this.selectedUser ? `&user=${this.selectedUser}` : ''
const data = await this.$axios.$get(`/api/sessions?page=${page}&itemsPerPage=${this.itemsPerPage}${userFilterQuery}`).catch((err) => {
console.error('Failed to load listening sesions', err)
console.error('Failed to load listening sessions', err)
return null
})
if (!data) {
@ -236,8 +284,24 @@ export default {
this.listeningSessions = data.sessions
this.userFilter = data.userFilter
},
async loadOpenSessions() {
const data = await this.$axios.$get('/api/sessions/open').catch((err) => {
console.error('Failed to load open sessions', err)
return null
})
if (!data) {
this.$toast.error('Failed to load open sessions')
return
}
this.openListeningSessions = (data.sessions || []).map((s) => {
s.open = true
return s
})
},
init() {
this.loadSessions(0)
this.loadOpenSessions()
}
},
mounted() {

View File

@ -173,16 +173,28 @@ export default class PlayerHandler {
this.ctx.setBufferTime(buffertime)
}
getDeviceId() {
let deviceId = localStorage.getItem('absDeviceId')
if (!deviceId) {
deviceId = this.ctx.$randomId()
localStorage.setItem('absDeviceId', deviceId)
}
return deviceId
}
async prepare(forceTranscode = false) {
var payload = {
const payload = {
deviceInfo: {
deviceId: this.getDeviceId()
},
supportedMimeTypes: this.player.playableMimeTypes,
mediaPlayer: this.isCasting ? 'chromecast' : 'html5',
forceTranscode,
forceDirectPlay: this.isCasting || this.isVideo // TODO: add transcode support for chromecast
}
var path = this.episodeId ? `/api/items/${this.libraryItem.id}/play/${this.episodeId}` : `/api/items/${this.libraryItem.id}/play`
var session = await this.ctx.$axios.$post(path, payload).catch((error) => {
const path = this.episodeId ? `/api/items/${this.libraryItem.id}/play/${this.episodeId}` : `/api/items/${this.libraryItem.id}/play`
const session = await this.ctx.$axios.$post(path, payload).catch((error) => {
console.error('Failed to start stream', error)
})
this.prepareSession(session)
@ -238,6 +250,10 @@ export default class PlayerHandler {
closePlayer() {
console.log('[PlayerHandler] Close Player')
this.sendCloseSession()
this.resetPlayer()
}
resetPlayer() {
if (this.player) {
this.player.destroy()
}

View File

@ -1,5 +1,8 @@
import Vue from 'vue'
import cronParser from 'cron-parser'
import { nanoid } from 'nanoid'
Vue.prototype.$randomId = () => nanoid()
Vue.prototype.$bytesPretty = (bytes, decimals = 2) => {
if (isNaN(bytes) || bytes == 0) {

View File

@ -14,7 +14,7 @@ class SessionController {
return res.sendStatus(404)
}
var listeningSessions = []
let listeningSessions = []
if (req.query.user) {
listeningSessions = await this.getUserListeningSessionsHelper(req.query.user)
} else {
@ -42,6 +42,25 @@ class SessionController {
res.json(payload)
}
getOpenSessions(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error(`[SessionController] getOpenSessions: Non-admin user requested open session data ${req.user.id}/"${req.user.username}"`)
return res.sendStatus(404)
}
const openSessions = this.playbackSessionManager.sessions.map(se => {
const user = this.db.users.find(u => u.id === se.userId) || null
return {
...se.toJSON(),
user: user ? { id: user.id, username: user.username } : null
}
})
res.json({
sessions: openSessions
})
}
getOpenSession(req, res) {
var libraryItem = this.db.getLibraryItem(req.session.libraryItemId)
var sessionForClient = req.session.toJSONForClient(libraryItem)

View File

@ -14,7 +14,6 @@ const PlaybackSession = require('../objects/PlaybackSession')
const DeviceInfo = require('../objects/DeviceInfo')
const Stream = require('../objects/Stream')
class PlaybackSessionManager {
constructor(db) {
this.db = db
@ -31,13 +30,14 @@ class PlaybackSessionManager {
}
getStream(sessionId) {
const session = this.getSession(sessionId)
return session ? session.stream : null
return session?.stream || null
}
getDeviceInfo(req) {
const ua = uaParserJs(req.headers['user-agent'])
const ip = requestIp.getClientIp(req)
const clientDeviceInfo = req.body ? req.body.deviceInfo || null : null // From mobile client
const clientDeviceInfo = req.body?.deviceInfo || null
const deviceInfo = new DeviceInfo()
deviceInfo.setData(ip, ua, clientDeviceInfo, serverVersion)
@ -138,18 +138,6 @@ class PlaybackSessionManager {
}
async syncLocalSessionRequest(user, sessionJson, res) {
// If server session is open for this same media item then close it
const userSessionForThisItem = this.sessions.find(playbackSession => {
if (playbackSession.userId !== user.id) return false
if (sessionJson.episodeId) return playbackSession.episodeId !== sessionJson.episodeId
return playbackSession.libraryItemId === sessionJson.libraryItemId
})
if (userSessionForThisItem) {
Logger.info(`[PlaybackSessionManager] syncLocalSessionRequest: Closing open session "${userSessionForThisItem.displayTitle}" for user "${user.username}"`)
await this.closeSession(user, userSessionForThisItem, null)
}
// Sync
const result = await this.syncLocalSession(user, sessionJson)
if (result.error) {
res.status(500).send(result.error)
@ -164,8 +152,8 @@ class PlaybackSessionManager {
}
async startSession(user, deviceInfo, libraryItem, episodeId, options) {
// Close any sessions already open for user
const userSessions = this.sessions.filter(playbackSession => playbackSession.userId === user.id)
// Close any sessions already open for user and device
const userSessions = this.sessions.filter(playbackSession => playbackSession.userId === user.id && playbackSession.deviceId === deviceInfo.deviceId)
for (const session of userSessions) {
Logger.info(`[PlaybackSessionManager] startSession: Closing open session "${session.displayTitle}" for user "${user.username}" (Device: ${session.deviceDescription})`)
await this.closeSession(user, session, null)
@ -268,6 +256,7 @@ class PlaybackSessionManager {
}
Logger.debug(`[PlaybackSessionManager] closeSession "${session.id}"`)
SocketAuthority.adminEmitter('user_stream_update', user.toJSONForPublic(this.sessions, this.db.libraryItems))
SocketAuthority.clientEmitter(session.userId, 'user_session_closed', session.id)
return this.removeSession(session.id)
}

View File

@ -1,5 +1,6 @@
class DeviceInfo {
constructor(deviceInfo = null) {
this.deviceId = null
this.ipAddress = null
// From User Agent (see: https://www.npmjs.com/package/ua-parser-js)
@ -32,6 +33,7 @@ class DeviceInfo {
toJSON() {
const obj = {
deviceId: this.deviceId,
ipAddress: this.ipAddress,
browserName: this.browserName,
browserVersion: this.browserVersion,
@ -60,23 +62,42 @@ class DeviceInfo {
return `${this.osName} ${this.osVersion} / ${this.browserName}`
}
// When client doesn't send a device id
getTempDeviceId() {
const keys = [
this.browserName,
this.browserVersion,
this.osName,
this.osVersion,
this.clientVersion,
this.manufacturer,
this.model,
this.sdkVersion,
this.ipAddress
].map(k => k || '')
return 'temp-' + Buffer.from(keys.join('-'), 'utf-8').toString('base64')
}
setData(ip, ua, clientDeviceInfo, serverVersion) {
this.deviceId = clientDeviceInfo?.deviceId || null
this.ipAddress = ip || null
const uaObj = ua || {}
this.browserName = uaObj.browser.name || null
this.browserVersion = uaObj.browser.version || null
this.osName = uaObj.os.name || null
this.osVersion = uaObj.os.version || null
this.deviceType = uaObj.device.type || null
this.browserName = ua?.browser.name || null
this.browserVersion = ua?.browser.version || null
this.osName = ua?.os.name || null
this.osVersion = ua?.os.version || null
this.deviceType = ua?.device.type || null
const cdi = clientDeviceInfo || {}
this.clientVersion = cdi.clientVersion || null
this.manufacturer = cdi.manufacturer || null
this.model = cdi.model || null
this.sdkVersion = cdi.sdkVersion || null
this.clientVersion = clientDeviceInfo?.clientVersion || null
this.manufacturer = clientDeviceInfo?.manufacturer || null
this.model = clientDeviceInfo?.model || null
this.sdkVersion = clientDeviceInfo?.sdkVersion || null
this.serverVersion = serverVersion || null
if (!this.deviceId) {
this.deviceId = this.getTempDeviceId()
}
}
}
module.exports = DeviceInfo

View File

@ -55,7 +55,7 @@ class PlaybackSession {
libraryItemId: this.libraryItemId,
episodeId: this.episodeId,
mediaType: this.mediaType,
mediaMetadata: this.mediaMetadata ? this.mediaMetadata.toJSON() : null,
mediaMetadata: this.mediaMetadata?.toJSON() || null,
chapters: (this.chapters || []).map(c => ({ ...c })),
displayTitle: this.displayTitle,
displayAuthor: this.displayAuthor,
@ -63,7 +63,7 @@ class PlaybackSession {
duration: this.duration,
playMethod: this.playMethod,
mediaPlayer: this.mediaPlayer,
deviceInfo: this.deviceInfo ? this.deviceInfo.toJSON() : null,
deviceInfo: this.deviceInfo?.toJSON() || null,
date: this.date,
dayOfWeek: this.dayOfWeek,
timeListening: this.timeListening,
@ -82,7 +82,7 @@ class PlaybackSession {
libraryItemId: this.libraryItemId,
episodeId: this.episodeId,
mediaType: this.mediaType,
mediaMetadata: this.mediaMetadata ? this.mediaMetadata.toJSON() : null,
mediaMetadata: this.mediaMetadata?.toJSON() || null,
chapters: (this.chapters || []).map(c => ({ ...c })),
displayTitle: this.displayTitle,
displayAuthor: this.displayAuthor,
@ -90,7 +90,7 @@ class PlaybackSession {
duration: this.duration,
playMethod: this.playMethod,
mediaPlayer: this.mediaPlayer,
deviceInfo: this.deviceInfo ? this.deviceInfo.toJSON() : null,
deviceInfo: this.deviceInfo?.toJSON() || null,
date: this.date,
dayOfWeek: this.dayOfWeek,
timeListening: this.timeListening,
@ -151,6 +151,10 @@ class PlaybackSession {
return Math.max(0, Math.min(this.currentTime / this.duration, 1))
}
get deviceId() {
return this.deviceInfo?.deviceId
}
get deviceDescription() {
if (!this.deviceInfo) return 'No Device Info'
return this.deviceInfo.deviceDescription

View File

@ -214,6 +214,7 @@ class ApiRouter {
//
this.router.get('/sessions', SessionController.getAllWithUserData.bind(this))
this.router.delete('/sessions/:id', SessionController.middleware.bind(this), SessionController.delete.bind(this))
this.router.get('/sessions/open', SessionController.getOpenSessions.bind(this))
this.router.post('/session/local', SessionController.syncLocal.bind(this))
this.router.post('/session/local-all', SessionController.syncLocalSessions.bind(this))
// TODO: Update these endpoints because they are only for open playback sessions