mirror of
				https://github.com/advplyr/audiobookshelf.git
				synced 2025-10-27 11:18:14 +01:00 
			
		
		
		
	Update listening sessions per device and show open sessions
This commit is contained in:
		
							parent
							
								
									8fca84e4bd
								
							
						
					
					
						commit
						25ca950dd0
					
				| @ -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() { | ||||
|  | ||||
| @ -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() {} | ||||
|  | ||||
| @ -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 | ||||
|  | ||||
| @ -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() { | ||||
|  | ||||
| @ -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() | ||||
|     } | ||||
|  | ||||
| @ -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) { | ||||
|  | ||||
| @ -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) | ||||
|  | ||||
| @ -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) | ||||
|   } | ||||
| 
 | ||||
|  | ||||
| @ -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 | ||||
| @ -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 | ||||
|  | ||||
| @ -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
 | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user