mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-01-15 18:38:56 +01:00
Add: User listening sessions and user listening stats #167
This commit is contained in:
parent
663d02e9fe
commit
91e44bc2f9
@ -74,7 +74,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<audio ref="audio" @progress="progress" @timeupdate="timeupdate" @loadeddata="audioLoadedData" @play="audioPlayed" @pause="audioPaused" @error="audioError" @ended="audioEnded" @stalled="audioStalled" @suspend="audioSuspended" />
|
<audio ref="audio" @progress="progress" @timeupdate="timeupdate" @loadedmetadata="audioLoadedMetadata" @loadeddata="audioLoadedData" @play="audioPlayed" @pause="audioPaused" @error="audioError" @ended="audioEnded" @stalled="audioStalled" @suspend="audioSuspended" />
|
||||||
|
|
||||||
<modals-chapters-modal v-model="showChaptersModal" :current-chapter="currentChapter" :chapters="chapters" @select="selectChapter" />
|
<modals-chapters-modal v-model="showChaptersModal" :current-chapter="currentChapter" :chapters="chapters" @select="selectChapter" />
|
||||||
</div>
|
</div>
|
||||||
@ -85,6 +85,8 @@ import Hls from 'hls.js'
|
|||||||
|
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
|
streamId: String,
|
||||||
|
audiobookId: String,
|
||||||
loading: Boolean,
|
loading: Boolean,
|
||||||
chapters: {
|
chapters: {
|
||||||
type: Array,
|
type: Array,
|
||||||
@ -115,7 +117,9 @@ export default {
|
|||||||
showChaptersModal: false,
|
showChaptersModal: false,
|
||||||
currentTime: 0,
|
currentTime: 0,
|
||||||
trackOffsetLeft: 16, // Track is 16px from edge
|
trackOffsetLeft: 16, // Track is 16px from edge
|
||||||
playStartTime: 0
|
listenTimeInterval: null,
|
||||||
|
listeningTimeSinceLastUpdate: 0,
|
||||||
|
totalListeningTimeInSession: 0
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@ -130,6 +134,10 @@ export default {
|
|||||||
return this.totalDuration - this.currentTime
|
return this.totalDuration - this.currentTime
|
||||||
},
|
},
|
||||||
timeRemainingPretty() {
|
timeRemainingPretty() {
|
||||||
|
if (this.timeRemaining < 0) {
|
||||||
|
console.warn('Time remaining < 0', this.totalDuration, this.currentTime, this.timeRemaining)
|
||||||
|
return this.$secondsToTimestamp(this.timeRemaining * -1)
|
||||||
|
}
|
||||||
return '-' + this.$secondsToTimestamp(this.timeRemaining)
|
return '-' + this.$secondsToTimestamp(this.timeRemaining)
|
||||||
},
|
},
|
||||||
progressPercent() {
|
progressPercent() {
|
||||||
@ -155,14 +163,19 @@ export default {
|
|||||||
methods: {
|
methods: {
|
||||||
audioPlayed() {
|
audioPlayed() {
|
||||||
if (!this.$refs.audio) return
|
if (!this.$refs.audio) return
|
||||||
// console.log('Audio Played', this.$refs.audio.paused, this.$refs.audio.currentTime)
|
console.log('Audio Played', this.$refs.audio.currentTime, 'Total Duration', this.$refs.audio.duration)
|
||||||
this.playStartTime = Date.now()
|
// setTimeout(() => {
|
||||||
|
// console.log('Audio Played FOLLOW UP', this.$refs.audio.currentTime, 'Total Duration', this.$refs.audio.duration)
|
||||||
|
// this.startListenTimeInterval()
|
||||||
|
// }, 500)
|
||||||
|
this.startListenTimeInterval()
|
||||||
this.isPaused = this.$refs.audio.paused
|
this.isPaused = this.$refs.audio.paused
|
||||||
},
|
},
|
||||||
audioPaused() {
|
audioPaused() {
|
||||||
if (!this.$refs.audio) return
|
if (!this.$refs.audio) return
|
||||||
// console.log('Audio Paused', this.$refs.audio.paused, this.$refs.audio.currentTime)
|
// console.log('Audio Paused', this.$refs.audio.paused, this.$refs.audio.currentTime)
|
||||||
this.isPaused = this.$refs.audio.paused
|
this.isPaused = this.$refs.audio.paused
|
||||||
|
this.cancelListenTimeInterval()
|
||||||
},
|
},
|
||||||
audioError(err) {
|
audioError(err) {
|
||||||
if (!this.$refs.audio) return
|
if (!this.$refs.audio) return
|
||||||
@ -180,6 +193,77 @@ export default {
|
|||||||
if (!this.$refs.audio) return
|
if (!this.$refs.audio) return
|
||||||
console.warn('Audio Suspended', this.$refs.audio.paused, this.$refs.audio.currentTime)
|
console.warn('Audio Suspended', this.$refs.audio.paused, this.$refs.audio.currentTime)
|
||||||
},
|
},
|
||||||
|
sendStreamSync(timeListened = 0) {
|
||||||
|
// If currentTime is null then currentTime wont be updated
|
||||||
|
var currentTime = null
|
||||||
|
if (this.$refs.audio) {
|
||||||
|
currentTime = this.$refs.audio.currentTime
|
||||||
|
} else if (!timeListened) {
|
||||||
|
console.warn('Not sending stream sync, no data to sync')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var syncData = {
|
||||||
|
timeListened,
|
||||||
|
currentTime,
|
||||||
|
streamId: this.streamId,
|
||||||
|
audiobookId: this.audiobookId
|
||||||
|
}
|
||||||
|
this.$emit('sync', syncData)
|
||||||
|
},
|
||||||
|
sendAddListeningTime() {
|
||||||
|
var listeningTimeToAdd = Math.floor(this.listeningTimeSinceLastUpdate)
|
||||||
|
this.listeningTimeSinceLastUpdate = Math.max(0, this.listeningTimeSinceLastUpdate - listeningTimeToAdd)
|
||||||
|
this.sendStreamSync(listeningTimeToAdd)
|
||||||
|
},
|
||||||
|
cancelListenTimeInterval() {
|
||||||
|
this.sendAddListeningTime()
|
||||||
|
clearInterval(this.listenTimeInterval)
|
||||||
|
this.listenTimeInterval = null
|
||||||
|
},
|
||||||
|
startListenTimeInterval() {
|
||||||
|
if (!this.$refs.audio) return
|
||||||
|
|
||||||
|
clearInterval(this.listenTimeInterval)
|
||||||
|
var lastTime = this.$refs.audio.currentTime
|
||||||
|
var lastTick = Date.now()
|
||||||
|
this.listenTimeInterval = setInterval(() => {
|
||||||
|
if (!this.$refs.audio) {
|
||||||
|
console.error('Canceling audio played interval no audio player')
|
||||||
|
this.cancelListenTimeInterval()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (this.$refs.audio.paused) {
|
||||||
|
console.warn('Canceling audio played interval audio player paused')
|
||||||
|
this.cancelListenTimeInterval()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var timeSinceLastTick = Date.now() - lastTick
|
||||||
|
lastTick = Date.now()
|
||||||
|
|
||||||
|
var expectedAudioTime = lastTime + timeSinceLastTick / 1000
|
||||||
|
var currentTime = this.$refs.audio.currentTime
|
||||||
|
var differenceFromExpected = expectedAudioTime - currentTime
|
||||||
|
if (currentTime === lastTime) {
|
||||||
|
console.error('Audio current time has not increased - cancel interval and pause player')
|
||||||
|
this.cancelListenTimeInterval()
|
||||||
|
this.pause()
|
||||||
|
} else if (Math.abs(differenceFromExpected) > 0.1) {
|
||||||
|
console.warn('Invalid time between interval - resync last', differenceFromExpected)
|
||||||
|
lastTime = currentTime
|
||||||
|
} else {
|
||||||
|
var exactPlayTimeDifference = currentTime - lastTime
|
||||||
|
// console.log('Difference from expected', differenceFromExpected, 'Exact play time diff', exactPlayTimeDifference)
|
||||||
|
lastTime = currentTime
|
||||||
|
this.listeningTimeSinceLastUpdate += exactPlayTimeDifference
|
||||||
|
this.totalListeningTimeInSession += exactPlayTimeDifference
|
||||||
|
// console.log('Time since last update:', this.listeningTimeSinceLastUpdate, 'Session listening time:', this.totalListeningTimeInSession)
|
||||||
|
if (this.listeningTimeSinceLastUpdate > 5) {
|
||||||
|
this.sendAddListeningTime()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
|
},
|
||||||
selectChapter(chapter) {
|
selectChapter(chapter) {
|
||||||
this.seek(chapter.start)
|
this.seek(chapter.start)
|
||||||
this.showChaptersModal = false
|
this.showChaptersModal = false
|
||||||
@ -201,11 +285,23 @@ export default {
|
|||||||
console.error('No Audio el for seek', time)
|
console.error('No Audio el for seek', time)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (!this.audioEl.paused) {
|
||||||
|
this.cancelListenTimeInterval()
|
||||||
|
}
|
||||||
|
|
||||||
this.seekedTime = time
|
this.seekedTime = time
|
||||||
this.seekLoading = true
|
this.seekLoading = true
|
||||||
|
|
||||||
this.audioEl.currentTime = time
|
this.audioEl.currentTime = time
|
||||||
|
|
||||||
|
this.sendStreamSync()
|
||||||
|
|
||||||
|
this.$nextTick(() => {
|
||||||
|
if (this.audioEl && !this.audioEl.paused) {
|
||||||
|
this.startListenTimeInterval()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
if (this.$refs.playedTrack) {
|
if (this.$refs.playedTrack) {
|
||||||
var perc = time / this.audioEl.duration
|
var perc = time / this.audioEl.duration
|
||||||
var ptWidth = Math.round(perc * this.trackWidth)
|
var ptWidth = Math.round(perc * this.trackWidth)
|
||||||
@ -287,7 +383,7 @@ export default {
|
|||||||
},
|
},
|
||||||
restart() {
|
restart() {
|
||||||
this.seek(0)
|
this.seek(0)
|
||||||
this.$nextTick(this.sendStreamUpdate)
|
this.$nextTick(this.sendStreamSync)
|
||||||
},
|
},
|
||||||
backward10() {
|
backward10() {
|
||||||
var newTime = this.audioEl.currentTime - 10
|
var newTime = this.audioEl.currentTime - 10
|
||||||
@ -299,10 +395,6 @@ export default {
|
|||||||
newTime = Math.min(this.audioEl.duration, newTime)
|
newTime = Math.min(this.audioEl.duration, newTime)
|
||||||
this.seek(newTime)
|
this.seek(newTime)
|
||||||
},
|
},
|
||||||
sendStreamUpdate() {
|
|
||||||
if (!this.audioEl) return
|
|
||||||
this.$emit('updateTime', this.audioEl.currentTime)
|
|
||||||
},
|
|
||||||
setStreamReady() {
|
setStreamReady() {
|
||||||
this.readyTrackWidth = this.trackWidth
|
this.readyTrackWidth = this.trackWidth
|
||||||
this.$refs.readyTrack.style.width = this.trackWidth + 'px'
|
this.$refs.readyTrack.style.width = this.trackWidth + 'px'
|
||||||
@ -432,9 +524,9 @@ export default {
|
|||||||
// Send update to server when currentTime > 0
|
// Send update to server when currentTime > 0
|
||||||
// this prevents errors when seeking to position not yet transcoded
|
// this prevents errors when seeking to position not yet transcoded
|
||||||
// seeking to position not yet transcoded will cause audio element to set currentTime to 0
|
// seeking to position not yet transcoded will cause audio element to set currentTime to 0
|
||||||
if (this.audioEl.currentTime) {
|
// if (this.audioEl.currentTime) {
|
||||||
this.sendStreamUpdate()
|
// this.sendStreamUpdate()
|
||||||
}
|
// }
|
||||||
|
|
||||||
this.currentTime = this.audioEl.currentTime
|
this.currentTime = this.audioEl.currentTime
|
||||||
|
|
||||||
@ -446,10 +538,12 @@ export default {
|
|||||||
this.$refs.playedTrack.style.width = ptWidth + 'px'
|
this.$refs.playedTrack.style.width = ptWidth + 'px'
|
||||||
this.playedTrackWidth = ptWidth
|
this.playedTrackWidth = ptWidth
|
||||||
},
|
},
|
||||||
audioLoadedData() {
|
audioLoadedMetadata() {
|
||||||
|
console.log('Audio METADATA Loaded, total duration', this.audioEl.duration)
|
||||||
this.totalDuration = this.audioEl.duration
|
this.totalDuration = this.audioEl.duration
|
||||||
this.$emit('loaded', this.totalDuration)
|
this.$emit('loaded', this.totalDuration)
|
||||||
},
|
},
|
||||||
|
audioLoadedData() {},
|
||||||
set(url, currentTime, playOnLoad = false) {
|
set(url, currentTime, playOnLoad = false) {
|
||||||
if (this.hlsInstance) {
|
if (this.hlsInstance) {
|
||||||
this.terminateStream()
|
this.terminateStream()
|
||||||
@ -458,6 +552,8 @@ export default {
|
|||||||
console.error('No audio widget')
|
console.error('No audio widget')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
this.listeningTimeSinceLastUpdate = 0
|
||||||
|
|
||||||
this.url = url
|
this.url = url
|
||||||
if (process.env.NODE_ENV === 'development') {
|
if (process.env.NODE_ENV === 'development') {
|
||||||
url = `${process.env.serverUrl}${url}`
|
url = `${process.env.serverUrl}${url}`
|
||||||
@ -471,6 +567,7 @@ export default {
|
|||||||
xhr.setRequestHeader('Authorization', `Bearer ${this.token}`)
|
xhr.setRequestHeader('Authorization', `Bearer ${this.token}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
console.log('Starting HLS audio stream at time', currentTime)
|
||||||
// console.log('[AudioPlayer-Set] HLS Config', hlsOptions)
|
// console.log('[AudioPlayer-Set] HLS Config', hlsOptions)
|
||||||
this.hlsInstance = new Hls(hlsOptions)
|
this.hlsInstance = new Hls(hlsOptions)
|
||||||
var audio = this.$refs.audio
|
var audio = this.$refs.audio
|
||||||
|
@ -25,7 +25,7 @@
|
|||||||
<span v-if="stream" class="material-icons p-4 cursor-pointer" @click="cancelStream">close</span>
|
<span v-if="stream" class="material-icons p-4 cursor-pointer" @click="cancelStream">close</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<audio-player ref="audioPlayer" :chapters="chapters" :loading="isLoading" :bookmarks="bookmarks" @close="cancelStream" @updateTime="updateTime" @loaded="(d) => (totalDuration = d)" @showBookmarks="showBookmarks" @hook:mounted="audioPlayerMounted" />
|
<audio-player ref="audioPlayer" :stream-id="streamId" :audiobook-id="audiobookId" :chapters="chapters" :loading="isLoading" :bookmarks="bookmarks" @close="cancelStream" @loaded="(d) => (totalDuration = d)" @showBookmarks="showBookmarks" @sync="sendStreamSync" @hook:mounted="audioPlayerMounted" />
|
||||||
|
|
||||||
<modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :audiobook-id="bookmarkAudiobookId" :current-time="bookmarkCurrentTime" @select="selectBookmark" />
|
<modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :audiobook-id="bookmarkAudiobookId" :current-time="bookmarkCurrentTime" @select="selectBookmark" />
|
||||||
</div>
|
</div>
|
||||||
@ -109,6 +109,9 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
addListeningTime(time) {
|
||||||
|
console.log('Send listening time to server', time)
|
||||||
|
},
|
||||||
showBookmarks(currentTime) {
|
showBookmarks(currentTime) {
|
||||||
this.bookmarkAudiobookId = this.audiobookId
|
this.bookmarkAudiobookId = this.audiobookId
|
||||||
this.bookmarkCurrentTime = currentTime
|
this.bookmarkCurrentTime = currentTime
|
||||||
@ -191,17 +194,25 @@ export default {
|
|||||||
console.error('No Audio Ref')
|
console.error('No Audio Ref')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
updateTime(currentTime) {
|
sendStreamSync(syncData) {
|
||||||
var diff = currentTime - this.lastServerUpdateSentSeconds
|
var diff = syncData.currentTime - this.lastServerUpdateSentSeconds
|
||||||
if (diff > 4 || diff < 0) {
|
if (Math.abs(diff) < 1 && !syncData.timeListened) {
|
||||||
this.lastServerUpdateSentSeconds = currentTime
|
// No need to sync
|
||||||
var updatePayload = {
|
return
|
||||||
currentTime,
|
|
||||||
streamId: this.streamId
|
|
||||||
}
|
|
||||||
this.$root.socket.emit('stream_update', updatePayload)
|
|
||||||
}
|
}
|
||||||
|
this.$root.socket.emit('stream_sync', syncData)
|
||||||
},
|
},
|
||||||
|
// updateTime(currentTime) {
|
||||||
|
// var diff = currentTime - this.lastServerUpdateSentSeconds
|
||||||
|
// if (diff > 4 || diff < 0) {
|
||||||
|
// this.lastServerUpdateSentSeconds = currentTime
|
||||||
|
// var updatePayload = {
|
||||||
|
// currentTime,
|
||||||
|
// streamId: this.streamId
|
||||||
|
// }
|
||||||
|
// this.$root.socket.emit('stream_update', updatePayload)
|
||||||
|
// }
|
||||||
|
// },
|
||||||
streamReset({ startTime, streamId }) {
|
streamReset({ startTime, streamId }) {
|
||||||
if (streamId !== this.streamId) {
|
if (streamId !== this.streamId) {
|
||||||
console.error('resetStream StreamId Mismatch', streamId, this.streamId)
|
console.error('resetStream StreamId Mismatch', streamId, this.streamId)
|
||||||
|
@ -6,26 +6,42 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div class="p-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 400px; max-height: 80vh">
|
<div class="p-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 400px; max-height: 80vh">
|
||||||
<form @submit.prevent="submitForm">
|
<template v-if="!showImageUploader">
|
||||||
<div class="flex">
|
<form @submit.prevent="submitForm">
|
||||||
<div>
|
<div class="flex">
|
||||||
<covers-collection-cover :book-items="books" :width="200" :height="100 * 1.6" />
|
<div>
|
||||||
<!-- <ui-btn type="button" @click="showUploadImageModal = true">Upload</ui-btn> -->
|
<covers-collection-cover :book-items="books" :width="200" :height="100 * 1.6" />
|
||||||
</div>
|
<!-- <ui-btn type="button" @click="showImageUploader = true">Upload</ui-btn> -->
|
||||||
<div class="flex-grow px-4">
|
</div>
|
||||||
<ui-text-input-with-label v-model="newCollectionName" label="Name" class="mb-2" />
|
<div class="flex-grow px-4">
|
||||||
|
<ui-text-input-with-label v-model="newCollectionName" label="Name" class="mb-2" />
|
||||||
|
|
||||||
<ui-textarea-with-label v-model="newCollectionDescription" label="Description" />
|
<ui-textarea-with-label v-model="newCollectionDescription" label="Description" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="absolute bottom-0 left-0 right-0 w-full py-2 px-4 flex">
|
||||||
|
<ui-btn small color="error" type="button" @click.stop="removeClick">Remove</ui-btn>
|
||||||
|
<div class="flex-grow" />
|
||||||
|
<ui-btn color="success" type="submit">Save</ui-btn>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div class="flex items-center mb-3">
|
||||||
|
<div class="hover:bg-white hover:bg-opacity-10 cursor-pointer h-11 w-11 flex items-center justify-center rounded-full" @click="showImageUploader = false">
|
||||||
|
<span class="material-icons text-4xl">arrow_back</span>
|
||||||
|
</div>
|
||||||
|
<p class="ml-2 text-xl mb-1">Collection Cover Image</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="absolute bottom-0 left-0 right-0 w-full py-2 px-4 flex">
|
<div class="flex mb-4">
|
||||||
<ui-btn small color="error" type="button" @click.stop="removeClick">Remove</ui-btn>
|
<ui-btn small class="mr-2">Upload</ui-btn>
|
||||||
<div class="flex-grow" />
|
<ui-text-input v-model="newCoverImage" class="flex-grow" placeholder="Collection Cover Image" />
|
||||||
<ui-btn color="success" type="submit">Save</ui-btn>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
<div class="flex justify-end">
|
||||||
|
<ui-btn color="success">Upload</ui-btn>
|
||||||
<modals-upload-image-modal v-model="showUploadImageModal" entity="collection" :entity-id="collection.id" />
|
</div>
|
||||||
|
</template>
|
||||||
|
<!-- <modals-upload-image-modal v-model="showUploadImageModal" entity="collection" :entity-id="collection.id" /> -->
|
||||||
</div>
|
</div>
|
||||||
</modals-modal>
|
</modals-modal>
|
||||||
</template>
|
</template>
|
||||||
@ -37,7 +53,7 @@ export default {
|
|||||||
processing: false,
|
processing: false,
|
||||||
newCollectionName: null,
|
newCollectionName: null,
|
||||||
newCollectionDescription: null,
|
newCollectionDescription: null,
|
||||||
showUploadImageModal: false
|
showImageUploader: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div ref="wrapper" class="modal modal-bg w-full h-full fixed top-0 left-0 bg-primary bg-opacity-75 items-center justify-center z-40 opacity-0 hidden">
|
<div ref="wrapper" class="modal modal-bg w-full h-full fixed top-0 left-0 bg-primary bg-opacity-75 items-center justify-center opacity-0 hidden" :class="`z-${zIndex}`">
|
||||||
<div class="absolute top-0 left-0 right-0 w-full h-36 bg-gradient-to-t from-transparent via-black-500 to-black-700 opacity-90 pointer-events-none" />
|
<div class="absolute top-0 left-0 right-0 w-full h-36 bg-gradient-to-t from-transparent via-black-500 to-black-700 opacity-90 pointer-events-none" />
|
||||||
|
|
||||||
<div class="absolute top-5 right-5 h-12 w-12 flex items-center justify-center cursor-pointer text-white hover:text-gray-300" @click="clickClose">
|
<div class="absolute top-5 right-5 h-12 w-12 flex items-center justify-center cursor-pointer text-white hover:text-gray-300" @click="clickClose">
|
||||||
@ -36,6 +36,10 @@ export default {
|
|||||||
contentMarginTop: {
|
contentMarginTop: {
|
||||||
type: Number,
|
type: Number,
|
||||||
default: 50
|
default: 50
|
||||||
|
},
|
||||||
|
zIndex: {
|
||||||
|
type: Number,
|
||||||
|
default: 40
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf-client",
|
"name": "audiobookshelf-client",
|
||||||
"version": "1.6.15",
|
"version": "1.6.16",
|
||||||
"description": "Audiobook manager and player",
|
"description": "Audiobook manager and player",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
@ -13,6 +13,23 @@
|
|||||||
<widgets-online-indicator :value="!!userOnline" />
|
<widgets-online-indicator :value="!!userOnline" />
|
||||||
<h1 class="text-xl pl-2">{{ username }}</h1>
|
<h1 class="text-xl pl-2">{{ username }}</h1>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="showExperimentalFeatures" class="w-full h-px bg-white bg-opacity-10 my-2" />
|
||||||
|
<div v-if="showExperimentalFeatures" class="py-2">
|
||||||
|
<h1 class="text-lg mb-2 text-white text-opacity-90 px-2 sm:px-0">Listening Stats <span class="pl-2 text-xs text-error">(web app only)</span></h1>
|
||||||
|
<p class="text-sm text-gray-300">
|
||||||
|
Total Time Listened:
|
||||||
|
<span class="font-mono text-base">{{ listeningTimePretty }}</span>
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-gray-300">
|
||||||
|
Time Listened Today:
|
||||||
|
<span class="font-mono text-base">{{ $elapsedPrettyExtended(timeListenedToday) }}</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<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 <span class="pl-2 text-xs text-error">(web app only)</span></h1>
|
||||||
|
<p class="text-sm text-gray-300">{{ latestSession.audiobookTitle }} {{ $dateDistanceFromNow(latestSession.lastUpdate) }} for {{ $elapsedPrettyExtended(this.latestSession.timeListening) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="w-full h-px bg-white bg-opacity-10 my-2" />
|
<div class="w-full h-px bg-white bg-opacity-10 my-2" />
|
||||||
<div class="py-2">
|
<div class="py-2">
|
||||||
<h1 class="text-lg mb-2 text-white text-opacity-90 px-2 sm:px-0">Reading Progress</h1>
|
<h1 class="text-lg mb-2 text-white text-opacity-90 px-2 sm:px-0">Reading Progress</h1>
|
||||||
@ -64,9 +81,15 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {}
|
return {
|
||||||
|
listeningSessions: [],
|
||||||
|
listeningStats: {}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
showExperimentalFeatures() {
|
||||||
|
return this.$store.state.showExperimentalFeatures
|
||||||
|
},
|
||||||
username() {
|
username() {
|
||||||
return this.user.username
|
return this.user.username
|
||||||
},
|
},
|
||||||
@ -75,10 +98,37 @@ export default {
|
|||||||
},
|
},
|
||||||
userAudiobooks() {
|
userAudiobooks() {
|
||||||
return Object.values(this.user.audiobooks || {}).sort((a, b) => b.lastUpdate - a.lastUpdate)
|
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: {},
|
methods: {
|
||||||
mounted() {}
|
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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
@ -54,6 +54,22 @@ Vue.prototype.$secondsToTimestamp = (seconds) => {
|
|||||||
return `${_hours}:${_minutes.toString().padStart(2, '0')}:${_seconds.toString().padStart(2, '0')}`
|
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 = {}) => {
|
Vue.prototype.$calculateTextSize = (text, styles = {}) => {
|
||||||
const el = document.createElement('p')
|
const el = document.createElement('p')
|
||||||
|
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf",
|
"name": "audiobookshelf",
|
||||||
"version": "1.6.15",
|
"version": "1.6.16",
|
||||||
"description": "Self-hosted audiobook server for managing and playing audiobooks",
|
"description": "Self-hosted audiobook server for managing and playing audiobooks",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
const express = require('express')
|
const express = require('express')
|
||||||
const Path = require('path')
|
const Path = require('path')
|
||||||
const fs = require('fs-extra')
|
const fs = require('fs-extra')
|
||||||
|
const date = require('date-and-time')
|
||||||
|
|
||||||
const Logger = require('./Logger')
|
const Logger = require('./Logger')
|
||||||
const { isObject } = require('./utils/index')
|
const { isObject } = require('./utils/index')
|
||||||
@ -74,6 +75,8 @@ class ApiController {
|
|||||||
this.router.get('/users', this.getUsers.bind(this))
|
this.router.get('/users', this.getUsers.bind(this))
|
||||||
this.router.post('/user', this.createUser.bind(this))
|
this.router.post('/user', this.createUser.bind(this))
|
||||||
this.router.get('/user/:id', this.getUser.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.patch('/user/:id', this.updateUser.bind(this))
|
||||||
this.router.delete('/user/:id', this.deleteUser.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('/filesystem', this.getFileSystemPaths.bind(this))
|
||||||
|
|
||||||
this.router.get('/scantracks/:id', this.scanAudioTrackNums.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) {
|
async find(req, res) {
|
||||||
@ -1026,5 +1032,75 @@ class ApiController {
|
|||||||
var scandata = await audioFileScanner.scanTrackNumbers(audiobook)
|
var scandata = await audioFileScanner.scanTrackNumbers(audiobook)
|
||||||
res.json(scandata)
|
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
|
module.exports = ApiController
|
@ -83,6 +83,10 @@ class Auth {
|
|||||||
return jwt.sign(payload, process.env.TOKEN_SECRET);
|
return jwt.sign(payload, process.env.TOKEN_SECRET);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
authenticateUser(token) {
|
||||||
|
return this.verifyToken(token)
|
||||||
|
}
|
||||||
|
|
||||||
verifyToken(token) {
|
verifyToken(token) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
jwt.verify(token, process.env.TOKEN_SECRET, (err, payload) => {
|
jwt.verify(token, process.env.TOKEN_SECRET, (err, payload) => {
|
||||||
|
@ -206,7 +206,7 @@ class BackupManager {
|
|||||||
}
|
}
|
||||||
newBackup.setData(newBackData)
|
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}`)
|
Logger.error(`[BackupManager] Backup Failed ${error}`)
|
||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
@ -246,7 +246,7 @@ class BackupManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
zipBackup(configPath, metadataBooksPath, backup) {
|
zipBackup(metadataBooksPath, backup) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
// create a file to stream archive data to
|
// create a file to stream archive data to
|
||||||
const output = fs.createWriteStream(backup.fullPath)
|
const output = fs.createWriteStream(backup.fullPath)
|
||||||
@ -307,17 +307,12 @@ class BackupManager {
|
|||||||
// pipe archive data to the file
|
// pipe archive data to the file
|
||||||
archive.pipe(output)
|
archive.pipe(output)
|
||||||
|
|
||||||
var audiobooksDbDir = Path.join(configPath, 'audiobooks')
|
archive.directory(this.db.AudiobooksPath, 'config/audiobooks')
|
||||||
var librariesDbDir = Path.join(configPath, 'libraries')
|
archive.directory(this.db.LibrariesPath, 'config/libraries')
|
||||||
var settingsDbDir = Path.join(configPath, 'settings')
|
archive.directory(this.db.SettingsPath, 'config/settings')
|
||||||
var usersDbDir = Path.join(configPath, 'users')
|
archive.directory(this.db.UsersPath, 'config/users')
|
||||||
var collectionsDbDir = Path.join(configPath, 'collections')
|
archive.directory(this.db.SessionsPath, 'config/sessions')
|
||||||
|
archive.directory(this.db.CollectionsPath, 'config/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')
|
|
||||||
|
|
||||||
if (metadataBooksPath) {
|
if (metadataBooksPath) {
|
||||||
Logger.debug(`[BackupManager] Backing up Metadata Books "${metadataBooksPath}"`)
|
Logger.debug(`[BackupManager] Backing up Metadata Books "${metadataBooksPath}"`)
|
||||||
|
18
server/Db.js
18
server/Db.js
@ -13,19 +13,23 @@ class Db {
|
|||||||
constructor(ConfigPath, AudiobookPath) {
|
constructor(ConfigPath, AudiobookPath) {
|
||||||
this.ConfigPath = ConfigPath
|
this.ConfigPath = ConfigPath
|
||||||
this.AudiobookPath = AudiobookPath
|
this.AudiobookPath = AudiobookPath
|
||||||
|
|
||||||
this.AudiobooksPath = Path.join(ConfigPath, 'audiobooks')
|
this.AudiobooksPath = Path.join(ConfigPath, 'audiobooks')
|
||||||
this.UsersPath = Path.join(ConfigPath, 'users')
|
this.UsersPath = Path.join(ConfigPath, 'users')
|
||||||
|
this.SessionsPath = Path.join(ConfigPath, 'sessions')
|
||||||
this.LibrariesPath = Path.join(ConfigPath, 'libraries')
|
this.LibrariesPath = Path.join(ConfigPath, 'libraries')
|
||||||
this.SettingsPath = Path.join(ConfigPath, 'settings')
|
this.SettingsPath = Path.join(ConfigPath, 'settings')
|
||||||
this.CollectionsPath = Path.join(ConfigPath, 'collections')
|
this.CollectionsPath = Path.join(ConfigPath, 'collections')
|
||||||
|
|
||||||
this.audiobooksDb = new njodb.Database(this.AudiobooksPath)
|
this.audiobooksDb = new njodb.Database(this.AudiobooksPath)
|
||||||
this.usersDb = new njodb.Database(this.UsersPath)
|
this.usersDb = new njodb.Database(this.UsersPath)
|
||||||
|
this.sessionsDb = new njodb.Database(this.SessionsPath)
|
||||||
this.librariesDb = new njodb.Database(this.LibrariesPath, { datastores: 2 })
|
this.librariesDb = new njodb.Database(this.LibrariesPath, { datastores: 2 })
|
||||||
this.settingsDb = new njodb.Database(this.SettingsPath, { datastores: 2 })
|
this.settingsDb = new njodb.Database(this.SettingsPath, { datastores: 2 })
|
||||||
this.collectionsDb = new njodb.Database(this.CollectionsPath, { datastores: 2 })
|
this.collectionsDb = new njodb.Database(this.CollectionsPath, { datastores: 2 })
|
||||||
|
|
||||||
this.users = []
|
this.users = []
|
||||||
|
this.sessions = []
|
||||||
this.libraries = []
|
this.libraries = []
|
||||||
this.audiobooks = []
|
this.audiobooks = []
|
||||||
this.settings = []
|
this.settings = []
|
||||||
@ -36,6 +40,7 @@ class Db {
|
|||||||
|
|
||||||
getEntityDb(entityName) {
|
getEntityDb(entityName) {
|
||||||
if (entityName === 'user') return this.usersDb
|
if (entityName === 'user') return this.usersDb
|
||||||
|
else if (entityName === 'session') return this.sessionsDb
|
||||||
else if (entityName === 'audiobook') return this.audiobooksDb
|
else if (entityName === 'audiobook') return this.audiobooksDb
|
||||||
else if (entityName === 'library') return this.librariesDb
|
else if (entityName === 'library') return this.librariesDb
|
||||||
else if (entityName === 'settings') return this.settingsDb
|
else if (entityName === 'settings') return this.settingsDb
|
||||||
@ -45,6 +50,7 @@ class Db {
|
|||||||
|
|
||||||
getEntityArrayKey(entityName) {
|
getEntityArrayKey(entityName) {
|
||||||
if (entityName === 'user') return 'users'
|
if (entityName === 'user') return 'users'
|
||||||
|
else if (entityName === 'session') return 'sessions'
|
||||||
else if (entityName === 'audiobook') return 'audiobooks'
|
else if (entityName === 'audiobook') return 'audiobooks'
|
||||||
else if (entityName === 'library') return 'libraries'
|
else if (entityName === 'library') return 'libraries'
|
||||||
else if (entityName === 'settings') return 'settings'
|
else if (entityName === 'settings') return 'settings'
|
||||||
@ -82,6 +88,7 @@ class Db {
|
|||||||
reinit() {
|
reinit() {
|
||||||
this.audiobooksDb = new njodb.Database(this.AudiobooksPath)
|
this.audiobooksDb = new njodb.Database(this.AudiobooksPath)
|
||||||
this.usersDb = new njodb.Database(this.UsersPath)
|
this.usersDb = new njodb.Database(this.UsersPath)
|
||||||
|
this.sessionsDb = new njodb.Database(this.SessionsPath)
|
||||||
this.librariesDb = new njodb.Database(this.LibrariesPath, { datastores: 2 })
|
this.librariesDb = new njodb.Database(this.LibrariesPath, { datastores: 2 })
|
||||||
this.settingsDb = new njodb.Database(this.SettingsPath, { datastores: 2 })
|
this.settingsDb = new njodb.Database(this.SettingsPath, { datastores: 2 })
|
||||||
this.collectionsDb = new njodb.Database(this.CollectionsPath, { datastores: 2 })
|
this.collectionsDb = new njodb.Database(this.CollectionsPath, { datastores: 2 })
|
||||||
@ -188,8 +195,6 @@ class Db {
|
|||||||
var jsonEntity = entity
|
var jsonEntity = entity
|
||||||
if (entity && entity.toJSON) {
|
if (entity && entity.toJSON) {
|
||||||
jsonEntity = entity.toJSON()
|
jsonEntity = entity.toJSON()
|
||||||
} else {
|
|
||||||
console.log('Entity has no json', jsonEntity)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return entityDb.update((record) => record.id === entity.id, () => jsonEntity).then((results) => {
|
return entityDb.update((record) => record.id === entity.id, () => jsonEntity).then((results) => {
|
||||||
@ -229,5 +234,14 @@ class Db {
|
|||||||
return false
|
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
|
module.exports = Db
|
||||||
|
@ -186,11 +186,13 @@ class Server {
|
|||||||
res.sendFile(fullPath)
|
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', (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('/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', (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('/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('/api', this.authMiddleware.bind(this), this.apiController.router)
|
||||||
app.use('/hls', this.authMiddleware.bind(this), this.hlsController.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('open_stream', (audiobookId) => this.streamManager.openStreamSocketRequest(socket, audiobookId))
|
||||||
socket.on('close_stream', () => this.streamManager.closeStreamRequest(socket))
|
socket.on('close_stream', () => this.streamManager.closeStreamRequest(socket))
|
||||||
socket.on('stream_update', (payload) => this.streamManager.streamUpdate(socket, payload))
|
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))
|
socket.on('progress_update', (payload) => this.audiobookProgressUpdate(socket, payload))
|
||||||
|
|
||||||
@ -569,7 +572,7 @@ class Server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async authenticateSocket(socket, token) {
|
async authenticateSocket(socket, token) {
|
||||||
var user = await this.auth.verifyToken(token)
|
var user = await this.auth.authenticateUser(token)
|
||||||
if (!user) {
|
if (!user) {
|
||||||
Logger.error('Cannot validate socket - invalid token')
|
Logger.error('Cannot validate socket - invalid token')
|
||||||
return socket.emit('invalid_token')
|
return socket.emit('invalid_token')
|
||||||
|
@ -151,6 +151,45 @@ class StreamManager {
|
|||||||
this.emitter('user_stream_update', client.user.toJSONForPublic(this.streams))
|
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 }) {
|
streamUpdate(socket, { currentTime, streamId }) {
|
||||||
var client = socket.sheepClient
|
var client = socket.sheepClient
|
||||||
if (!client || !client.stream) {
|
if (!client || !client.stream) {
|
||||||
|
@ -7,7 +7,7 @@ const { secondsToTimestamp } = require('../utils/fileUtils')
|
|||||||
const { writeConcatFile } = require('../utils/ffmpegHelpers')
|
const { writeConcatFile } = require('../utils/ffmpegHelpers')
|
||||||
const hlsPlaylistGenerator = require('../utils/hlsPlaylistGenerator')
|
const hlsPlaylistGenerator = require('../utils/hlsPlaylistGenerator')
|
||||||
|
|
||||||
// const UserListeningSession = require('./UserListeningSession')
|
const UserListeningSession = require('./UserListeningSession')
|
||||||
|
|
||||||
class Stream extends EventEmitter {
|
class Stream extends EventEmitter {
|
||||||
constructor(streamPath, client, audiobook) {
|
constructor(streamPath, client, audiobook) {
|
||||||
@ -34,8 +34,8 @@ class Stream extends EventEmitter {
|
|||||||
this.furthestSegmentCreated = 0
|
this.furthestSegmentCreated = 0
|
||||||
this.clientCurrentTime = 0
|
this.clientCurrentTime = 0
|
||||||
|
|
||||||
// this.listeningSession = new UserListeningSession()
|
this.listeningSession = new UserListeningSession()
|
||||||
// this.listeningSession.setData(audiobook, client.user)
|
this.listeningSession.setData(audiobook, client.user)
|
||||||
|
|
||||||
this.init()
|
this.init()
|
||||||
}
|
}
|
||||||
@ -163,6 +163,35 @@ class Stream extends EventEmitter {
|
|||||||
this.clientCurrentTime = currentTime
|
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() {
|
async generatePlaylist() {
|
||||||
fs.ensureDirSync(this.streamPath)
|
fs.ensureDirSync(this.streamPath)
|
||||||
await hlsPlaylistGenerator(this.playlistPath, 'output', this.totalDuration, this.segmentLength, this.hlsSegmentType)
|
await hlsPlaylistGenerator(this.playlistPath, 'output', this.totalDuration, this.segmentLength, this.hlsSegmentType)
|
||||||
|
@ -1,16 +1,22 @@
|
|||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
|
const date = require('date-and-time')
|
||||||
|
|
||||||
class UserListeningSession {
|
class UserListeningSession {
|
||||||
constructor(session) {
|
constructor(session) {
|
||||||
|
this.id = null
|
||||||
|
this.sessionType = 'listeningSession'
|
||||||
this.userId = null
|
this.userId = null
|
||||||
this.audiobookId = null
|
this.audiobookId = null
|
||||||
this.audiobookTitle = null
|
this.audiobookTitle = null
|
||||||
this.audiobookAuthor = null
|
this.audiobookAuthor = null
|
||||||
|
this.audiobookGenres = []
|
||||||
|
|
||||||
|
this.date = null
|
||||||
|
this.dayOfWeek = null
|
||||||
|
|
||||||
this.timeListening = null
|
this.timeListening = null
|
||||||
this.lastUpdate = null
|
this.lastUpdate = null
|
||||||
this.startedAt = null
|
this.startedAt = null
|
||||||
this.finishedAt = null
|
|
||||||
|
|
||||||
if (session) {
|
if (session) {
|
||||||
this.construct(session)
|
this.construct(session)
|
||||||
@ -19,39 +25,68 @@ class UserListeningSession {
|
|||||||
|
|
||||||
toJSON() {
|
toJSON() {
|
||||||
return {
|
return {
|
||||||
|
id: this.id,
|
||||||
|
sessionType: this.sessionType,
|
||||||
userId: this.userId,
|
userId: this.userId,
|
||||||
audiobookId: this.audiobookId,
|
audiobookId: this.audiobookId,
|
||||||
audiobookTitle: this.audiobookTitle,
|
audiobookTitle: this.audiobookTitle,
|
||||||
audiobookAuthor: this.audiobookAuthor,
|
audiobookAuthor: this.audiobookAuthor,
|
||||||
|
audiobookGenres: [...this.audiobookGenres],
|
||||||
|
date: this.date,
|
||||||
|
dayOfWeek: this.dayOfWeek,
|
||||||
timeListening: this.timeListening,
|
timeListening: this.timeListening,
|
||||||
lastUpdate: this.lastUpdate,
|
lastUpdate: this.lastUpdate,
|
||||||
startedAt: this.startedAt,
|
startedAt: this.startedAt
|
||||||
finishedAt: this.finishedAt
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
construct(session) {
|
construct(session) {
|
||||||
|
this.id = session.id
|
||||||
|
this.sessionType = session.sessionType
|
||||||
this.userId = session.userId
|
this.userId = session.userId
|
||||||
this.audiobookId = session.audiobookId
|
this.audiobookId = session.audiobookId
|
||||||
this.audiobookTitle = session.audiobookTitle
|
this.audiobookTitle = session.audiobookTitle
|
||||||
this.audiobookAuthor = session.audiobookAuthor
|
this.audiobookAuthor = session.audiobookAuthor
|
||||||
|
this.audiobookGenres = session.audiobookGenres
|
||||||
|
|
||||||
|
this.date = session.date
|
||||||
|
this.dayOfWeek = session.dayOfWeek
|
||||||
|
|
||||||
this.timeListening = session.timeListening || null
|
this.timeListening = session.timeListening || null
|
||||||
this.lastUpdate = session.lastUpdate || null
|
this.lastUpdate = session.lastUpdate || null
|
||||||
this.startedAt = session.startedAt
|
this.startedAt = session.startedAt
|
||||||
this.finishedAt = session.finishedAt || null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setData(audiobook, user) {
|
setData(audiobook, user) {
|
||||||
|
this.id = 'ls_' + (Math.trunc(Math.random() * 1000) + Date.now()).toString(36)
|
||||||
this.userId = user.id
|
this.userId = user.id
|
||||||
this.audiobookId = audiobook.id
|
this.audiobookId = audiobook.id
|
||||||
this.audiobookTitle = audiobook.title || ''
|
this.audiobookTitle = audiobook.title || ''
|
||||||
this.audiobookAuthor = audiobook.author || ''
|
this.audiobookAuthor = audiobook.authorFL || ''
|
||||||
|
this.audiobookGenres = [...audiobook.genres]
|
||||||
|
|
||||||
this.timeListening = 0
|
this.timeListening = 0
|
||||||
this.lastUpdate = Date.now()
|
this.lastUpdate = Date.now()
|
||||||
this.startedAt = 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
|
module.exports = UserListeningSession
|
Loading…
Reference in New Issue
Block a user