@@ -36,6 +36,10 @@ export default {
contentMarginTop: {
type: Number,
default: 50
+ },
+ zIndex: {
+ type: Number,
+ default: 40
}
},
data() {
diff --git a/client/package.json b/client/package.json
index f63a0742..693a63cb 100644
--- a/client/package.json
+++ b/client/package.json
@@ -1,6 +1,6 @@
{
"name": "audiobookshelf-client",
- "version": "1.6.15",
+ "version": "1.6.16",
"description": "Audiobook manager and player",
"main": "index.js",
"scripts": {
diff --git a/client/pages/config/users/_id.vue b/client/pages/config/users/_id.vue
index f4192eca..b7ad421d 100644
--- a/client/pages/config/users/_id.vue
+++ b/client/pages/config/users/_id.vue
@@ -13,6 +13,23 @@
{{ username }}
+
+
+
Listening Stats (web app only)
+
+ Total Time Listened:
+ {{ listeningTimePretty }}
+
+
+ Time Listened Today:
+ {{ $elapsedPrettyExtended(timeListenedToday) }}
+
+
+
+
Last Listening Session (web app only)
+
{{ latestSession.audiobookTitle }} {{ $dateDistanceFromNow(latestSession.lastUpdate) }} for {{ $elapsedPrettyExtended(this.latestSession.timeListening) }}
+
+
Reading Progress
@@ -64,9 +81,15 @@ export default {
}
},
data() {
- return {}
+ return {
+ listeningSessions: [],
+ listeningStats: {}
+ }
},
computed: {
+ showExperimentalFeatures() {
+ return this.$store.state.showExperimentalFeatures
+ },
username() {
return this.user.username
},
@@ -75,10 +98,37 @@ export default {
},
userAudiobooks() {
return Object.values(this.user.audiobooks || {}).sort((a, b) => b.lastUpdate - a.lastUpdate)
+ },
+ totalListeningTime() {
+ return this.listeningStats.totalTime || 0
+ },
+ listeningTimePretty() {
+ return this.$elapsedPrettyExtended(this.totalListeningTime)
+ },
+ timeListenedToday() {
+ return this.listeningStats.today || 0
+ },
+ latestSession() {
+ if (!this.listeningSessions.length) return null
+ return this.listeningSessions[0]
}
},
- methods: {},
- mounted() {}
+ methods: {
+ async init() {
+ this.listeningSessions = await this.$axios.$get(`/api/user/${this.user.id}/listeningSessions`).catch((err) => {
+ console.error('Failed to load listening sesions', err)
+ return []
+ })
+ this.listeningStats = await this.$axios.$get(`/api/user/${this.user.id}/listeningStats`).catch((err) => {
+ console.error('Failed to load listening sesions', err)
+ return []
+ })
+ console.log('Loaded user listening data', this.listeningSessions, this.listeningStats)
+ }
+ },
+ mounted() {
+ this.init()
+ }
}
diff --git a/client/plugins/init.client.js b/client/plugins/init.client.js
index d3932e1e..7ecda99f 100644
--- a/client/plugins/init.client.js
+++ b/client/plugins/init.client.js
@@ -54,6 +54,22 @@ Vue.prototype.$secondsToTimestamp = (seconds) => {
return `${_hours}:${_minutes.toString().padStart(2, '0')}:${_seconds.toString().padStart(2, '0')}`
}
+Vue.prototype.$elapsedPrettyExtended = (seconds) => {
+ var minutes = Math.floor(seconds / 60)
+ seconds -= minutes * 60
+ var hours = Math.floor(minutes / 60)
+ minutes -= hours * 60
+ var days = Math.floor(hours / 24)
+ hours -= days * 24
+
+ var strs = []
+ if (days) strs.push(`${days}d`)
+ if (hours) strs.push(`${hours}h`)
+ if (minutes) strs.push(`${minutes}m`)
+ if (seconds) strs.push(`${seconds}s`)
+ return strs.join(' ')
+}
+
Vue.prototype.$calculateTextSize = (text, styles = {}) => {
const el = document.createElement('p')
diff --git a/package.json b/package.json
index 8f6f7271..cf3b49b4 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "audiobookshelf",
- "version": "1.6.15",
+ "version": "1.6.16",
"description": "Self-hosted audiobook server for managing and playing audiobooks",
"main": "index.js",
"scripts": {
diff --git a/server/ApiController.js b/server/ApiController.js
index bfdd2f6a..8de1aefe 100644
--- a/server/ApiController.js
+++ b/server/ApiController.js
@@ -1,6 +1,7 @@
const express = require('express')
const Path = require('path')
const fs = require('fs-extra')
+const date = require('date-and-time')
const Logger = require('./Logger')
const { isObject } = require('./utils/index')
@@ -74,6 +75,8 @@ class ApiController {
this.router.get('/users', this.getUsers.bind(this))
this.router.post('/user', this.createUser.bind(this))
this.router.get('/user/:id', this.getUser.bind(this))
+ this.router.get('/user/:id/listeningSessions', this.getUserListeningSessions.bind(this))
+ this.router.get('/user/:id/listeningStats', this.getUserListeningStats.bind(this))
this.router.patch('/user/:id', this.updateUser.bind(this))
this.router.delete('/user/:id', this.deleteUser.bind(this))
@@ -99,6 +102,9 @@ class ApiController {
this.router.get('/filesystem', this.getFileSystemPaths.bind(this))
this.router.get('/scantracks/:id', this.scanAudioTrackNums.bind(this))
+
+ this.router.get('/listeningSessions', this.getCurrentUserListeningSessions.bind(this))
+ this.router.get('/listeningStats', this.getCurrentUserListeningStats.bind(this))
}
async find(req, res) {
@@ -1026,5 +1032,75 @@ class ApiController {
var scandata = await audioFileScanner.scanTrackNumbers(audiobook)
res.json(scandata)
}
+
+ async getUserListeningSessionsHelper(userId) {
+ var userSessions = await this.db.selectUserSessions(userId)
+ var listeningSessions = userSessions.filter(us => us.sessionType === 'listeningSession')
+ return listeningSessions.sort((a, b) => b.lastUpdate - a.lastUpdate)
+ }
+
+ async getUserListeningSessions(req, res) {
+ if (!req.user || !req.user.isRoot) {
+ return res.sendStatus(403)
+ }
+ var listeningSessions = await this.getUserListeningSessionsHelper(req.params.id)
+ res.json(listeningSessions.slice(0, 10))
+ }
+
+ async getCurrentUserListeningSessions(req, res) {
+ if (!req.user) {
+ return res.sendStatus(500)
+ }
+ var listeningSessions = await this.getUserListeningSessionsHelper(req.user.id)
+ res.json(listeningSessions.slice(0, 10))
+ }
+
+ async getUserListeningStatsHelpers(userId) {
+ const today = date.format(new Date(), 'YYYY-MM-DD')
+
+ var listeningSessions = await this.getUserListeningSessionsHelper(userId)
+ var listeningStats = {
+ totalTime: 0,
+ books: {},
+ days: {},
+ dayOfWeek: {},
+ today: 0
+ }
+ listeningSessions.forEach((s) => {
+ if (s.dayOfWeek) {
+ if (!listeningStats.dayOfWeek[s.dayOfWeek]) listeningStats.dayOfWeek[s.dayOfWeek] = 0
+ listeningStats.dayOfWeek[s.dayOfWeek] += s.timeListening
+ }
+ if (s.date) {
+ if (!listeningStats.days[s.date]) listeningStats.days[s.date] = 0
+ listeningStats.days[s.date] += s.timeListening
+
+ if (s.date === today) {
+ listeningStats.today += s.timeListening
+ }
+ }
+ if (!listeningStats.books[s.audiobookId]) listeningStats.books[s.audiobookId] = 0
+ listeningStats.books[s.audiobookId] += s.timeListening
+
+ listeningStats.totalTime += s.timeListening
+ })
+ return listeningStats
+ }
+
+ async getUserListeningStats(req, res) {
+ if (!req.user || !req.user.isRoot) {
+ return res.sendStatus(403)
+ }
+ var listeningStats = await this.getUserListeningStatsHelpers(req.params.id)
+ res.json(listeningStats)
+ }
+
+ async getCurrentUserListeningStats(req, res) {
+ if (!req.user) {
+ return res.sendStatus(500)
+ }
+ var listeningStats = await this.getUserListeningStatsHelpers(req.user.id)
+ res.json(listeningStats)
+ }
}
module.exports = ApiController
\ No newline at end of file
diff --git a/server/Auth.js b/server/Auth.js
index 88aee8dd..faf02218 100644
--- a/server/Auth.js
+++ b/server/Auth.js
@@ -83,6 +83,10 @@ class Auth {
return jwt.sign(payload, process.env.TOKEN_SECRET);
}
+ authenticateUser(token) {
+ return this.verifyToken(token)
+ }
+
verifyToken(token) {
return new Promise((resolve) => {
jwt.verify(token, process.env.TOKEN_SECRET, (err, payload) => {
diff --git a/server/BackupManager.js b/server/BackupManager.js
index 0d032408..b59ffe01 100644
--- a/server/BackupManager.js
+++ b/server/BackupManager.js
@@ -206,7 +206,7 @@ class BackupManager {
}
newBackup.setData(newBackData)
- var zipResult = await this.zipBackup(this.db.ConfigPath, metadataBooksPath, newBackup).then(() => true).catch((error) => {
+ var zipResult = await this.zipBackup(metadataBooksPath, newBackup).then(() => true).catch((error) => {
Logger.error(`[BackupManager] Backup Failed ${error}`)
return false
})
@@ -246,7 +246,7 @@ class BackupManager {
}
}
- zipBackup(configPath, metadataBooksPath, backup) {
+ zipBackup(metadataBooksPath, backup) {
return new Promise((resolve, reject) => {
// create a file to stream archive data to
const output = fs.createWriteStream(backup.fullPath)
@@ -307,17 +307,12 @@ class BackupManager {
// pipe archive data to the file
archive.pipe(output)
- var audiobooksDbDir = Path.join(configPath, 'audiobooks')
- var librariesDbDir = Path.join(configPath, 'libraries')
- var settingsDbDir = Path.join(configPath, 'settings')
- var usersDbDir = Path.join(configPath, 'users')
- var collectionsDbDir = Path.join(configPath, 'collections')
-
- archive.directory(audiobooksDbDir, 'config/audiobooks')
- archive.directory(librariesDbDir, 'config/libraries')
- archive.directory(settingsDbDir, 'config/settings')
- archive.directory(usersDbDir, 'config/users')
- archive.directory(collectionsDbDir, 'config/collections')
+ archive.directory(this.db.AudiobooksPath, 'config/audiobooks')
+ archive.directory(this.db.LibrariesPath, 'config/libraries')
+ archive.directory(this.db.SettingsPath, 'config/settings')
+ archive.directory(this.db.UsersPath, 'config/users')
+ archive.directory(this.db.SessionsPath, 'config/sessions')
+ archive.directory(this.db.CollectionsPath, 'config/collections')
if (metadataBooksPath) {
Logger.debug(`[BackupManager] Backing up Metadata Books "${metadataBooksPath}"`)
diff --git a/server/Db.js b/server/Db.js
index a7651c73..4b5cc66b 100644
--- a/server/Db.js
+++ b/server/Db.js
@@ -13,19 +13,23 @@ class Db {
constructor(ConfigPath, AudiobookPath) {
this.ConfigPath = ConfigPath
this.AudiobookPath = AudiobookPath
+
this.AudiobooksPath = Path.join(ConfigPath, 'audiobooks')
this.UsersPath = Path.join(ConfigPath, 'users')
+ this.SessionsPath = Path.join(ConfigPath, 'sessions')
this.LibrariesPath = Path.join(ConfigPath, 'libraries')
this.SettingsPath = Path.join(ConfigPath, 'settings')
this.CollectionsPath = Path.join(ConfigPath, 'collections')
this.audiobooksDb = new njodb.Database(this.AudiobooksPath)
this.usersDb = new njodb.Database(this.UsersPath)
+ this.sessionsDb = new njodb.Database(this.SessionsPath)
this.librariesDb = new njodb.Database(this.LibrariesPath, { datastores: 2 })
this.settingsDb = new njodb.Database(this.SettingsPath, { datastores: 2 })
this.collectionsDb = new njodb.Database(this.CollectionsPath, { datastores: 2 })
this.users = []
+ this.sessions = []
this.libraries = []
this.audiobooks = []
this.settings = []
@@ -36,6 +40,7 @@ class Db {
getEntityDb(entityName) {
if (entityName === 'user') return this.usersDb
+ else if (entityName === 'session') return this.sessionsDb
else if (entityName === 'audiobook') return this.audiobooksDb
else if (entityName === 'library') return this.librariesDb
else if (entityName === 'settings') return this.settingsDb
@@ -45,6 +50,7 @@ class Db {
getEntityArrayKey(entityName) {
if (entityName === 'user') return 'users'
+ else if (entityName === 'session') return 'sessions'
else if (entityName === 'audiobook') return 'audiobooks'
else if (entityName === 'library') return 'libraries'
else if (entityName === 'settings') return 'settings'
@@ -82,6 +88,7 @@ class Db {
reinit() {
this.audiobooksDb = new njodb.Database(this.AudiobooksPath)
this.usersDb = new njodb.Database(this.UsersPath)
+ this.sessionsDb = new njodb.Database(this.SessionsPath)
this.librariesDb = new njodb.Database(this.LibrariesPath, { datastores: 2 })
this.settingsDb = new njodb.Database(this.SettingsPath, { datastores: 2 })
this.collectionsDb = new njodb.Database(this.CollectionsPath, { datastores: 2 })
@@ -188,8 +195,6 @@ class Db {
var jsonEntity = entity
if (entity && entity.toJSON) {
jsonEntity = entity.toJSON()
- } else {
- console.log('Entity has no json', jsonEntity)
}
return entityDb.update((record) => record.id === entity.id, () => jsonEntity).then((results) => {
@@ -229,5 +234,14 @@ class Db {
return false
})
}
+
+ selectUserSessions(userId) {
+ return this.sessionsDb.select((session) => session.userId === userId).then((results) => {
+ return results.data || []
+ }).catch((error) => {
+ Logger.error(`[Db] Failed to select user sessions "${userId}"`, error)
+ return []
+ })
+ }
}
module.exports = Db
diff --git a/server/Server.js b/server/Server.js
index 165246ad..f7e31556 100644
--- a/server/Server.js
+++ b/server/Server.js
@@ -186,11 +186,13 @@ class Server {
res.sendFile(fullPath)
})
- // Client routes
+ // Client dynamic routes
app.get('/audiobook/:id', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
app.get('/audiobook/:id/edit', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
app.get('/library/:library', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
app.get('/library/:library/bookshelf/:id?', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
+ app.get('/config/users/:id', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
+ app.get('/collection/:id', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
app.use('/api', this.authMiddleware.bind(this), this.apiController.router)
app.use('/hls', this.authMiddleware.bind(this), this.hlsController.router)
@@ -252,6 +254,7 @@ class Server {
socket.on('open_stream', (audiobookId) => this.streamManager.openStreamSocketRequest(socket, audiobookId))
socket.on('close_stream', () => this.streamManager.closeStreamRequest(socket))
socket.on('stream_update', (payload) => this.streamManager.streamUpdate(socket, payload))
+ socket.on('stream_sync', (syncData) => this.streamManager.streamSync(socket, syncData))
socket.on('progress_update', (payload) => this.audiobookProgressUpdate(socket, payload))
@@ -569,7 +572,7 @@ class Server {
}
async authenticateSocket(socket, token) {
- var user = await this.auth.verifyToken(token)
+ var user = await this.auth.authenticateUser(token)
if (!user) {
Logger.error('Cannot validate socket - invalid token')
return socket.emit('invalid_token')
diff --git a/server/StreamManager.js b/server/StreamManager.js
index 8a2ab719..554cfdee 100644
--- a/server/StreamManager.js
+++ b/server/StreamManager.js
@@ -151,6 +151,45 @@ class StreamManager {
this.emitter('user_stream_update', client.user.toJSONForPublic(this.streams))
}
+ streamSync(socket, syncData) {
+ const client = socket.sheepClient
+ if (!client || !client.stream) {
+ Logger.error('[StreamManager] streamSync: No stream for client', (client && client.user) ? client.user.id : 'No Client')
+ return
+ }
+ if (client.stream.id !== syncData.streamId) {
+ Logger.error('[StreamManager] streamSync: Stream id mismatch on stream update', syncData.streamId, client.stream.id)
+ return
+ }
+ if (!client.user) {
+ Logger.error('[StreamManager] streamSync: No User for client', client)
+ return
+ }
+ // const { timeListened, currentTime, streamId } = syncData
+ var listeningSession = client.stream.syncStream(syncData)
+
+ if (listeningSession && listeningSession.timeListening > 0) {
+ // Save listening session
+ var existingListeningSession = this.db.sessions.find(s => s.id === listeningSession.id)
+ if (existingListeningSession) {
+ this.db.updateEntity('session', listeningSession)
+ } else {
+ this.db.sessions.push(listeningSession.toJSON()) // Insert right away to prevent duplicate session
+ this.db.insertEntity('session', listeningSession)
+ }
+ }
+
+ var userAudiobook = client.user.updateAudiobookProgressFromStream(client.stream)
+ this.db.updateEntity('user', client.user)
+
+ if (userAudiobook) {
+ this.clientEmitter(client.user.id, 'current_user_audiobook_update', {
+ id: userAudiobook.audiobookId,
+ data: userAudiobook.toJSON()
+ })
+ }
+ }
+
streamUpdate(socket, { currentTime, streamId }) {
var client = socket.sheepClient
if (!client || !client.stream) {
diff --git a/server/objects/Stream.js b/server/objects/Stream.js
index decb564e..be2f4a30 100644
--- a/server/objects/Stream.js
+++ b/server/objects/Stream.js
@@ -7,7 +7,7 @@ const { secondsToTimestamp } = require('../utils/fileUtils')
const { writeConcatFile } = require('../utils/ffmpegHelpers')
const hlsPlaylistGenerator = require('../utils/hlsPlaylistGenerator')
-// const UserListeningSession = require('./UserListeningSession')
+const UserListeningSession = require('./UserListeningSession')
class Stream extends EventEmitter {
constructor(streamPath, client, audiobook) {
@@ -34,8 +34,8 @@ class Stream extends EventEmitter {
this.furthestSegmentCreated = 0
this.clientCurrentTime = 0
- // this.listeningSession = new UserListeningSession()
- // this.listeningSession.setData(audiobook, client.user)
+ this.listeningSession = new UserListeningSession()
+ this.listeningSession.setData(audiobook, client.user)
this.init()
}
@@ -163,6 +163,35 @@ class Stream extends EventEmitter {
this.clientCurrentTime = currentTime
}
+ syncStream({ timeListened, currentTime }) {
+ var syncLog = ''
+ if (currentTime !== null && !isNaN(currentTime)) {
+ syncLog = `Update client current time ${secondsToTimestamp(currentTime)}`
+ this.clientCurrentTime = currentTime
+ }
+ var saveListeningSession = false
+ if (timeListened && !isNaN(timeListened)) {
+
+ // Check if listening session should roll to next day
+ if (this.listeningSession.checkDateRollover()) {
+ if (!this.clientUser) {
+ Logger.error(`[Stream] Sync stream invalid client user`)
+ return null
+ }
+ this.listeningSession = new UserListeningSession()
+ this.listeningSession.setData(this.audiobook, this.clientUser)
+ Logger.debug(`[Stream] Listening session rolled to next day`)
+ }
+
+ this.listeningSession.addListeningTime(timeListened)
+ if (syncLog) syncLog += ' | '
+ syncLog += `Add listening time ${timeListened}s, Total time listened ${this.listeningSession.timeListening}s`
+ saveListeningSession = true
+ }
+ Logger.debug('[Stream]', syncLog)
+ return saveListeningSession ? this.listeningSession : null
+ }
+
async generatePlaylist() {
fs.ensureDirSync(this.streamPath)
await hlsPlaylistGenerator(this.playlistPath, 'output', this.totalDuration, this.segmentLength, this.hlsSegmentType)
diff --git a/server/objects/UserListeningSession.js b/server/objects/UserListeningSession.js
index 2cb2e39b..ecd66edd 100644
--- a/server/objects/UserListeningSession.js
+++ b/server/objects/UserListeningSession.js
@@ -1,16 +1,22 @@
const Logger = require('../Logger')
+const date = require('date-and-time')
class UserListeningSession {
constructor(session) {
+ this.id = null
+ this.sessionType = 'listeningSession'
this.userId = null
this.audiobookId = null
this.audiobookTitle = null
this.audiobookAuthor = null
+ this.audiobookGenres = []
+
+ this.date = null
+ this.dayOfWeek = null
this.timeListening = null
this.lastUpdate = null
this.startedAt = null
- this.finishedAt = null
if (session) {
this.construct(session)
@@ -19,39 +25,68 @@ class UserListeningSession {
toJSON() {
return {
+ id: this.id,
+ sessionType: this.sessionType,
userId: this.userId,
audiobookId: this.audiobookId,
audiobookTitle: this.audiobookTitle,
audiobookAuthor: this.audiobookAuthor,
+ audiobookGenres: [...this.audiobookGenres],
+ date: this.date,
+ dayOfWeek: this.dayOfWeek,
timeListening: this.timeListening,
lastUpdate: this.lastUpdate,
- startedAt: this.startedAt,
- finishedAt: this.finishedAt
+ startedAt: this.startedAt
}
}
construct(session) {
+ this.id = session.id
+ this.sessionType = session.sessionType
this.userId = session.userId
this.audiobookId = session.audiobookId
this.audiobookTitle = session.audiobookTitle
this.audiobookAuthor = session.audiobookAuthor
+ this.audiobookGenres = session.audiobookGenres
+
+ this.date = session.date
+ this.dayOfWeek = session.dayOfWeek
this.timeListening = session.timeListening || null
this.lastUpdate = session.lastUpdate || null
this.startedAt = session.startedAt
- this.finishedAt = session.finishedAt || null
}
setData(audiobook, user) {
+ this.id = 'ls_' + (Math.trunc(Math.random() * 1000) + Date.now()).toString(36)
this.userId = user.id
this.audiobookId = audiobook.id
this.audiobookTitle = audiobook.title || ''
- this.audiobookAuthor = audiobook.author || ''
+ this.audiobookAuthor = audiobook.authorFL || ''
+ this.audiobookGenres = [...audiobook.genres]
this.timeListening = 0
this.lastUpdate = Date.now()
this.startedAt = Date.now()
- this.finishedAt = null
+ }
+
+ addListeningTime(timeListened) {
+ if (timeListened && !isNaN(timeListened)) {
+ if (!this.date) {
+ // Set date info on first listening update
+ this.date = date.format(new Date(), 'YYYY-MM-DD')
+ this.dayOfWeek = date.format(new Date(), 'dddd')
+ }
+
+ this.timeListening += timeListened
+ this.lastUpdate = Date.now()
+ }
+ }
+
+ // New date since start of listening session
+ checkDateRollover() {
+ if (!this.date) return false
+ return date.format(new Date(), 'YYYY-MM-DD') !== this.date
}
}
module.exports = UserListeningSession
\ No newline at end of file