Add:User listening sessions page, Update:Listening sessions to save media times and device info

This commit is contained in:
advplyr 2022-05-26 19:09:46 -05:00
parent 54663f0f01
commit f002532c1e
12 changed files with 466 additions and 20 deletions

View File

@ -32,6 +32,7 @@ export default {
default: ''
},
paddingX: Number,
paddingY: Number,
small: Boolean,
loading: Boolean,
disabled: Boolean
@ -48,14 +49,17 @@ export default {
if (this.small) {
list.push('text-sm')
if (this.paddingX === undefined) list.push('px-4')
list.push('py-1')
if (this.paddingY === undefined) list.push('py-1')
} else {
if (this.paddingX === undefined) list.push('px-8')
list.push('py-2')
if (this.paddingY === undefined) list.push('py-2')
}
if (this.paddingX !== undefined) {
list.push(`px-${this.paddingX}`)
}
if (this.paddingY !== undefined) {
list.push(`py-${this.paddingY}`)
}
if (this.disabled) {
list.push('cursor-not-allowed')
}

View File

@ -22,7 +22,10 @@
<div class="w-full h-px bg-white bg-opacity-10 my-2" />
<div class="py-2">
<h1 class="text-lg mb-2 text-white text-opacity-90 px-2 sm:px-0">Listening Stats</h1>
<p class="text-sm text-gray-300">{{ listeningSessions.length }} Listening Sessions</p>
<div class="flex items-center">
<p class="text-sm text-gray-300">{{ listeningSessions.length }} Listening Sessions</p>
<ui-btn :to="`/config/users/${user.id}/sessions`" class="text-xs mx-2" :padding-x="1.5" :padding-y="1">View All</ui-btn>
</div>
<p class="text-sm text-gray-300">
Total Time Listened:&nbsp;
<span class="font-mono text-base">{{ listeningTimePretty }}</span>
@ -35,7 +38,7 @@
<div v-if="latestSession" class="mt-4">
<h1 class="text-lg mb-2 text-white text-opacity-90 px-2 sm:px-0">Last Listening Session</h1>
<p class="text-sm text-gray-300">
<strong>{{ latestSession.displayTitle }}</strong> {{ $dateDistanceFromNow(latestSession.updatedAt) }} for <span class="font-mono text-base">{{ $elapsedPrettyExtended(this.latestSession.timeListening) }}</span>
<strong>{{ latestSession.displayTitle }}</strong> {{ $dateDistanceFromNow(latestSession.updatedAt) }} for <span class="font-mono text-base">{{ $elapsedPrettyExtended(this.latestSession.timeListening) }}</span>
</p>
</div>
</div>
@ -73,7 +76,7 @@
</td>
</tr>
</table>
<p v-else class="text-white text-opacity-50">Nothing read yet...</p>
<p v-else class="text-white text-opacity-50">Nothing listened to yet...</p>
</div>
</div>
</div>

View File

@ -0,0 +1,146 @@
<template>
<div class="w-full h-full">
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-0 sm:p-4 mb-8">
<nuxt-link :to="`/config/users/${user.id}`" class="text-white text-opacity-70 hover:text-opacity-100 hover:bg-white hover:bg-opacity-5 cursor-pointer rounded-full px-2 sm:px-0">
<div class="flex items-center">
<div class="h-10 w-10 flex items-center justify-center">
<span class="material-icons text-2xl">arrow_back</span>
</div>
<p class="pl-1">Back to User</p>
</div>
</nuxt-link>
<div class="flex items-center mb-2 mt-4 px-2 sm:px-0">
<widgets-online-indicator :value="!!userOnline" />
<h1 class="text-xl pl-2">{{ username }}</h1>
</div>
<div class="w-full h-px bg-white bg-opacity-10 my-2" />
<div class="py-2">
<h1 class="text-lg mb-2 text-white text-opacity-90 px-2 sm:px-0">Listening Sessions</h1>
<table v-if="listeningSessions.length" class="userSessionsTable">
<tr class="bg-primary bg-opacity-40">
<th class="flex-grow text-left">Item</th>
<th class="w-40 text-left hidden md:table-cell">Play Method</th>
<th class="w-40 text-left hidden sm:table-cell">Device Info</th>
<th class="w-20">Listening Time</th>
<th class="w-20">Last Time</th>
<!-- <th class="w-40 hidden sm:table-cell">Started At</th> -->
<th class="w-40 hidden sm:table-cell">Last Update</th>
</tr>
<tr v-for="session in listeningSessions" :key="session.id">
<td class="py-1">
<p class="text-sm text-gray-200">{{ session.displayTitle }}</p>
<p class="text-xs text-gray-400">{{ session.displayAuthor }}</p>
</td>
<td class="hidden md:table-cell">
<p class="text-xs">{{ getPlayMethodName(session.playMethod) }} with {{ session.mediaPlayer }}</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">
<p class="text-xs font-mono">{{ $secondsToTimestamp(session.currentTime) }}</p>
</td>
<!-- <td class="text-center hidden sm:table-cell">
<ui-tooltip v-if="session.startedAt" direction="top" :text="$formatDate(session.startedAt, 'MMMM do, yyyy HH:mm')">
<p class="text-xs">{{ $dateDistanceFromNow(session.startedAt) }}</p>
</ui-tooltip>
</td> -->
<td class="text-center hidden sm:table-cell">
<ui-tooltip v-if="session.updatedAt" direction="top" :text="$formatDate(session.updatedAt, 'MMMM do, yyyy HH:mm')">
<p class="text-xs">{{ $dateDistanceFromNow(session.updatedAt) }}</p>
</ui-tooltip>
</td>
</tr>
</table>
<p v-else class="text-white text-opacity-50">No sessions yet...</p>
</div>
</div>
</div>
</template>
<script>
export default {
async asyncData({ params, redirect, app }) {
var user = await app.$axios.$get(`/api/users/${params.id}`).catch((error) => {
console.error('Failed to get user', error)
return null
})
if (!user) return redirect('/config/users')
return {
user
}
},
data() {
return {
listeningSessions: []
}
},
computed: {
username() {
return this.user.username
},
userOnline() {
return this.$store.getters['users/getIsUserOnline'](this.user.id)
}
},
methods: {
getDeviceInfoString(deviceInfo) {
if (!deviceInfo) return ''
var lines = []
if (deviceInfo.osName) lines.push(`${deviceInfo.osName} ${deviceInfo.osVersion}`)
if (deviceInfo.browserName) lines.push(deviceInfo.browserName)
if (deviceInfo.manufacturer && deviceInfo.model) lines.push(`${deviceInfo.manufacturer} ${deviceInfo.model}`)
if (deviceInfo.sdkVersion) lines.push(`SDK Version: ${deviceInfo.sdkVersion}`)
return lines.join('<br>')
},
getPlayMethodName(playMethod) {
if (playMethod === this.$constants.PlayMethod.DIRECTPLAY) return 'Direct Play'
else if (playMethod === this.$constants.PlayMethod.TRANSCODE) return 'Transcode'
else if (playMethod === this.$constants.PlayMethod.DIRECTSTREAM) return 'Direct Stream'
else if (playMethod === this.$constants.PlayMethod.LOCAL) return 'Local'
return 'Unknown'
},
async init() {
console.log(navigator)
this.listeningSessions = await this.$axios.$get(`/api/users/${this.user.id}/listening-sessions`).catch((err) => {
console.error('Failed to load listening sesions', err)
return []
})
}
},
mounted() {
this.init()
}
}
</script>
<style>
.userSessionsTable {
border-collapse: collapse;
width: 100%;
border: 1px solid #474747;
}
.userSessionsTable tr:nth-child(even) {
background-color: #2e2e2e;
}
.userSessionsTable tr:not(:first-child) {
background-color: #373838;
}
.userSessionsTable tr:hover:not(:first-child) {
background-color: #474747;
}
.userSessionsTable td {
padding: 4px 8px;
}
.userSessionsTable th {
padding: 4px 8px;
font-size: 0.75rem;
}
</style>

View File

@ -28,7 +28,8 @@ const BookshelfView = {
const PlayMethod = {
DIRECTPLAY: 0,
DIRECTSTREAM: 1,
TRANSCODE: 2
TRANSCODE: 2,
LOCAL: 3
}
const Constants = {

View File

@ -57,6 +57,7 @@ Vue.prototype.$elapsedPretty = (seconds, useFullNames = false) => {
}
Vue.prototype.$secondsToTimestamp = (seconds) => {
if (!seconds) return '0:00'
var _seconds = seconds
var _minutes = Math.floor(seconds / 60)
_seconds -= _minutes * 60

View File

@ -189,8 +189,8 @@ class LibraryItemController {
Logger.error(`[LibraryItemController] startPlaybackSession cannot playback ${req.libraryItem.id}`)
return res.sendStatus(404)
}
const options = req.body || {}
this.playbackSessionManager.startSessionRequest(req.user, req.libraryItem, null, options, res)
this.playbackSessionManager.startSessionRequest(req, res, null)
}
// POST: api/items/:id/play/:episodeId
@ -206,8 +206,7 @@ class LibraryItemController {
return res.sendStatus(404)
}
const options = req.body || {}
this.playbackSessionManager.startSessionRequest(req.user, libraryItem, episodeId, options, res)
this.playbackSessionManager.startSessionRequest(req, res, episodeId)
}
// PATCH: api/items/:id/tracks

5
server/libs/isJs.js Normal file

File diff suppressed because one or more lines are too long

174
server/libs/requestIp.js Normal file
View File

@ -0,0 +1,174 @@
// SOURCE: https://github.com/pbojinov/request-ip
"use strict";
function _typeof(obj) { if (typeof Symbol === "function" && typeof Symbol.iterator === "symbol") { _typeof = function _typeof(obj) { return typeof obj; }; } else { _typeof = function _typeof(obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }; } return _typeof(obj); }
var is = require('./isJs');
/**
* Parse x-forwarded-for headers.
*
* @param {string} value - The value to be parsed.
* @return {string|null} First known IP address, if any.
*/
function getClientIpFromXForwardedFor(value) {
if (!is.existy(value)) {
return null;
}
if (is.not.string(value)) {
throw new TypeError("Expected a string, got \"".concat(_typeof(value), "\""));
} // x-forwarded-for may return multiple IP addresses in the format:
// "client IP, proxy 1 IP, proxy 2 IP"
// Therefore, the right-most IP address is the IP address of the most recent proxy
// and the left-most IP address is the IP address of the originating client.
// source: http://docs.aws.amazon.com/elasticloadbalancing/latest/classic/x-forwarded-headers.html
// Azure Web App's also adds a port for some reason, so we'll only use the first part (the IP)
var forwardedIps = value.split(',').map(function (e) {
var ip = e.trim();
if (ip.includes(':')) {
var splitted = ip.split(':'); // make sure we only use this if it's ipv4 (ip:port)
if (splitted.length === 2) {
return splitted[0];
}
}
return ip;
}); // Sometimes IP addresses in this header can be 'unknown' (http://stackoverflow.com/a/11285650).
// Therefore taking the left-most IP address that is not unknown
// A Squid configuration directive can also set the value to "unknown" (http://www.squid-cache.org/Doc/config/forwarded_for/)
return forwardedIps.find(is.ip);
}
/**
* Determine client IP address.
*
* @param req
* @returns {string} ip - The IP address if known, defaulting to empty string if unknown.
*/
function getClientIp(req) {
// Server is probably behind a proxy.
if (req.headers) {
// Standard headers used by Amazon EC2, Heroku, and others.
if (is.ip(req.headers['x-client-ip'])) {
return req.headers['x-client-ip'];
} // Load-balancers (AWS ELB) or proxies.
var xForwardedFor = getClientIpFromXForwardedFor(req.headers['x-forwarded-for']);
if (is.ip(xForwardedFor)) {
return xForwardedFor;
} // Cloudflare.
// @see https://support.cloudflare.com/hc/en-us/articles/200170986-How-does-Cloudflare-handle-HTTP-Request-headers-
// CF-Connecting-IP - applied to every request to the origin.
if (is.ip(req.headers['cf-connecting-ip'])) {
return req.headers['cf-connecting-ip'];
} // Fastly and Firebase hosting header (When forwared to cloud function)
if (is.ip(req.headers['fastly-client-ip'])) {
return req.headers['fastly-client-ip'];
} // Akamai and Cloudflare: True-Client-IP.
if (is.ip(req.headers['true-client-ip'])) {
return req.headers['true-client-ip'];
} // Default nginx proxy/fcgi; alternative to x-forwarded-for, used by some proxies.
if (is.ip(req.headers['x-real-ip'])) {
return req.headers['x-real-ip'];
} // (Rackspace LB and Riverbed's Stingray)
// http://www.rackspace.com/knowledge_center/article/controlling-access-to-linux-cloud-sites-based-on-the-client-ip-address
// https://splash.riverbed.com/docs/DOC-1926
if (is.ip(req.headers['x-cluster-client-ip'])) {
return req.headers['x-cluster-client-ip'];
}
if (is.ip(req.headers['x-forwarded'])) {
return req.headers['x-forwarded'];
}
if (is.ip(req.headers['forwarded-for'])) {
return req.headers['forwarded-for'];
}
if (is.ip(req.headers.forwarded)) {
return req.headers.forwarded;
}
} // Remote address checks.
if (is.existy(req.connection)) {
if (is.ip(req.connection.remoteAddress)) {
return req.connection.remoteAddress;
}
if (is.existy(req.connection.socket) && is.ip(req.connection.socket.remoteAddress)) {
return req.connection.socket.remoteAddress;
}
}
if (is.existy(req.socket) && is.ip(req.socket.remoteAddress)) {
return req.socket.remoteAddress;
}
if (is.existy(req.info) && is.ip(req.info.remoteAddress)) {
return req.info.remoteAddress;
} // AWS Api Gateway + Lambda
if (is.existy(req.requestContext) && is.existy(req.requestContext.identity) && is.ip(req.requestContext.identity.sourceIp)) {
return req.requestContext.identity.sourceIp;
}
return null;
}
/**
* Expose request IP as a middleware.
*
* @param {object} [options] - Configuration.
* @param {string} [options.attributeName] - Name of attribute to augment request object with.
* @return {*}
*/
function mw(options) {
// Defaults.
var configuration = is.not.existy(options) ? {} : options; // Validation.
if (is.not.object(configuration)) {
throw new TypeError('Options must be an object!');
}
var attributeName = configuration.attributeName || 'clientIp';
return function (req, res, next) {
var ip = getClientIp(req);
Object.defineProperty(req, attributeName, {
get: function get() {
return ip;
},
configurable: true
});
next();
};
}
module.exports = {
getClientIpFromXForwardedFor: getClientIpFromXForwardedFor,
getClientIp: getClientIp,
mw: mw
};

File diff suppressed because one or more lines are too long

View File

@ -1,11 +1,16 @@
const Path = require('path')
const date = require('date-and-time')
const serverVersion = require('../../package.json').version
const { PlayMethod } = require('../utils/constants')
const PlaybackSession = require('../objects/PlaybackSession')
const DeviceInfo = require('../objects/DeviceInfo')
const Stream = require('../objects/Stream')
const Logger = require('../Logger')
const fs = require('fs-extra')
const uaParserJs = require('../libs/uaParserJs')
const requestIp = require('../libs/requestIp')
class PlaybackSessionManager {
constructor(db, emitter, clientEmitter) {
this.db = db
@ -27,8 +32,21 @@ class PlaybackSessionManager {
return session ? session.stream : null
}
async startSessionRequest(user, libraryItem, episodeId, options, res) {
const session = await this.startSession(user, libraryItem, episodeId, options)
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 deviceInfo = new DeviceInfo()
deviceInfo.setData(ip, ua, clientDeviceInfo, serverVersion)
return deviceInfo
}
async startSessionRequest(req, res, episodeId) {
const deviceInfo = this.getDeviceInfo(req)
const { user, libraryItem, body: options } = req
const session = await this.startSession(user, deviceInfo, libraryItem, episodeId, options)
res.json(session.toJSONForClient(libraryItem))
}
@ -84,7 +102,7 @@ class PlaybackSessionManager {
res.sendStatus(200)
}
async startSession(user, libraryItem, episodeId, options) {
async startSession(user, deviceInfo, libraryItem, episodeId, options) {
// Close any sessions already open for user
var userSessions = this.sessions.filter(playbackSession => playbackSession.userId === user.id)
for (const session of userSessions) {
@ -99,7 +117,7 @@ class PlaybackSessionManager {
var userStartTime = 0
if (userProgress) userStartTime = Number.parseFloat(userProgress.currentTime) || 0
const newPlaybackSession = new PlaybackSession()
newPlaybackSession.setData(libraryItem, user, mediaPlayer, episodeId)
newPlaybackSession.setData(libraryItem, user, mediaPlayer, deviceInfo, userStartTime, episodeId)
var audioTracks = []
if (shouldDirectPlay) {
@ -122,7 +140,6 @@ class PlaybackSessionManager {
})
}
newPlaybackSession.currentTime = userStartTime
newPlaybackSession.audioTracks = audioTracks
// Will save on the first sync

View File

@ -0,0 +1,74 @@
class DeviceInfo {
constructor(deviceInfo = null) {
this.ipAddress = null
// From User Agent (see: https://www.npmjs.com/package/ua-parser-js)
this.browserName = null
this.browserVersion = null
this.osName = null
this.osVersion = null
this.deviceType = null
// From client
this.clientVersion = null
this.manufacturer = null
this.model = null
this.sdkVersion = null // Android Only
this.serverVersion = null
if (deviceInfo) {
this.construct(deviceInfo)
}
}
construct(deviceInfo) {
for (const key in deviceInfo) {
if (deviceInfo[key] !== undefined && this[key] !== undefined) {
this[key] = deviceInfo[key]
}
}
}
toJSON() {
const obj = {
ipAddress: this.ipAddress,
browserName: this.browserName,
browserVersion: this.browserVersion,
osName: this.osName,
osVersion: this.osVersion,
deviceType: this.deviceType,
clientVersion: this.clientVersion,
manufacturer: this.manufacturer,
model: this.model,
sdkVersion: this.sdkVersion,
serverVersion: this.serverVersion
}
for (const key in obj) {
if (obj[key] === null || obj[key] === undefined) {
delete obj[key]
}
}
return obj
}
setData(ip, ua, clientDeviceInfo, serverVersion) {
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
var cdi = clientDeviceInfo || {}
this.clientVersion = cdi.clientVersion || null
this.manufacturer = cdi.manufacturer || null
this.model = cdi.model || null
this.sdkVersion = cdi.sdkVersion || null
this.serverVersion = serverVersion || null
}
}
module.exports = DeviceInfo

View File

@ -3,6 +3,7 @@ const { getId } = require('../utils/index')
const { PlayMethod } = require('../utils/constants')
const BookMetadata = require('./metadata/BookMetadata')
const PodcastMetadata = require('./metadata/PodcastMetadata')
const DeviceInfo = require('./DeviceInfo')
class PlaybackSession {
constructor(session) {
@ -21,18 +22,21 @@ class PlaybackSession {
this.playMethod = null
this.mediaPlayer = null
this.deviceInfo = null
this.date = null
this.dayOfWeek = null
this.timeListening = null
this.startTime = null // media current time at start of playback
this.currentTime = 0 // Last current time set
this.startedAt = null
this.updatedAt = null
// Not saved in DB
this.lastSave = 0
this.audioTracks = []
this.currentTime = 0
this.stream = null
if (session) {
@ -56,10 +60,13 @@ class PlaybackSession {
duration: this.duration,
playMethod: this.playMethod,
mediaPlayer: this.mediaPlayer,
deviceInfo: this.deviceInfo ? this.deviceInfo.toJSON() : null,
date: this.date,
dayOfWeek: this.dayOfWeek,
timeListening: this.timeListening,
lastUpdate: this.lastUpdate,
startTime: this.startTime,
currentTime: this.currentTime,
startedAt: this.startedAt,
updatedAt: this.updatedAt
}
}
@ -80,13 +87,15 @@ class PlaybackSession {
duration: this.duration,
playMethod: this.playMethod,
mediaPlayer: this.mediaPlayer,
deviceInfo: this.deviceInfo ? this.deviceInfo.toJSON() : null,
date: this.date,
dayOfWeek: this.dayOfWeek,
timeListening: this.timeListening,
lastUpdate: this.lastUpdate,
startTime: this.startTime,
currentTime: this.currentTime,
startedAt: this.startedAt,
updatedAt: this.updatedAt,
audioTracks: this.audioTracks.map(at => at.toJSON()),
currentTime: this.currentTime,
libraryItem: libraryItem.toJSONExpanded()
}
}
@ -101,6 +110,7 @@ class PlaybackSession {
this.duration = session.duration
this.playMethod = session.playMethod
this.mediaPlayer = session.mediaPlayer || null
this.deviceInfo = new DeviceInfo(session.deviceInfo)
this.chapters = session.chapters || []
this.mediaMetadata = null
@ -118,6 +128,9 @@ class PlaybackSession {
this.dayOfWeek = session.dayOfWeek
this.timeListening = session.timeListening || null
this.startTime = session.startTime || 0
this.currentTime = session.currentTime || 0
this.startedAt = session.startedAt
this.updatedAt = session.updatedAt || null
}
@ -127,7 +140,7 @@ class PlaybackSession {
return Math.max(0, Math.min(this.currentTime / this.duration, 1))
}
setData(libraryItem, user, mediaPlayer, episodeId = null) {
setData(libraryItem, user, mediaPlayer, deviceInfo, startTime, episodeId = null) {
this.id = getId('play')
this.userId = user.id
this.libraryItemId = libraryItem.id
@ -146,8 +159,13 @@ class PlaybackSession {
}
this.mediaPlayer = mediaPlayer
this.deviceInfo = deviceInfo || new DeviceInfo()
this.timeListening = 0
this.startTime = startTime
this.currentTime = startTime
this.date = date.format(new Date(), 'YYYY-MM-DD')
this.dayOfWeek = date.format(new Date(), 'dddd')
this.startedAt = Date.now()