Compare commits

..

23 Commits

Author SHA1 Message Date
advplyr b6ae6d86fa Version update 2021-10-26 20:09:35 -05:00
advplyr 9f66054a72 Add: Experimental bookmarks edit and delete #115 2021-10-26 20:09:04 -05:00
advplyr fffa02e7e8 Change: Book details modal re-scan and save metadata buttons shifted right #147 2021-10-26 18:35:37 -05:00
advplyr 6fbd9dc260 Fix: initializing backup settings #146 2021-10-26 18:29:04 -05:00
advplyr c6614aba05 Fix: listen to changing series query string #140 2021-10-26 18:24:42 -05:00
advplyr ee62385980 Change: audio player author and duration styles #122 2021-10-26 18:12:19 -05:00
advplyr 806017175d Fix: seconds to timestamp floor #144 2021-10-26 17:55:48 -05:00
advplyr 1a3a7c5823 Version update 2021-10-26 17:38:02 -05:00
advplyr 550873ff87 Merge pull request #150 from svdztn/seekback-support
Seekback support
2021-10-26 16:53:06 -05:00
advplyr 4dd9f779e2 Fix check user audiobook data exists when getting last update 2021-10-26 16:52:45 -05:00
svd 580b961c4a log modify 2021-10-26 11:36:23 +08:00
svdztn 28b1132171 Merge branch 'advplyr:master' into seekback-support 2021-10-26 10:53:01 +08:00
advplyr 8b31c6555a Fix: audiobook progress emitter emit to all user sockets #145, Fix: save user after progress update 2021-10-25 21:14:54 -05:00
svd 8ffb4f88c9 transcoding maxSeekBackTime more; json add lastUpdate 2021-10-26 09:38:09 +08:00
advplyr c5eafdfa8a Change: audio player shows time remaining and percent #122, Add: experimental bookmarks start #115 2021-10-24 18:25:44 -05:00
advplyr f9bf846b30 readme and version update 2021-10-24 16:05:58 -05:00
advplyr 335bbac81d Add: Filter and side rail button to show books with issues #132, Add: realtime updates for audiobook progress 2021-10-24 15:53:51 -05:00
advplyr 874c910e24 Fix: hotkeys prevent default browser behavior #135, Fix: mark audiobook as read when less than 10s remaining 2021-10-24 14:02:49 -05:00
advplyr aca88f73ad Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2021-10-24 11:32:59 -05:00
advplyr ed80e15b7d Fix: stream to use actual timestamp, update start time to tenth of second string #116 2021-10-24 11:32:52 -05:00
advplyr 3f13d35241 Merge pull request #136 from zv0n/master
Updated Readme to include Apache config.
2021-10-24 06:29:22 -05:00
zvon 05be496817 Updated Readme to include Apache config. 2021-10-24 10:36:34 +02:00
advplyr 1cca288031 Version update 2021-10-23 21:09:23 -05:00
32 changed files with 791 additions and 129 deletions
+1 -1
View File
@@ -20,7 +20,7 @@
-webkit-font-feature-settings: 'liga';
-webkit-font-smoothing: antialiased;
}
.material-icons:not(.text-sm):not(.text-md):not(.text-base):not(.text-lg):not(.text-xl):not(.text-2xl):not(.text-3xl):not(.text-4xl):not(.text-5xl):not(.text-6xl) {
.material-icons:not(.text-xs):not(.text-sm):not(.text-md):not(.text-base):not(.text-lg):not(.text-xl):not(.text-2xl):not(.text-3xl):not(.text-4xl):not(.text-5xl):not(.text-6xl) {
font-size: 1.5rem;
}
+63 -24
View File
@@ -1,25 +1,29 @@
<template>
<div class="w-full">
<div class="w-full relative mb-4">
<div class="absolute left-2 top-0 bottom-0 h-full flex items-center">
<p ref="currentTimestamp" class="font-mono text-sm">00:00:00</p>
</div>
<div class="absolute right-2 top-0 bottom-0 h-full flex items-center">
<p class="font-mono text-sm">{{ totalDurationPretty }}</p>
<div class="w-full -mt-4">
<div class="w-full relative mb-2">
<div class="absolute top-0 left-0 w-full h-full bg-red flex items-end pointer-events-none">
<p ref="currentTimestamp" class="font-mono text-sm text-gray-100 pointer-events-auto">00:00:00</p>
<p class="font-mono text-sm text-gray-100 pointer-events-auto">&nbsp;/&nbsp;{{ progressPercent }}%</p>
<div class="flex-grow" />
<p class="font-mono text-sm text-gray-100 pointer-events-auto">{{ timeRemainingPretty }}</p>
</div>
<div class="absolute right-20 top-0 bottom-0">
<div v-if="chapters.length" class="cursor-pointer flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="showChapters">
<div v-if="chapters.length" class="absolute right-20 top-0 bottom-0 h-full flex items-end">
<div class="cursor-pointer text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="showChapters">
<span class="material-icons text-3xl">format_list_bulleted</span>
</div>
</div>
<div class="absolute right-32 top-0 bottom-0">
<div v-if="showExperimentalFeatures" class="absolute top-0 bottom-0 h-full flex items-end" :class="chapters.length ? ' right-32' : 'right-20'">
<div class="cursor-pointer text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="showBookmarks">
<span class="material-icons" style="font-size: 1.7rem">{{ bookmarks.length ? 'bookmarks' : 'bookmark_border' }}</span>
</div>
</div>
<div class="absolute top-0 bottom-0 h-full flex items-end" :class="!showExperimentalFeatures ? (chapters.length ? ' right-32' : 'right-20') : chapters.length ? ' right-44' : 'right-32'">
<controls-volume-control ref="volumeControl" v-model="volume" @input="updateVolume" />
</div>
<div class="flex my-2">
<div class="flex-grow" />
<div class="flex pb-2">
<div class="flex-grow" />
<template v-if="!loading">
<div class="cursor-pointer flex items-center justify-center text-gray-300 mr-8" @mousedown.prevent @mouseup.prevent @click.stop="restart">
<span class="material-icons text-3xl">first_page</span>
@@ -85,6 +89,10 @@ export default {
chapters: {
type: Array,
default: () => []
},
bookmarks: {
type: Array,
default: () => []
}
},
data() {
@@ -116,6 +124,17 @@ export default {
totalDurationPretty() {
return this.$secondsToTimestamp(this.totalDuration)
},
timeRemaining() {
if (!this.audioEl) return 0
return this.totalDuration - this.currentTime
},
timeRemainingPretty() {
return '-' + this.$secondsToTimestamp(this.timeRemaining)
},
progressPercent() {
if (!this.totalDuration) return 0
return Math.round((100 * this.currentTime) / this.totalDuration)
},
chapterTicks() {
return this.chapters.map((chap) => {
var perc = chap.start / this.totalDuration
@@ -127,6 +146,9 @@ export default {
},
currentChapter() {
return this.chapters.find((chapter) => chapter.start <= this.currentTime && this.currentTime < chapter.end)
},
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
}
},
methods: {
@@ -134,6 +156,11 @@ export default {
this.seek(chapter.start)
this.showChaptersModal = false
},
selectBookmark(bookmark) {
if (bookmark) {
this.seek(bookmark.time)
}
},
seek(time) {
if (this.loading) {
return
@@ -232,6 +259,7 @@ export default {
},
restart() {
this.seek(0)
this.$nextTick(this.sendStreamUpdate)
},
backward10() {
var newTime = this.audioEl.currentTime - 10
@@ -346,7 +374,7 @@ export default {
return
}
var lastbuff = this.getLastBufferedTime()
this.sendStreamUpdate()
var bufferlen = (lastbuff / this.audioEl.duration) * this.trackWidth
bufferlen = Math.round(bufferlen)
if (this.bufferTrackWidth === bufferlen || !this.$refs.bufferTrack) return
@@ -373,6 +401,13 @@ export default {
this.updateTimestamp()
// Send update to server when currentTime > 0
// 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
if (this.audioEl.currentTime) {
this.sendStreamUpdate()
}
this.currentTime = this.audioEl.currentTime
var perc = this.audioEl.currentTime / this.audioEl.duration
@@ -399,6 +434,7 @@ export default {
},
audioLoadedData() {
this.totalDuration = this.audioEl.duration
this.$emit('loaded', this.totalDuration)
},
set(url, currentTime, playOnLoad = false) {
if (this.hlsInstance) {
@@ -454,6 +490,9 @@ export default {
if (!this.chapters.length) return
this.showChaptersModal = !this.showChaptersModal
},
showBookmarks() {
this.$emit('showBookmarks', this.currentTime)
},
play() {
if (!this.$refs.audio) {
console.error('No Audio ref')
@@ -535,16 +574,16 @@ export default {
this.$emit('close')
},
hotkey(action) {
if (action === 'Space') this.playPauseClick()
else if (action === 'ArrowRight') this.forward10()
else if (action === 'ArrowLeft') this.backward10()
else if (action === 'ArrowUp') this.volumeUp()
else if (action === 'ArrowDown') this.volumeDown()
else if (action === 'KeyM') this.toggleMute()
else if (action === 'KeyL') this.showChapters()
else if (action === 'Shift-ArrowUp') this.increasePlaybackRate()
else if (action === 'Shift-ArrowDown') this.decreasePlaybackRate()
else if (action === 'Escape') this.closePlayer()
if (action === this.$hotkeys.AudioPlayer.PLAY_PAUSE) this.playPauseClick()
else if (action === this.$hotkeys.AudioPlayer.JUMP_FORWARD) this.forward10()
else if (action === this.$hotkeys.AudioPlayer.JUMP_BACKWARD) this.backward10()
else if (action === this.$hotkeys.AudioPlayer.VOLUME_UP) this.volumeUp()
else if (action === this.$hotkeys.AudioPlayer.VOLUME_DOWN) this.volumeDown()
else if (action === this.$hotkeys.AudioPlayer.MUTE_UNMUTE) this.toggleMute()
else if (action === this.$hotkeys.AudioPlayer.SHOW_CHAPTERS) this.showChapters()
else if (action === this.$hotkeys.AudioPlayer.INCREASE_PLAYBACK_RATE) this.increasePlaybackRate()
else if (action === this.$hotkeys.AudioPlayer.DECREASE_PLAYBACK_RATE) this.decreasePlaybackRate()
else if (action === this.$hotkeys.AudioPlayer.CLOSE) this.closePlayer()
},
windowResize() {
this.setTrackWidth()
+2
View File
@@ -89,6 +89,8 @@ export default {
'$route.query.filter'() {
if (this.$route.query.filter && this.$route.query.filter !== this.filterBy) {
this.$store.dispatch('user/updateUserSettings', { filterBy: this.$route.query.filter })
} else if (!this.$route.query.filter && this.filterBy) {
this.$store.dispatch('user/updateUserSettings', { filterBy: 'all' })
}
}
},
+25 -2
View File
@@ -11,14 +11,14 @@
<div v-show="homePage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf`" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="paramId === '' && !homePage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf`" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="showLibrary ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
<p class="font-book pt-1.5" style="font-size: 1rem">Library</p>
<div v-show="paramId === '' && !homePage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
<div v-show="showLibrary" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf/series`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="paramId === 'series' ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
@@ -31,6 +31,16 @@
<div v-show="paramId === 'series'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link v-if="numIssues" :to="`/library/${currentLibraryId}/bookshelf?filter=issues`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-opacity-40 cursor-pointer relative" :class="showingIssues ? 'bg-error bg-opacity-40' : ' bg-error bg-opacity-20'">
<span class="material-icons text-2xl">warning</span>
<p class="font-book pt-1.5" style="font-size: 1rem">Issues</p>
<div v-show="showingIssues" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
<div class="absolute top-1 right-1 w-4 h-4 rounded-full bg-white bg-opacity-30 flex items-center justify-center">
<p class="text-xs font-mono pb-0.5">{{ numIssues }}</p>
</div>
</nuxt-link>
<!-- <nuxt-link to="/library/collections" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="paramId === 'collections' ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
@@ -80,6 +90,19 @@ export default {
},
homePage() {
return this.$route.name === 'library-library'
},
libraryBookshelfPage() {
return this.$route.name === 'library-library-bookshelf-id'
},
showLibrary() {
return this.libraryBookshelfPage && this.paramId === '' && !this.showingIssues
},
showingIssues() {
if (!this.$route.query) return false
return this.libraryBookshelfPage && this.$route.query.filter === 'issues'
},
numIssues() {
return this.$store.getters['audiobooks/getAudiobooksWithIssues'].length
}
},
methods: {},
+70 -5
View File
@@ -1,20 +1,30 @@
<template>
<div v-if="streamAudiobook" id="streamContainer" class="w-full fixed bottom-0 left-0 right-0 h-40 z-40 bg-primary p-4">
<div v-if="streamAudiobook" id="streamContainer" class="w-full fixed bottom-0 left-0 right-0 h-40 z-40 bg-primary px-4 pb-4 pt-2">
<nuxt-link :to="`/audiobook/${streamAudiobook.id}`" class="absolute -top-16 left-4 cursor-pointer">
<cards-book-cover :audiobook="streamAudiobook" :width="88" />
</nuxt-link>
<div class="flex items-center pl-24">
<div>
<nuxt-link :to="`/audiobook/${streamAudiobook.id}`" class="hover:underline cursor-pointer">
<nuxt-link :to="`/audiobook/${streamAudiobook.id}`" class="hover:underline cursor-pointer text-lg">
{{ title }} <span v-if="stream && $isDev" class="text-xs text-gray-400">({{ stream.id }})</span>
</nuxt-link>
<p class="text-gray-400 text-sm hover:underline cursor-pointer" @click="filterByAuthor">by {{ author }}</p>
<div class="text-gray-400 flex items-center">
<span class="material-icons text-sm">person</span>
<p class="text-base hover:underline cursor-pointer pl-2" @click="filterByAuthor">{{ author }}</p>
</div>
<div class="text-gray-400 flex items-center">
<span class="material-icons text-xs">schedule</span>
<p class="font-mono text-sm pl-2 pb-px">{{ totalDurationPretty }}</p>
</div>
</div>
<div class="flex-grow" />
<span v-if="stream" class="material-icons px-4 cursor-pointer" @click="cancelStream">close</span>
</div>
<audio-player ref="audioPlayer" :chapters="chapters" :loading="isLoading" @close="cancelStream" @updateTime="updateTime" @hook:mounted="audioPlayerMounted" />
<audio-player ref="audioPlayer" :chapters="chapters" :loading="isLoading" :bookmarks="bookmarks" @close="cancelStream" @updateTime="updateTime" @loaded="(d) => (totalDuration = d)" @showBookmarks="showBookmarks" @hook:mounted="audioPlayerMounted" />
<modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :audiobook-id="bookmarkAudiobookId" :current-time="bookmarkCurrentTime" @select="selectBookmark" @create="createBookmark" @update="updateBookmark" @delete="deleteBookmark" />
</div>
</template>
@@ -24,7 +34,11 @@ export default {
return {
audioPlayerReady: false,
lastServerUpdateSentSeconds: 0,
stream: null
stream: null,
totalDuration: 0,
showBookmarksModal: false,
bookmarkCurrentTime: 0,
bookmarkAudiobookId: null
}
},
computed: {
@@ -35,6 +49,14 @@ export default {
user() {
return this.$store.state.user.user
},
userAudiobook() {
if (!this.audiobookId) return
return this.$store.getters['user/getUserAudiobook'](this.audiobookId)
},
bookmarks() {
if (!this.userAudiobook) return []
return this.userAudiobook.bookmarks || []
},
isLoading() {
if (!this.streamAudiobook) return false
if (this.stream) {
@@ -46,6 +68,9 @@ export default {
streamAudiobook() {
return this.$store.state.streamAudiobook
},
audiobookId() {
return this.streamAudiobook ? this.streamAudiobook.id : null
},
book() {
return this.streamAudiobook ? this.streamAudiobook.book || {} : {}
},
@@ -66,9 +91,49 @@ export default {
},
libraryId() {
return this.streamAudiobook ? this.streamAudiobook.libraryId : null
},
totalDurationPretty() {
return this.$secondsToTimestamp(this.totalDuration)
}
},
methods: {
showBookmarks(currentTime) {
this.bookmarkAudiobookId = this.audiobookId
this.bookmarkCurrentTime = currentTime
this.showBookmarksModal = true
},
// bookmarkCreated(time) {
// if (time === this.bookmarkTimeProcessing) {
// this.bookmarkTimeProcessing = 0
// this.$toast.success(`${this.$secondsToTimestamp(time)} Bookmarked`)
// }
// },
createBookmark(bookmark) {
// this.bookmarkTimeProcessing = bookmark.time
this.$root.socket.emit('create_bookmark', bookmark)
this.showBookmarksModal = false
},
// bookmarkUpdated(time) {
// if (time === this.bookmarkTimeProcessing) {
// this.bookmarkTimeProcessing = 0
// this.$toast.success(`Bookmark @${this.$secondsToTimestamp(time)} Updated`)
// }
// },
updateBookmark(bookmark) {
// this.bookmarkTimeProcessing = bookmark.time
this.$root.socket.emit('update_bookmark', bookmark)
this.showBookmarksModal = false
},
selectBookmark(bookmark) {
if (this.$refs.audioPlayer) {
this.$refs.audioPlayer.selectBookmark(bookmark)
}
this.showBookmarksModal = false
},
deleteBookmark(bookmark) {
this.$root.socket.emit('delete_bookmark', bookmark)
this.showBookmarksModal = false
},
filterByAuthor() {
if (this.$route.name !== 'index') {
this.$router.push(`/library/${this.libraryId || this.$store.state.libraries.currentLibraryId}/bookshelf`)
@@ -101,6 +101,11 @@ export default {
text: 'Progress',
value: 'progress',
sublist: true
},
{
text: 'Issues',
value: 'issues',
sublist: false
}
]
}
+122
View File
@@ -0,0 +1,122 @@
<template>
<modals-modal v-model="show" name="bookmarks" :width="500" :height="'unset'">
<div ref="container" class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
<div class="w-full h-full px-6 py-6" v-show="showBookmarkTitleInput">
<div class="flex mb-4 items-center">
<div class="w-9 h-9 flex items-center justify-center rounded-full hover:bg-white hover:bg-opacity-10 cursor-pointer" @click="showBookmarkTitleInput = false">
<span class="material-icons text-3xl">arrow_back</span>
</div>
<p class="text-xl pl-2">{{ selectedBookmark ? 'Edit Bookmark' : 'New Bookmark' }}</p>
<div class="flex-grow" />
<p class="text-xl font-mono">
{{ this.$secondsToTimestamp(currentTime) }}
</p>
</div>
<form @submit.prevent="submitBookmark">
<ui-text-input-with-label v-model="newBookmarkTitle" label="Note" />
<div class="flex justify-end mt-6">
<ui-btn color="success" class="w-1/2" type="submit">{{ selectedBookmark ? 'Update' : 'Create' }} Bookmark</ui-btn>
</div>
</form>
</div>
<div class="w-full h-full" v-show="!showBookmarkTitleInput">
<template v-for="bookmark in bookmarks">
<modals-bookmarks-bookmark-item :key="bookmark.id" :highlight="currentTime === bookmark.time" :bookmark="bookmark" @click="clickBookmark" @edit="editBookmark" @delete="deleteBookmark" />
</template>
<div v-if="!bookmarks.length" class="flex h-32 items-center justify-center">
<p class="text-xl">No Bookmarks</p>
</div>
<div v-show="canCreateBookmark" class="flex px-4 py-2 items-center text-center justify-between border-b border-white border-opacity-10 bg-blue-500 bg-opacity-20 cursor-pointer text-white text-opacity-80 hover:bg-opacity-40 hover:text-opacity-100" @click="createBookmark">
<span class="material-icons">add</span>
<p class="text-base pl-2">Create Bookmark</p>
<p class="text-sm font-mono">
{{ this.$secondsToTimestamp(currentTime) }}
</p>
</div>
</div>
</div>
</modals-modal>
</template>
<script>
export default {
props: {
value: Boolean,
bookmarks: {
type: Array,
default: () => []
},
currentTime: {
type: Number,
default: 0
},
audiobookId: String
},
data() {
return {
selectedBookmark: null,
showBookmarkTitleInput: false,
newBookmarkTitle: ''
}
},
watch: {
show(newVal) {
if (newVal) {
this.showBookmarkTitleInput = false
this.newBookmarkTitle = ''
}
}
},
computed: {
show: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
},
canCreateBookmark() {
return !this.bookmarks.find((bm) => bm.time === this.currentTime)
}
},
methods: {
editBookmark(bm) {
this.selectedBookmark = bm
this.newBookmarkTitle = bm.title
this.showBookmarkTitleInput = true
},
deleteBookmark(bm) {
var bookmark = { ...bm, audiobookId: this.audiobookId }
this.$emit('delete', bookmark)
},
clickBookmark(bm) {
this.$emit('select', bm)
},
createBookmark() {
this.selectedBookmark = null
this.newBookmarkTitle = this.$formatDate(Date.now(), 'MMM dd, yyyy HH:mm')
this.showBookmarkTitleInput = true
},
submitBookmark() {
if (this.selectedBookmark) {
if (this.selectedBookmark.title !== this.newBookmarkTitle) {
var bookmark = { ...this.selectedBookmark }
bookmark.audiobookId = this.audiobookId
bookmark.title = this.newBookmarkTitle
this.$emit('update', bookmark)
}
} else {
var bookmark = {
audiobookId: this.audiobookId,
title: this.newBookmarkTitle,
time: this.currentTime
}
this.$emit('create', bookmark)
}
this.newBookmarkTitle = ''
this.showBookmarkTitleInput = false
}
}
}
</script>
+2 -2
View File
@@ -218,9 +218,9 @@ export default {
}
},
hotkey(action) {
if (action === 'ArrowRight') {
if (action === this.$hotkeys.Modal.NEXT_PAGE) {
this.goNextBook()
} else if (action === 'ArrowLeft') {
} else if (action === this.$hotkeys.Modal.PREV_PAGE) {
this.goPrevBook()
}
},
+1 -1
View File
@@ -84,7 +84,7 @@ export default {
}
},
hotkey(action) {
if (action === 'Escape') {
if (action === this.$hotkeys.Modal.CLOSE) {
this.show = false
}
},
@@ -0,0 +1,45 @@
<template>
<div :key="bookmark.id" :id="`bookmark-row-${bookmark.id}`" class="flex items-center px-4 py-4 justify-start cursor-pointer hover:bg-bg relative" :class="highlight ? 'bg-bg bg-opacity-60' : ' bg-opacity-20'" @click="click" @mouseover="isHovering = true" @mouseleave="isHovering = false">
<span class="material-icons" :class="highlight ? 'text-success' : 'text-white text-opacity-60'">{{ highlight ? 'bookmark' : 'bookmark_border' }}</span>
<div class="flex-grow overflow-hidden">
<p class="pl-2 pr-2 truncate">{{ bookmark.title }}</p>
</div>
<div class="h-full flex items-center w-16 justify-end">
<span class="font-mono text-sm text-gray-300">{{ $secondsToTimestamp(bookmark.time) }}</span>
</div>
<div class="h-full flex items-center justify-end transform" :class="isHovering ? 'transition-transform translate-0 w-16' : 'translate-x-40 w-0'">
<span class="material-icons text-lg mr-2 text-gray-200 hover:text-yellow-400" @click.stop="editClick">edit</span>
<span class="material-icons text-lg text-gray-200 hover:text-error cursor-pointer" @click.stop="deleteClick">delete</span>
</div>
</div>
</template>
<script>
export default {
props: {
bookmark: {
type: Object,
default: () => {}
},
highlight: Boolean
},
data() {
return {
isHovering: false
}
},
computed: {},
methods: {
click() {
this.$emit('click', this.bookmark)
},
deleteClick() {
this.$emit('delete', this.bookmark)
},
editClick() {
this.$emit('edit', this.bookmark)
}
},
mounted() {}
}
</script>
@@ -53,18 +53,19 @@
</div>
<div class="absolute bottom-0 left-0 w-full py-4 bg-bg" :class="isScrollable ? 'box-shadow-md-up' : 'box-shadow-sm-up border-t border-primary border-opacity-50'">
<div class="flex px-4">
<ui-btn v-if="userCanDelete" color="error" type="button" small @click.stop.prevent="deleteAudiobook">Remove</ui-btn>
<div class="flex items-center px-4">
<ui-btn v-if="userCanDelete" color="error" type="button" class="h-8" :padding-x="3" small @click.stop.prevent="deleteAudiobook">Remove</ui-btn>
<ui-tooltip text="(Root User Only) Save a NFO metadata file in your audiobooks directory" direction="bottom" class="ml-4">
<div class="flex-grow" />
<ui-tooltip v-if="!isMissing" text="(Root User Only) Save a NFO metadata file in your audiobooks directory" direction="bottom" class="mr-4">
<ui-btn v-if="isRootUser" :loading="savingMetadata" color="bg" type="button" class="h-full" small @click.stop.prevent="saveMetadata">Save Metadata</ui-btn>
</ui-tooltip>
<ui-tooltip :disabled="!!libraryScan" text="(Root User Only) Rescan audiobook including metadata" direction="bottom" class="ml-4">
<ui-tooltip :disabled="!!libraryScan" text="(Root User Only) Rescan audiobook including metadata" direction="bottom" class="mr-4">
<ui-btn v-if="isRootUser" :loading="rescanning" :disabled="!!libraryScan" color="bg" type="button" class="h-full" small @click.stop.prevent="rescan">Re-Scan</ui-btn>
</ui-tooltip>
<div class="flex-grow" />
<ui-btn type="submit">Submit</ui-btn>
</div>
</div>
@@ -121,6 +122,9 @@ export default {
isRootUser() {
return this.$store.getters['user/getIsRoot']
},
isMissing() {
return !!this.audiobook && !!this.audiobook.isMissing
},
audiobookId() {
return this.audiobook ? this.audiobook.id : null
},
+3 -7
View File
@@ -91,11 +91,11 @@ export default {
console.log('Reader hotkey', action)
if (!this.$refs.readerComponent) return
if (action === 'ArrowRight') {
if (action === this.$hotkeys.EReader.NEXT_PAGE) {
if (this.$refs.readerComponent.next) this.$refs.readerComponent.next()
} else if (action === 'ArrowLeft') {
} else if (action === this.$hotkeys.EReader.PREV_PAGE) {
if (this.$refs.readerComponent.prev) this.$refs.readerComponent.prev()
} else if (action === 'Escape') {
} else if (action === this.$hotkeys.EReader.CLOSE) {
this.close()
}
},
@@ -114,21 +114,17 @@ export default {
this.ebookType = 'pdf'
} else if (this.selectedAudiobookFile.ext === '.mobi' || this.selectedAudiobookFile.ext === '.azw3') {
this.ebookType = 'mobi'
// this.initMobi()
} else if (this.selectedAudiobookFile.ext === '.epub') {
this.ebookType = 'epub'
// this.initEpub()
} else if (this.selectedAudiobookFile.ext === '.cbr' || this.selectedAudiobookFile.ext === '.cbz') {
this.ebookType = 'comic'
}
} else if (this.epubEbook) {
this.ebookType = 'epub'
this.ebookUrl = this.getEbookUrl(this.epubEbook.path)
// this.initEpub()
} else if (this.mobiEbook) {
this.ebookType = 'mobi'
this.ebookUrl = this.getEbookUrl(this.mobiEbook.path)
// this.initMobi()
} else if (this.pdfEbook) {
this.ebookType = 'pdf'
this.ebookUrl = this.getEbookUrl(this.pdfEbook.path)
+33 -12
View File
@@ -177,6 +177,10 @@ export default {
userStreamUpdate(user) {
this.$store.commit('users/updateUser', user)
},
currentUserAudiobookUpdate(payload) {
// console.log('Received user audiobook update', payload)
this.$store.commit('user/updateUserAudiobook', payload)
},
downloadToastClick(download) {
if (!download || !download.audiobookId) {
return console.error('Invalid download object', download)
@@ -235,6 +239,12 @@ export default {
download.status = this.$constants.DownloadStatus.EXPIRED
this.$store.commit('downloads/addUpdateDownload', download)
},
showErrorToast(message) {
this.$toast.error(message)
},
showSuccessToast(message) {
this.$toast.success(message)
},
logEvtReceived(payload) {
this.$store.commit('logs/logEvt', payload)
},
@@ -285,6 +295,7 @@ export default {
this.socket.on('user_online', this.userOnline)
this.socket.on('user_offline', this.userOffline)
this.socket.on('user_stream_update', this.userStreamUpdate)
this.socket.on('current_user_audiobook_update', this.currentUserAudiobookUpdate)
// Scan Listeners
this.socket.on('scan_start', this.scanStart)
@@ -298,6 +309,10 @@ export default {
this.socket.on('download_killed', this.downloadKilled)
this.socket.on('download_expired', this.downloadExpired)
// Toast Listeners
this.socket.on('show_error_toast', this.showErrorToast)
this.socket.on('show_success_toast', this.showSuccessToast)
this.socket.on('log', this.logEvtReceived)
this.socket.on('backup_applied', this.backupApplied)
@@ -331,20 +346,24 @@ export default {
var inputs = ['input', 'select', 'button', 'textarea']
return activeElement && inputs.indexOf(activeElement.tagName.toLowerCase()) !== -1
},
keyUp(e) {
getHotkeyName(e) {
var keyCode = e.keyCode || e.which
if (!this.$keynames[keyCode]) {
// Unused hotkey
return
return null
}
var keyName = this.$keynames[keyCode]
var name = keyName
if (e.shiftKey) name = 'Shift-' + keyName
if (process.env.NODE_ENV !== 'production') {
console.log('Hotkey command', name)
}
return name
},
keyDown(e) {
var name = this.getHotkeyName(e)
if (!name) return
// Input is focused then ignore key press
if (this.checkActiveElementIsInput()) {
@@ -352,34 +371,36 @@ export default {
}
// Modal is open
if (this.$store.state.openModal) {
if (this.$store.state.openModal && Object.values(this.$hotkeys.Modal).includes(name)) {
this.$eventBus.$emit('modal-hotkey', name)
e.preventDefault()
return
}
// EReader is open
if (this.$store.state.showEReader) {
if (this.$store.state.showEReader && Object.values(this.$hotkeys.EReader).includes(name)) {
this.$eventBus.$emit('reader-hotkey', name)
e.preventDefault()
return
}
// Batch selecting
if (this.$store.getters['getNumAudiobooksSelected']) {
if (this.$store.getters['getNumAudiobooksSelected'] && name === 'Escape') {
// ESCAPE key cancels batch selection
if (name === 'Escape') {
this.$store.commit('setSelectedAudiobooks', [])
}
this.$store.commit('setSelectedAudiobooks', [])
e.preventDefault()
return
}
// Playing audiobook
if (this.$store.state.streamAudiobook) {
if (this.$store.state.streamAudiobook && Object.values(this.$hotkeys.AudioPlayer).includes(name)) {
this.$eventBus.$emit('player-hotkey', name)
e.preventDefault()
}
}
},
mounted() {
document.addEventListener('keyup', this.keyUp)
window.addEventListener('keydown', this.keyDown)
this.initializeSocket()
this.$store.dispatch('libraries/load')
@@ -403,7 +424,7 @@ export default {
}
},
beforeDestroy() {
document.removeEventListener('keyup', this.keyUp)
window.removeEventListener('keydown', this.keyDown)
}
}
</script>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "audiobookshelf-client",
"version": "1.4.16",
"version": "1.5.5",
"description": "Audiobook manager and player",
"main": "index.js",
"scripts": {
-10
View File
@@ -397,16 +397,6 @@ export default {
this.$store.commit('setBookshelfBookIds', [])
this.$store.commit('showEditModal', this.audiobook)
},
lookupMetadata(index) {
this.$axios
.$get(`/api/metadata/${this.audiobookId}/${index}`)
.then((metadata) => {
console.log('Metadata for ' + index, metadata)
})
.catch((error) => {
console.error(error)
})
},
audiobookUpdated() {
console.log('Audiobook Updated - Fetch full audiobook')
this.$axios
+22 -2
View File
@@ -31,12 +31,24 @@ export default {
return {
updatingServerSettings: false,
dailyBackups: true,
backupsToKeep: 2
backupsToKeep: 2,
newServerSettings: {}
}
},
watch: {
serverSettings(newVal, oldVal) {
if (newVal && !oldVal) {
this.newServerSettings = { ...this.serverSettings }
this.initServerSettings()
}
}
},
computed: {
dailyBackupsTooltip() {
return 'Runs at 1am every day (your server time). Saved in /metadata/backups.'
},
serverSettings() {
return this.$store.state.serverSettings
}
},
methods: {
@@ -63,8 +75,16 @@ export default {
console.error('Failed to update server settings', error)
this.updatingServerSettings = false
})
},
initServerSettings() {
this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {}
this.backupsToKeep = this.newServerSettings.backupsToKeep || 2
this.dailyBackups = !!this.newServerSettings.backupSchedule
}
},
mounted() {}
mounted() {
this.initServerSettings()
}
}
</script>
-2
View File
@@ -145,8 +145,6 @@ export default {
this.storeCoversInAudiobookDir = this.newServerSettings.coverDestination === this.$constants.CoverDestination.AUDIOBOOK
this.storeCoversInAudiobookDir = this.newServerSettings.coverDestination === this.$constants.CoverDestination.AUDIOBOOK
this.backupsToKeep = this.newServerSettings.backupsToKeep || 2
this.dailyBackups = !!this.newServerSettings.backupSchedule
},
resetAudiobooks() {
if (confirm('WARNING! This action will remove all audiobooks from the database including any updates or matches you have made. This does not do anything to your actual files. Shall we continue?')) {
@@ -64,6 +64,17 @@ export default {
if (this.$route.query.query !== this.searchQuery) {
this.newQuery()
}
} else if (this.id === 'series') {
if (this.selectedSeries && this.$route.query.series && this.$route.query.series !== this.$encode(this.selectedSeries)) {
// Series changed
this.selectedSeries = this.$decode(this.$route.query.series)
} else if (!this.selectedSeries && this.$route.query.series) {
// Series selected
this.selectedSeries = this.$decode(this.$route.query.series)
} else if (this.selectedSeries && !this.$route.query.series) {
// Series unselected
this.selectedSeries = null
}
}
}
},
+25
View File
@@ -25,8 +25,33 @@ const KeyNames = {
76: 'KeyL',
77: 'KeyM'
}
const Hotkeys = {
AudioPlayer: {
PLAY_PAUSE: 'Space',
JUMP_FORWARD: 'ArrowRight',
JUMP_BACKWARD: 'ArrowLeft',
VOLUME_UP: 'ArrowUp',
VOLUME_DOWN: 'ArrowDown',
MUTE_UNMUTE: 'KeyM',
SHOW_CHAPTERS: 'KeyL',
INCREASE_PLAYBACK_RATE: 'Shift-ArrowUp',
DECREASE_PLAYBACK_RATE: 'Shift-ArrowDown',
CLOSE: 'Escape'
},
EReader: {
NEXT_PAGE: 'ArrowRight',
PREV_PAGE: 'ArrowLeft',
CLOSE: 'Escape'
},
Modal: {
NEXT_PAGE: 'ArrowRight',
PREV_PAGE: 'ArrowLeft',
CLOSE: 'Escape'
}
}
export default ({ app }, inject) => {
inject('constants', Constants)
inject('keynames', KeyNames)
inject('hotkeys', Hotkeys)
}
+1 -1
View File
@@ -44,7 +44,7 @@ Vue.prototype.$secondsToTimestamp = (seconds) => {
_seconds -= _minutes * 60
var _hours = Math.floor(_minutes / 60)
_minutes -= _hours * 60
_seconds = Math.round(_seconds)
_seconds = Math.floor(_seconds)
if (!_hours) {
return `${_minutes}:${_seconds.toString().padStart(2, '0')}`
}
+10
View File
@@ -22,6 +22,11 @@ export const getters = {
getAudiobook: (state) => id => {
return state.audiobooks.find(ab => ab.id === id)
},
getAudiobooksWithIssues: (state) => {
return state.audiobooks.filter(ab => {
return ab.hasMissingParts || ab.hasInvalidParts || ab.isMissing || ab.isIncomplete
})
},
getEntitiesShowing: (state, getters, rootState, rootGetters) => () => {
if (!state.libraryPage) {
return getters.getFiltered()
@@ -66,7 +71,12 @@ export const getters = {
return false
})
}
} else if (filterBy === 'issues') {
filtered = filtered.filter(ab => {
return ab.hasMissingParts || ab.hasInvalidParts || ab.isMissing || ab.isIncomplete
})
}
if (state.keywordFilter) {
const keywordFilterKeys = ['title', 'subtitle', 'author', 'series', 'narrator']
const keyworkFilter = state.keywordFilter.toLowerCase()
+9
View File
@@ -1,4 +1,6 @@
import Vue from 'vue'
export const state = () => ({
user: null,
settings: {
@@ -80,6 +82,13 @@ export const mutations = {
localStorage.removeItem('token')
}
},
updateUserAudiobook(state, { id, data }) {
if (!state.user) return
if (!state.user.audiobooks) {
Vue.set(state.user, 'audiobooks', {})
}
Vue.set(state.user.audiobooks, id, data)
},
setSettings(state, settings) {
if (!settings) return
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "audiobookshelf",
"version": "1.4.16",
"version": "1.5.5",
"description": "Self-hosted audiobook server for managing and playing audiobooks",
"main": "index.js",
"scripts": {
+49 -17
View File
@@ -39,8 +39,6 @@ Android app is in beta, try it out on the [Google Play Store](https://play.googl
See [documentation](https://audiobookshelf.org/docs) for supported directory structure, folder naming conventions, and audio file metadata usage.
## Installation
** Default username is "root" with no password
@@ -103,23 +101,11 @@ System Service: `/lib/systemd/system/audiobookshelf.service`
Ffmpeg static build: `/usr/lib/audiobookshelf-ffmpeg/`
## Run from source
## Reverse Proxy Set Up
Note: you will need `npm`, `node12`, and `ffmpeg` to run this project locally
### NGINX Reverse Proxy
```bash
git clone https://github.com/advplyr/audiobookshelf.git
cd audiobookshelf
# All paths default to root directory. Config path is the database.
# Directories will be created if they don't exist
# Paths are relative to the root directory, so "../Audiobooks" would be a valid path
npm run prod -- -p [PORT] --audiobooks [AUDIOBOOKS_PATH] --config [CONFIG_PATH] --metadata [METADATA_PATH]
```
### nginx Reverse Proxy
Add this to the site config file on your nginx server after you have changed the relevant parts in the <> brackets, and inserted the path to you certificates.
Add this to the site config file on your nginx server after you have changed the relevant parts in the <> brackets, and inserted your certificate paths.
```bash
@@ -149,6 +135,52 @@ server
}
```
### Apache Reverse Proxy
Add this to the site config file on your Apache server after you have changed the relevant parts in the <> brackets, and inserted your certificate paths.
For this to work you must enable at least the following mods using `a2enmod`:
- `ssl`
- `proxy_module`
- `proxy_wstunnel_module`
- `rewrite_module`
```bash
<IfModule mod_ssl.c>
<VirtualHost *:443>
ServerName <sub>.<domain>.<tld>
ErrorLog ${APACHE_LOG_DIR}/error.log
CustomLog ${APACHE_LOG_DIR}/access.log combined
ProxyPreserveHost On
ProxyPass / http://localhost:<audiobookshelf_port>/
RewriteEngine on
RewriteCond %{HTTP:Upgrade} websocket [NC]
RewriteCond %{HTTP:Connection} upgrade [NC]
RewriteRule ^/?(.*) "ws://localhost:<audiobookshelf_port>/$1" [P,L]
# unless you're doing something special this should be generated by a
# tool like certbot by let's encrypt
SSLCertificateFile /path/to/cert/file
SSLCertificateKeyFile /path/to/key/file
</VirtualHost>
</IfModule>
```
## Run from source
Note: you will need `npm`, `node12`, and `ffmpeg` to run this project locally
```bash
git clone https://github.com/advplyr/audiobookshelf.git
cd audiobookshelf
# All paths default to root directory. Config path is the database.
# Directories will be created if they don't exist
# Paths are relative to the root directory, so "../Audiobooks" would be a valid path
npm run prod -- -p [PORT] --audiobooks [AUDIOBOOKS_PATH] --config [CONFIG_PATH] --metadata [METADATA_PATH]
```
## Contributing
+92 -5
View File
@@ -11,6 +11,7 @@ const { version } = require('../package.json')
// Utils
const { ScanResult } = require('./utils/constants')
const filePerms = require('./utils/filePerms')
const { secondsToTimestamp } = require('./utils/fileUtils')
const Logger = require('./Logger')
// Classes
@@ -46,7 +47,7 @@ class Server {
this.watcher = new Watcher(this.AudiobookPath)
this.coverController = new CoverController(this.db, this.MetadataPath, this.AudiobookPath)
this.scanner = new Scanner(this.AudiobookPath, this.MetadataPath, this.db, this.coverController, this.emitter.bind(this))
this.streamManager = new StreamManager(this.db, this.MetadataPath, this.emitter.bind(this))
this.streamManager = new StreamManager(this.db, this.MetadataPath, this.emitter.bind(this), this.clientEmitter.bind(this))
this.rssFeeds = new RssFeeds(this.Port, this.db)
this.downloadManager = new DownloadManager(this.db, this.MetadataPath, this.AudiobookPath, this.emitter.bind(this))
this.apiController = new ApiController(this.MetadataPath, this.db, this.scanner, this.auth, this.streamManager, this.rssFeeds, this.downloadManager, this.coverController, this.backupManager, this.watcher, this.emitter.bind(this), this.clientEmitter.bind(this))
@@ -86,7 +87,6 @@ class Server {
clientEmitter(userId, ev, data) {
var clients = this.getClientsForUser(userId)
if (!clients.length) {
console.log('clients', clients)
return Logger.error(`[Server] clientEmitter - no clients found for user ${userId}`)
}
clients.forEach((client) => {
@@ -247,7 +247,7 @@ class Server {
socket.on('close_stream', () => this.streamManager.closeStreamRequest(socket))
socket.on('stream_update', (payload) => this.streamManager.streamUpdate(socket, payload))
socket.on('progress_update', (payload) => this.audiobookProgressUpdate(socket.sheepClient, payload))
socket.on('progress_update', (payload) => this.audiobookProgressUpdate(socket, payload))
// Downloading
socket.on('download', (payload) => this.downloadManager.downloadSocketRequest(socket, payload))
@@ -260,6 +260,11 @@ class Server {
socket.on('create_backup', () => this.backupManager.requestCreateBackup(socket))
socket.on('apply_backup', (id) => this.backupManager.requestApplyBackup(socket, id))
// Bookmarks
socket.on('create_bookmark', (payload) => this.createBookmark(socket, payload))
socket.on('update_bookmark', (payload) => this.updateBookmark(socket, payload))
socket.on('delete_bookmark', (payload) => this.deleteBookmark(socket, payload))
socket.on('test', () => {
socket.emit('test_received', socket.id)
})
@@ -451,12 +456,94 @@ class Server {
res.sendStatus(200)
}
audiobookProgressUpdate(client, progressPayload) {
async audiobookProgressUpdate(socket, progressPayload) {
var client = socket.sheepClient
if (!client || !client.user) {
Logger.error('[Server] audiobookProgressUpdate invalid socket client')
return
}
client.user.updateAudiobookProgress(progressPayload.audiobookId, progressPayload)
var audiobookProgress = client.user.updateAudiobookProgress(progressPayload.audiobookId, progressPayload)
if (audiobookProgress) {
await this.db.updateEntity('user', client.user)
// This audiobook progress is out of date, why?
// var userAudiobook = client.user.getAudiobookJSON(progressPayload.audiobookId)
// Logger.debug(`[Server] Emitting audiobook progress update to clients ${this.getClientsForUser(client.user.id).length}: ${JSON.stringify(userAudiobook)}`)
this.clientEmitter(client.user.id, 'current_user_audiobook_update', {
id: progressPayload.audiobookId,
data: audiobookProgress || null
})
}
}
async createBookmark(socket, payload) {
var client = socket.sheepClient
if (!client || !client.user) {
Logger.error('[Server] createBookmark invalid socket client')
return
}
var userAudiobook = client.user.createBookmark(payload)
if (!userAudiobook || userAudiobook.error) {
var failMessage = (userAudiobook ? userAudiobook.error : null) || 'Unknown Error'
socket.emit('show_error_toast', `Failed to create Bookmark: ${failMessage}`)
return
}
await this.db.updateEntity('user', client.user)
socket.emit('show_success_toast', `${secondsToTimestamp(payload.time)} Bookmarked`)
this.clientEmitter(client.user.id, 'current_user_audiobook_update', {
id: userAudiobook.audiobookId,
data: userAudiobook || null
})
}
async updateBookmark(socket, payload) {
var client = socket.sheepClient
if (!client || !client.user) {
Logger.error('[Server] updateBookmark invalid socket client')
return
}
var userAudiobook = client.user.updateBookmark(payload)
if (!userAudiobook || userAudiobook.error) {
var failMessage = (userAudiobook ? userAudiobook.error : null) || 'Unknown Error'
socket.emit('show_error_toast', `Failed to update Bookmark: ${failMessage}`)
return
}
await this.db.updateEntity('user', client.user)
socket.emit('show_success_toast', `Bookmark ${secondsToTimestamp(payload.time)} Updated`)
this.clientEmitter(client.user.id, 'current_user_audiobook_update', {
id: userAudiobook.audiobookId,
data: userAudiobook || null
})
}
async deleteBookmark(socket, payload) {
var client = socket.sheepClient
if (!client || !client.user) {
Logger.error('[Server] deleteBookmark invalid socket client')
return
}
var userAudiobook = client.user.deleteBookmark(payload)
if (!userAudiobook || userAudiobook.error) {
var failMessage = (userAudiobook ? userAudiobook.error : null) || 'Unknown Error'
socket.emit('show_error_toast', `Failed to delete Bookmark: ${failMessage}`)
return
}
await this.db.updateEntity('user', client.user)
socket.emit('show_success_toast', `Bookmark ${secondsToTimestamp(payload.time)} Removed`)
this.clientEmitter(client.user.id, 'current_user_audiobook_update', {
id: userAudiobook.audiobookId,
data: userAudiobook || null
})
}
async authenticateSocket(socket, token) {
+10 -2
View File
@@ -5,10 +5,11 @@ const fs = require('fs-extra')
const Path = require('path')
class StreamManager {
constructor(db, MetadataPath, emitter) {
constructor(db, MetadataPath, emitter, clientEmitter) {
this.db = db
this.emitter = emitter
this.clientEmitter = clientEmitter
this.MetadataPath = MetadataPath
this.streams = []
@@ -153,8 +154,15 @@ class StreamManager {
Logger.error('Invalid User for client', client)
return
}
client.user.updateAudiobookProgressFromStream(client.stream)
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()
})
}
}
}
module.exports = StreamManager
+32
View File
@@ -0,0 +1,32 @@
class AudioBookmark {
constructor(bookmark) {
this.title = null
this.time = null
this.createdAt = null
if (bookmark) {
this.construct(bookmark)
}
}
toJSON() {
return {
title: this.title || '',
time: this.time,
createdAt: this.createdAt
}
}
construct(bookmark) {
this.title = bookmark.title || ''
this.time = bookmark.time || 0
this.createdAt = bookmark.createdAt
}
setData(time, title) {
this.title = title
this.time = time
this.createdAt = Date.now()
}
}
module.exports = AudioBookmark
+6 -6
View File
@@ -193,16 +193,14 @@ class Audiobook {
lastUpdate: this.lastUpdate,
duration: this.totalDuration,
size: this.totalSize,
hasBookMatch: !!this.book,
hasMissingParts: this.missingParts ? this.missingParts.length : 0,
hasInvalidParts: this.invalidParts ? this.invalidParts.length : 0,
// numEbooks: this.ebooks.length,
ebooks: this.ebooks.map(ebook => ebook.toJSON()),
numEbooks: this.ebooks.length,
numTracks: this.tracks.length,
chapters: this.chapters || [],
isMissing: !!this.isMissing,
isInvalid: !!this.isInvalid
isInvalid: !!this.isInvalid,
hasMissingParts: this.missingParts ? this.missingParts.length : 0,
hasInvalidParts: this.invalidParts ? this.invalidParts.length : 0
}
}
@@ -232,7 +230,9 @@ class Audiobook {
tracks: this.tracksToJSON(),
chapters: this.chapters || [],
isMissing: !!this.isMissing,
isInvalid: !!this.isInvalid
isInvalid: !!this.isInvalid,
hasMissingParts: this.missingParts ? this.missingParts.length : 0,
hasInvalidParts: this.invalidParts ? this.invalidParts.length : 0
}
}
+42 -6
View File
@@ -1,3 +1,6 @@
const Logger = require('../Logger')
const AudioBookmark = require('./AudioBookmark')
class AudiobookProgress {
constructor(progress) {
this.audiobookId = null
@@ -10,12 +13,18 @@ class AudiobookProgress {
this.lastUpdate = null
this.startedAt = null
this.finishedAt = null
this.bookmarks = []
if (progress) {
this.construct(progress)
}
}
bookmarksToJSON() {
if (!this.bookmarks) return []
return this.bookmarks.map(b => b.toJSON())
}
toJSON() {
return {
audiobookId: this.audiobookId,
@@ -25,7 +34,8 @@ class AudiobookProgress {
isRead: this.isRead,
lastUpdate: this.lastUpdate,
startedAt: this.startedAt,
finishedAt: this.finishedAt
finishedAt: this.finishedAt,
bookmarks: this.bookmarksToJSON()
}
}
@@ -38,6 +48,11 @@ class AudiobookProgress {
this.lastUpdate = progress.lastUpdate
this.startedAt = progress.startedAt
this.finishedAt = progress.finishedAt || null
if (progress.bookmarks) {
this.bookmarks = progress.bookmarks.map(b => new AudioBookmark(b))
} else {
this.bookmarks = []
}
}
updateFromStream(stream) {
@@ -54,11 +69,9 @@ class AudiobookProgress {
// If has < 10 seconds remaining mark as read
var timeRemaining = this.totalDuration - this.currentTime
if (timeRemaining < 10) {
if (!this.isRead) {
this.isRead = true
this.progress = 1
this.finishedAt = Date.now()
}
this.isRead = true
this.progress = 1
this.finishedAt = Date.now()
} else {
this.isRead = false
this.finishedAt = null
@@ -67,6 +80,7 @@ class AudiobookProgress {
update(payload) {
var hasUpdates = false
Logger.debug(`[AudiobookProgress] Update called ${JSON.stringify(payload)}`)
for (const key in payload) {
if (payload[key] !== this[key]) {
if (key === 'isRead') {
@@ -92,5 +106,27 @@ class AudiobookProgress {
}
return hasUpdates
}
checkBookmarkExists(time) {
return this.bookmarks.find(bm => bm.time === time)
}
createBookmark(time, title) {
var newBookmark = new AudioBookmark()
newBookmark.setData(time, title)
this.bookmarks.push(newBookmark)
return newBookmark
}
updateBookmark(time, title) {
var bookmark = this.bookmarks.find(bm => bm.time === time)
if (!bookmark) return false
bookmark.title = title
return bookmark
}
deleteBookmark(time) {
this.bookmarks = this.bookmarks.filter(bm => bm.time !== time)
}
}
module.exports = AudiobookProgress
+36 -15
View File
@@ -16,6 +16,7 @@ class Stream extends EventEmitter {
this.audiobook = audiobook
this.segmentLength = 6
this.maxSeekBackTime = 30
this.streamPath = Path.join(streamPath, this.id)
this.concatFilesPath = Path.join(this.streamPath, 'files.txt')
this.playlistPath = Path.join(this.streamPath, 'output.m3u8')
@@ -67,7 +68,7 @@ class Stream extends EventEmitter {
get segmentStartNumber() {
if (!this.startTime) return 0
return Math.floor(this.startTime / this.segmentLength)
return Math.floor(Math.max(this.startTime - this.maxSeekBackTime, 0) / this.segmentLength)
}
get numSegments() {
@@ -82,13 +83,26 @@ class Stream extends EventEmitter {
return this.audiobook.tracks
}
get clientUser() {
return this.client.user || {}
}
get clientUserAudiobooks() {
return this.clientUser.audiobooks || {}
}
get clientUserAudiobookData() {
return this.clientUserAudiobooks[this.audiobookId]
}
get clientPlaylistUri() {
return `/hls/${this.id}/output.m3u8`
}
get clientProgress() {
if (!this.clientCurrentTime) return 0
return Number((this.clientCurrentTime / this.totalDuration).toFixed(3))
var prog = Math.min(1, this.clientCurrentTime / this.totalDuration)
return Number(prog.toFixed(3))
}
toJSON() {
@@ -103,18 +117,17 @@ class Stream extends EventEmitter {
clientCurrentTime: this.clientCurrentTime,
startTime: this.startTime,
segmentStartNumber: this.segmentStartNumber,
isTranscodeComplete: this.isTranscodeComplete
isTranscodeComplete: this.isTranscodeComplete,
lastUpdate: this.clientUserAudiobookData ? this.clientUserAudiobookData.lastUpdate : 0
}
}
init() {
var clientUserAudiobooks = this.client.user ? this.client.user.audiobooks || {} : {}
var userAudiobook = clientUserAudiobooks[this.audiobookId] || null
if (userAudiobook) {
var timeRemaining = this.totalDuration - userAudiobook.currentTime
Logger.info('[STREAM] User has progress for audiobook', userAudiobook.progress, `Time Remaining: ${timeRemaining}s`)
if (this.clientUserAudiobookData) {
var timeRemaining = this.totalDuration - this.clientUserAudiobookData.currentTime
Logger.info('[STREAM] User has progress for audiobook', this.clientUserAudiobookData.progress, `Time Remaining: ${timeRemaining}s`)
if (timeRemaining > 15) {
this.startTime = userAudiobook.currentTime
this.startTime = this.clientUserAudiobookData.currentTime
this.clientCurrentTime = this.startTime
}
}
@@ -238,20 +251,28 @@ class Stream extends EventEmitter {
this.ffmpeg = Ffmpeg()
var trackStartTime = await writeConcatFile(this.tracks, this.concatFilesPath, this.startTime)
var adjustedStartTime = Math.max(this.startTime - this.maxSeekBackTime, 0)
var trackStartTime = await writeConcatFile(this.tracks, this.concatFilesPath, adjustedStartTime)
this.ffmpeg.addInput(this.concatFilesPath)
// seek_timestamp : https://ffmpeg.org/ffmpeg.html
// the argument to the -ss option is considered an actual timestamp, and is not offset by the start time of the file
// fixes https://github.com/advplyr/audiobookshelf/issues/116
this.ffmpeg.inputOption('-seek_timestamp 1')
this.ffmpeg.inputFormat('concat')
this.ffmpeg.inputOption('-safe 0')
if (this.startTime > 0) {
const shiftedStartTime = this.startTime - trackStartTime
Logger.info(`[STREAM] Starting Stream at startTime ${secondsToTimestamp(this.startTime)} and Segment #${this.segmentStartNumber}`)
this.ffmpeg.inputOption(`-ss ${shiftedStartTime}`)
if (adjustedStartTime > 0) {
const shiftedStartTime = adjustedStartTime - trackStartTime
// Issues using exact fractional seconds i.e. 29.49814 - changing to 29.5s
var startTimeS = Math.round(shiftedStartTime * 10) / 10 + 's'
Logger.info(`[STREAM] Starting Stream at startTime ${secondsToTimestamp(adjustedStartTime)} (User startTime ${secondsToTimestamp(this.startTime)}) and Segment #${this.segmentStartNumber}`)
this.ffmpeg.inputOption(`-ss ${startTimeS}`)
this.ffmpeg.inputOption('-noaccurate_seek')
}
const logLevel = process.env.NODE_ENV === 'production' ? 'error' : 'warning'
const logLevel = process.env.NODE_ENV === 'production' ? 'error' : 'error'
const audioCodec = (this.hlsSegmentType === 'fmp4' || this.tracksAudioFileType === 'opus') ? 'aac' : 'copy'
this.ffmpeg.addOption([
`-loglevel ${logLevel}`,
+62 -1
View File
@@ -1,3 +1,4 @@
const Logger = require('../Logger')
const AudiobookProgress = require('./AudiobookProgress')
class User {
@@ -203,6 +204,7 @@ class User {
this.audiobooks[stream.audiobookId] = new AudiobookProgress()
}
this.audiobooks[stream.audiobookId].updateFromStream(stream)
return this.audiobooks[stream.audiobookId]
}
updateAudiobookProgress(audiobook, updatePayload) {
@@ -211,7 +213,12 @@ class User {
this.audiobooks[audiobook.id] = new AudiobookProgress()
this.audiobooks[audiobook.id].audiobookId = audiobook.id
}
return this.audiobooks[audiobook.id].update(updatePayload)
var wasUpdated = this.audiobooks[audiobook.id].update(updatePayload)
if (wasUpdated) {
Logger.debug(`[User] Audiobook progress was updated ${JSON.stringify(this.audiobooks[audiobook.id])}`)
return this.audiobooks[audiobook.id]
}
return false
}
// Returns Boolean If update was made
@@ -267,5 +274,59 @@ class User {
if (!this.librariesAccessible) return false
return this.librariesAccessible.includes(libraryId)
}
getAudiobookJSON(audiobookId) {
return this.audiobooks[audiobookId] ? this.audiobooks[audiobookId].toJSON() : null
}
createBookmark({ audiobookId, time, title }) {
if (!this.audiobooks || !this.audiobooks[audiobookId]) {
return {
error: 'Invalid Audiobook'
}
}
if (this.audiobooks[audiobookId].checkBookmarkExists(time)) {
return {
error: 'Bookmark already exists'
}
}
var success = this.audiobooks[audiobookId].createBookmark(time, title)
if (success) return this.audiobooks[audiobookId]
return null
}
updateBookmark({ audiobookId, time, title }) {
if (!this.audiobooks || !this.audiobooks[audiobookId]) {
return {
error: 'Invalid Audiobook'
}
}
if (!this.audiobooks[audiobookId].checkBookmarkExists(time)) {
return {
error: 'Bookmark does not exist'
}
}
var success = this.audiobooks[audiobookId].updateBookmark(time, title)
if (success) return this.audiobooks[audiobookId]
return null
}
deleteBookmark({ audiobookId, time }) {
if (!this.audiobooks || !this.audiobooks[audiobookId]) {
return {
error: 'Invalid Audiobook'
}
}
if (!this.audiobooks[audiobookId].checkBookmarkExists(time)) {
return {
error: 'Bookmark does not exist'
}
}
this.audiobooks[audiobookId].deleteBookmark(time)
return this.audiobooks[audiobookId]
}
}
module.exports = User
+1 -1
View File
@@ -69,7 +69,7 @@ function secondsToTimestamp(seconds) {
_seconds -= _minutes * 60
var _hours = Math.floor(_minutes / 60)
_minutes -= _hours * 60
_seconds = Math.round(_seconds)
_seconds = Math.floor(_seconds)
if (!_hours) {
return `${_minutes}:${_seconds.toString().padStart(2, '0')}`
}