From 25ca950dd06629b7918927e7025c97569222b72c Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 8 Apr 2023 18:01:24 -0500 Subject: [PATCH] Update listening sessions per device and show open sessions --- client/components/app/StreamContainer.vue | 7 ++ .../modals/ListeningSessionModal.vue | 24 ++++++- client/layouts/default.vue | 10 +++ client/pages/config/sessions.vue | 68 ++++++++++++++++++- client/players/PlayerHandler.js | 22 +++++- client/plugins/utils.js | 3 + server/controllers/SessionController.js | 21 +++++- server/managers/PlaybackSessionManager.js | 23 ++----- server/objects/DeviceInfo.js | 43 +++++++++--- server/objects/PlaybackSession.js | 12 ++-- server/routers/ApiRouter.js | 1 + 11 files changed, 195 insertions(+), 39 deletions(-) diff --git a/client/components/app/StreamContainer.vue b/client/components/app/StreamContainer.vue index fa41b528..baecbde4 100644 --- a/client/components/app/StreamContainer.vue +++ b/client/components/app/StreamContainer.vue @@ -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() { diff --git a/client/components/modals/ListeningSessionModal.vue b/client/components/modals/ListeningSessionModal.vue index 79e66cc7..ea9988f3 100644 --- a/client/components/modals/ListeningSessionModal.vue +++ b/client/components/modals/ListeningSessionModal.vue @@ -98,7 +98,8 @@
- {{ $strings.ButtonDelete }} + {{ $strings.ButtonDelete }} + Close Open Session
@@ -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() {} diff --git a/client/layouts/default.vue b/client/layouts/default.vue index 10794605..8c9316fa 100644 --- a/client/layouts/default.vue +++ b/client/layouts/default.vue @@ -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 diff --git a/client/pages/config/sessions.vue b/client/pages/config/sessions.vue index c31ce2cd..b0badc91 100644 --- a/client/pages/config/sessions.vue +++ b/client/pages/config/sessions.vue @@ -52,9 +52,53 @@

{{ $strings.MessageNoListeningSessions }}

+ + +

Open Listening Sessions

+
+ + + + + + + + + + + + + + + + + + + + +
{{ $strings.LabelItem }}{{ $strings.LabelTimeListened }}{{ $strings.LabelLastTime }}
+

{{ session.displayTitle }}

+

{{ session.displayAuthor }}

+
+

{{ $elapsedPretty(session.timeListening) }}

+
+

{{ $secondsToTimestamp(session.currentTime) }}

+
+
- + @@ -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() { diff --git a/client/players/PlayerHandler.js b/client/players/PlayerHandler.js index 93d884f6..1e591069 100644 --- a/client/players/PlayerHandler.js +++ b/client/players/PlayerHandler.js @@ -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() } diff --git a/client/plugins/utils.js b/client/plugins/utils.js index c9ece333..c4162de5 100644 --- a/client/plugins/utils.js +++ b/client/plugins/utils.js @@ -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) { diff --git a/server/controllers/SessionController.js b/server/controllers/SessionController.js index 721fbd27..5ca694fc 100644 --- a/server/controllers/SessionController.js +++ b/server/controllers/SessionController.js @@ -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) diff --git a/server/managers/PlaybackSessionManager.js b/server/managers/PlaybackSessionManager.js index 30b4ad24..d5d204cb 100644 --- a/server/managers/PlaybackSessionManager.js +++ b/server/managers/PlaybackSessionManager.js @@ -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) } diff --git a/server/objects/DeviceInfo.js b/server/objects/DeviceInfo.js index 4015be70..4d7cf0d6 100644 --- a/server/objects/DeviceInfo.js +++ b/server/objects/DeviceInfo.js @@ -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 \ No newline at end of file diff --git a/server/objects/PlaybackSession.js b/server/objects/PlaybackSession.js index db19a8d2..b4daa026 100644 --- a/server/objects/PlaybackSession.js +++ b/server/objects/PlaybackSession.js @@ -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 diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index 5b37fd5b..2264e175 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -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