mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2024-12-20 19:06:06 +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