mirror of
				https://github.com/advplyr/audiobookshelf.git
				synced 2025-10-27 11:18:14 +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