Compare commits

...

39 Commits

Author SHA1 Message Date
advplyr d38d4dcd62 Version bump 1.6.0 2021-10-31 15:37:53 -05:00
advplyr 724aa67cf4 Add: Bookshelf list view no longer experimental 2021-10-31 15:35:13 -05:00
advplyr 5a0eb6d52b Add: Bookmarks no longer experimental 2021-10-31 15:33:27 -05:00
advplyr bf9cb5ddec Fix: List view overflowing titles #149 2021-10-31 15:31:03 -05:00
advplyr e073577574 Add: Support for multiple authors separated by & #160 2021-10-31 14:59:39 -05:00
advplyr 94f47b855b Fix: docker compose example 2021-10-31 09:58:30 -05:00
advplyr 8a684ccdc4 Add: Docker compose example and readme #164 2021-10-31 09:54:58 -05:00
advplyr e564c80ed2 Add: Bookshelf list link to audiobook page 2021-10-30 20:12:36 -05:00
advplyr 729654f5b2 Add: Experimental list view #149 2021-10-30 18:50:49 -05:00
advplyr 6fd3317454 Add: experimental library list view #149, Add: experimental series fan out on hover #149 2021-10-29 20:42:28 -05:00
advplyr 9057afb5ee Version update 2021-10-28 19:39:51 -05:00
advplyr 7933eb369e Change: Series show first cover on top #149 2021-10-28 19:34:29 -05:00
advplyr 866ee18016 Fix: backups toast message on successful backup #101 2021-10-28 17:54:53 -05:00
advplyr ff92fbb849 Change: AudiobookProgress becomes UserAudiobookData, Change: Delete audiobook progress route 2021-10-28 17:19:09 -05:00
advplyr ad4dad1c29 Add: experimental match tab with google books search #59, Add: isbn field for books #59 2021-10-28 14:41:42 -05:00
advplyr 7c1789a7c2 Fix: filter out invalid bookmarks 2021-10-28 06:35:22 -05:00
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
62 changed files with 1874 additions and 289 deletions
+3
View File
@@ -19,6 +19,9 @@
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar:horizontal {
height: 8px;
}
/* ::-webkit-scrollbar:horizontal { */
/* height: 16px; */
/* height: 24px;
+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 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="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()
+127
View File
@@ -0,0 +1,127 @@
<template>
<div class="outer-container">
<!-- absolute positioned container -->
<div class="inner-container">
<div class="relative h-10">
<div class="table-header" id="headerdiv">
<table id="headertable" width="100%" cellpadding="0" cellspacing="0">
<thead>
<tr>
<th class="header-cell min-w-12 max-w-12"></th>
<th class="header-cell min-w-6 max-w-6"></th>
<th class="header-cell min-w-64 max-w-64 px-2">Title</th>
<th class="header-cell min-w-48 max-w-48 px-2">Author</th>
<th class="header-cell min-w-48 max-w-48 px-2">Series</th>
<th class="header-cell min-w-24 max-w-24 px-2">Year</th>
<th class="header-cell min-w-80 max-w-80 px-2">Description</th>
<th class="header-cell min-w-48 max-w-48 px-2">Narrator</th>
<th class="header-cell min-w-48 max-w-48 px-2">Genres</th>
<th class="header-cell min-w-48 max-w-48 px-2">Tags</th>
<th class="header-cell min-w-24 max-w-24 px-2"></th>
</tr>
</thead>
</table>
</div>
<div class="absolute top-0 left-0 w-full h-full pointer-events-none" :class="isScrollable ? 'header-shadow' : ''" />
</div>
<div ref="tableBody" class="table-body" onscroll="document.getElementById('headerdiv').scrollLeft = this.scrollLeft;" @scroll="tableScrolled">
<table id="bodytable" width="100%" cellpadding="0" cellspacing="0">
<tbody>
<template v-for="book in books">
<app-book-list-row :key="book.id" :book="book" @edit="editBook" />
</template>
</tbody>
</table>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
books: {
type: Array,
default: () => []
}
},
data() {
return {
isScrollable: false
}
},
computed: {},
methods: {
checkIsScrolled() {
if (!this.$refs.tableBody) return
this.isScrollable = this.$refs.tableBody.scrollTop > 0
},
tableScrolled() {
this.checkIsScrolled()
},
editBook(book) {
var bookIds = this.books.map((e) => e.id)
this.$store.commit('setBookshelfBookIds', bookIds)
this.$store.commit('showEditModal', book)
}
},
mounted() {
this.checkIsScrolled()
},
beforeDestroy() {}
}
</script>
<style>
.outer-container {
position: absolute;
top: 0;
left: 0;
overflow: visible;
height: calc(100% - 50px);
width: calc(100% - 10px);
margin: 10px;
}
.inner-container {
width: 100%;
height: 100%;
position: relative;
}
.table-header {
float: left;
overflow: hidden;
width: 100%;
}
.header-shadow {
box-shadow: 3px 8px 3px #11111155;
}
.table-body {
float: left;
height: 100%;
width: inherit;
overflow-y: scroll;
padding-right: 0px;
}
.header-cell {
background-color: #22222288;
padding: 0px 4px;
text-align: left;
height: 40px;
font-size: 0.9rem;
font-weight: semi-bold;
}
.body-cell {
text-align: left;
font-size: 0.9rem;
}
.book-row {
background-color: #22222288;
}
.book-row:nth-child(odd) {
background-color: #333;
}
.book-row.selected {
background-color: rgba(0, 255, 0, 0.05);
}
</style>
+186
View File
@@ -0,0 +1,186 @@
<template>
<tr class="book-row" :class="selected ? 'selected' : ''">
<td class="body-cell min-w-12 max-w-12">
<div class="flex justify-center">
<div class="bg-white border-2 rounded border-gray-400 flex flex-shrink-0 justify-center items-center focus-within:border-blue-500 w-4 h-4" @click="selectBtnClick">
<svg v-if="selected" class="fill-current text-green-500 pointer-events-none w-2.5 h-2.5" viewBox="0 0 20 20"><path d="M0 11l2-2 5 5L18 3l2 2L7 18z" /></svg>
</div>
</div>
</td>
<td class="body-cell min-w-6 max-w-6">
<cards-hover-book-cover :audiobook="book" />
</td>
<td class="body-cell min-w-64 max-w-64 px-2">
<nuxt-link :to="`/audiobook/${book.id}`" class="hover:underline">
<p class="truncate">
{{ book.book.title }}<span v-if="book.book.subtitle">: {{ book.book.subtitle }}</span>
</p>
</nuxt-link>
</td>
<td class="body-cell min-w-48 max-w-48 px-2">
<p class="truncate">{{ book.book.authorFL }}</p>
</td>
<td class="body-cell min-w-48 max-w-48 px-2">
<p class="truncate">{{ seriesText }}</p>
</td>
<td class="body-cell min-w-24 max-w-24 px-2">
<p class="truncate">{{ book.book.publishYear }}</p>
</td>
<td class="body-cell min-w-80 max-w-80 px-2">
<p class="truncate">{{ book.book.description }}</p>
</td>
<td class="body-cell min-w-48 max-w-48 px-2">
<p class="truncate">{{ book.book.narrator }}</p>
</td>
<td class="body-cell min-w-48 max-w-48 px-2">
<p class="truncate">{{ genresText }}</p>
</td>
<td class="body-cell min-w-48 max-w-48 px-2">
<p class="truncate">{{ tagsText }}</p>
</td>
<td class="body-cell min-w-24 max-w-24 px-2">
<div class="flex">
<span v-if="userCanUpdate" class="material-icons cursor-pointer text-white text-opacity-60 hover:text-opacity-100 text-xl" @click="editClick">edit</span>
<span v-if="showPlayButton" class="material-icons cursor-pointer text-white text-opacity-60 hover:text-opacity-100 text-2xl mx-1" @click="startStream">play_arrow</span>
<span v-if="showReadButton" class="material-icons cursor-pointer text-white text-opacity-60 hover:text-opacity-100 text-xl" @click="openEbook">auto_stories</span>
</div>
</td>
</tr>
</template>
<script>
export default {
props: {
book: {
type: Object,
default: () => {}
},
userAudiobook: {
type: Object,
default: () => {}
}
},
data() {
return {
isProcessingReadUpdate: false
}
},
computed: {
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
},
audiobookId() {
return this.book.id
},
isSelectionMode() {
return !!this.selectedAudiobooks.length
},
selectedAudiobooks() {
return this.$store.state.selectedAudiobooks
},
selected: {
get() {
return this.$store.getters['getIsAudiobookSelected'](this.audiobookId)
},
set(val) {
if (this.processingBatch) return
this.$store.commit('setAudiobookSelected', { audiobookId: this.audiobookId, selected: val })
}
},
processingBatch() {
return this.$store.state.processingBatch
},
bookObj() {
return this.book.book || {}
},
series() {
return this.bookObj.series || null
},
volumeNumber() {
return this.bookObj.volumeNumber || null
},
seriesText() {
if (!this.series) return ''
if (!this.volumeNumber) return this.series
return `${this.series} #${this.volumeNumber}`
},
genresText() {
if (!this.bookObj.genres) return ''
return this.bookObj.genres.join(', ')
},
tagsText() {
return (this.book.tags || []).join(', ')
},
isMissing() {
return this.book.isMissing
},
isIncomplete() {
return this.book.isIncomplete
},
numEbooks() {
return this.book.numEbooks
},
numTracks() {
return this.book.numTracks
},
isStreaming() {
return this.$store.getters['getAudiobookIdStreaming'] === this.audiobookId
},
showReadButton() {
return this.showExperimentalFeatures && this.numEbooks
},
showPlayButton() {
return !this.isMissing && !this.isIncomplete && this.numTracks && !this.isStreaming
},
userIsRead() {
return this.userAudiobook ? !!this.userAudiobook.isRead : false
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
},
userCanDelete() {
return this.$store.getters['user/getUserCanDelete']
},
userCanDownload() {
return this.$store.getters['user/getUserCanDownload']
}
},
methods: {
selectBtnClick() {
if (this.processingBatch) return
this.$store.commit('toggleAudiobookSelected', this.audiobookId)
},
openEbook() {
this.$store.commit('showEReader', this.book)
},
downloadClick() {
this.$store.commit('showEditModalOnTab', { audiobook: this.book, tab: 'download' })
},
toggleRead() {
var updatePayload = {
isRead: !this.userIsRead
}
this.isProcessingReadUpdate = true
this.$axios
.$patch(`/api/user/audiobook/${this.audiobookId}`, updatePayload)
.then(() => {
this.isProcessingReadUpdate = false
this.$toast.success(`"${this.title}" Marked as ${updatePayload.isRead ? 'Read' : 'Not Read'}`)
})
.catch((error) => {
console.error('Failed', error)
this.isProcessingReadUpdate = false
this.$toast.error(`Failed to mark as ${updatePayload.isRead ? 'Read' : 'Not Read'}`)
})
},
startStream() {
this.$store.commit('setStreamAudiobook', this.book)
this.$root.socket.emit('open_stream', this.book.id)
},
editClick() {
this.$emit('edit', this.book)
}
},
mounted() {}
}
</script>
+56 -39
View File
@@ -1,47 +1,56 @@
<template>
<div id="bookshelf" ref="wrapper" class="w-full h-full overflow-y-scroll relative">
<!-- Cover size widget -->
<div v-show="!isSelectionMode" class="fixed bottom-2 right-4 z-30">
<div class="rounded-full py-1 bg-primary px-2 border border-black-100 text-center flex items-center box-shadow-md" @mousedown.prevent @mouseup.prevent>
<span class="material-icons" :class="selectedSizeIndex === 0 ? 'text-gray-400' : 'hover:text-yellow-300 cursor-pointer'" style="font-size: 0.9rem" @mousedown.prevent @click="decreaseSize">remove</span>
<p class="px-2 font-mono">{{ bookCoverWidth }}</p>
<span class="material-icons" :class="selectedSizeIndex === availableSizes.length - 1 ? 'text-gray-400' : 'hover:text-yellow-300 cursor-pointer'" style="font-size: 0.9rem" @mousedown.prevent @click="increaseSize">add</span>
<div class="bookshelf overflow-hidden relative block max-h-full">
<div ref="wrapper" class="h-full w-full relative" :class="isGridMode ? 'overflow-y-scroll' : 'overflow-hidden'">
<!-- Cover size widget -->
<div v-show="!isSelectionMode && isGridMode" class="fixed bottom-2 right-4 z-30">
<div class="rounded-full py-1 bg-primary px-2 border border-black-100 text-center flex items-center box-shadow-md" @mousedown.prevent @mouseup.prevent>
<span class="material-icons" :class="selectedSizeIndex === 0 ? 'text-gray-400' : 'hover:text-yellow-300 cursor-pointer'" style="font-size: 0.9rem" @mousedown.prevent @click="decreaseSize">remove</span>
<p class="px-2 font-mono">{{ bookCoverWidth }}</p>
<span class="material-icons" :class="selectedSizeIndex === availableSizes.length - 1 ? 'text-gray-400' : 'hover:text-yellow-300 cursor-pointer'" style="font-size: 0.9rem" @mousedown.prevent @click="increaseSize">add</span>
</div>
</div>
</div>
<div v-if="!audiobooks.length" class="w-full flex flex-col items-center justify-center py-12">
<p class="text-center text-2xl font-book mb-4 py-4">Your Audiobookshelf is empty!</p>
<div class="flex">
<ui-btn to="/config" color="primary" class="w-52 mr-2" @click="scan">Configure Scanner</ui-btn>
<ui-btn color="success" class="w-52" @click="scan">Scan Audiobooks</ui-btn>
<div v-if="!audiobooks.length" class="w-full flex flex-col items-center justify-center py-12">
<p class="text-center text-2xl font-book mb-4 py-4">Your Audiobookshelf is empty!</p>
<div class="flex">
<ui-btn to="/config" color="primary" class="w-52 mr-2" @click="scan">Configure Scanner</ui-btn>
<ui-btn color="success" class="w-52" @click="scan">Scan Audiobooks</ui-btn>
</div>
</div>
</div>
<div v-else-if="page === 'search'" id="bookshelf-categorized" class="w-full flex flex-col items-center">
<template v-for="(shelf, index) in categorizedShelves">
<app-book-shelf-row :key="index" :index="index" :shelf="shelf" :size-multiplier="sizeMultiplier" :book-cover-width="bookCoverWidth" />
</template>
<div v-show="!categorizedShelves.length" class="w-full py-16 text-center text-xl">
<div class="py-4 mb-6"><p class="text-2xl">No Results</p></div>
<div v-else-if="page === 'search'" id="bookshelf-categorized" class="w-full flex flex-col items-center">
<template v-for="(shelf, index) in categorizedShelves">
<app-book-shelf-row :key="index" :index="index" :shelf="shelf" :size-multiplier="sizeMultiplier" :book-cover-width="bookCoverWidth" />
</template>
<div v-show="!categorizedShelves.length" class="w-full py-16 text-center text-xl">
<div class="py-4 mb-6"><p class="text-2xl">No Results</p></div>
</div>
</div>
</div>
<div v-else id="bookshelf" class="w-full flex flex-col items-center">
<template v-for="(shelf, index) in shelves">
<div :key="index" class="w-full bookshelfRow relative">
<div class="flex justify-center items-center">
<template v-for="entity in shelf">
<cards-group-card v-if="showGroups" :key="entity.id" :width="bookCoverWidth" :group="entity" @click="clickGroup" />
<!-- <cards-book-3d :key="entity.id" v-else :width="100" :src="$store.getters['audiobooks/getBookCoverSrc'](entity.book)" /> -->
<cards-book-card v-else :key="entity.id" :show-volume-number="!!selectedSeries" :width="bookCoverWidth" :user-progress="userAudiobooks[entity.id]" :audiobook="entity" @edit="editBook" />
<div v-else class="w-full">
<template v-if="viewMode === 'grid'">
<div class="w-full flex flex-col items-center">
<template v-for="(shelf, index) in shelves">
<div :key="index" class="w-full bookshelfRow relative">
<div class="flex justify-center items-center">
<template v-for="entity in shelf">
<cards-group-card v-if="showGroups" :key="entity.id" :width="bookCoverWidth" :group="entity" @click="clickGroup" />
<!-- <cards-book-3d :key="entity.id" v-else :width="100" :src="$store.getters['audiobooks/getBookCoverSrc'](entity.book)" /> -->
<cards-book-card v-else :key="entity.id" :show-volume-number="!!selectedSeries" :width="bookCoverWidth" :user-progress="userAudiobooks[entity.id]" :audiobook="entity" @edit="editBook" />
</template>
</div>
<div class="bookshelfDivider h-4 w-full absolute bottom-0 left-0 right-0 z-10" />
</div>
</template>
</div>
<div class="bookshelfDivider h-4 w-full absolute bottom-0 left-0 right-0 z-10" />
</template>
<template v-else>
<app-book-list :books="entities" />
</template>
<div v-show="!shelves.length" class="w-full py-16 text-center text-xl">
<div v-if="page === 'search'" class="py-4 mb-6"><p class="text-2xl">No Results</p></div>
<div v-else class="py-4">No {{ showGroups ? 'Series' : 'Audiobooks' }}</div>
<ui-btn v-if="!showGroups && (filterBy !== 'all' || keywordFilter)" @click="clearFilter">Clear Filter</ui-btn>
<ui-btn v-else-if="page === 'search'" to="/library">Back to Library</ui-btn>
</div>
</template>
<div v-show="!shelves.length" class="w-full py-16 text-center text-xl">
<div v-if="page === 'search'" class="py-4 mb-6"><p class="text-2xl">No Results</p></div>
<div v-else class="py-4">No {{ showGroups ? 'Series' : 'Audiobooks' }}</div>
<ui-btn v-if="!showGroups && (filterBy !== 'all' || keywordFilter)" @click="clearFilter">Clear Filter</ui-btn>
<ui-btn v-else-if="page === 'search'" to="/library">Back to Library</ui-btn>
</div>
</div>
</div>
@@ -56,7 +65,8 @@ export default {
type: Object,
default: () => {}
},
searchQuery: String
searchQuery: String,
viewMode: String
},
data() {
return {
@@ -89,10 +99,15 @@ 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' })
}
}
},
computed: {
isGridMode() {
return this.viewMode === 'grid'
},
keywordFilter() {
return this.$store.state.audiobooks.keywordFilter
},
@@ -106,7 +121,8 @@ export default {
return this.bookCoverWidth / 120
},
bookCoverWidth() {
return this.availableSizes[this.selectedSizeIndex]
var coverWidth = this.availableSizes[this.selectedSizeIndex]
return coverWidth
},
sizeMultiplier() {
return this.bookCoverWidth / 120
@@ -345,8 +361,9 @@ export default {
</script>
<style>
#bookshelf {
.bookshelf {
height: calc(100% - 40px);
width: calc(100vw - 80px);
}
.bookshelfRow {
background-image: url(/wood_panels.jpg);
+13 -1
View File
@@ -20,6 +20,14 @@
<ui-text-input v-show="!selectedSeries" v-model="_keywordFilter" placeholder="Keyword Filter" :padding-y="1.5" clearable class="text-xs w-40" />
<controls-filter-select v-show="showSortFilters" v-model="settings.filterBy" class="w-48 h-7.5 ml-4" @change="updateFilter" />
<controls-order-select v-show="showSortFilters" v-model="settings.orderBy" :descending.sync="settings.orderDesc" class="w-48 h-7.5 ml-4" @change="updateOrder" />
<div class="h-7 ml-4 flex border border-white border-opacity-25 rounded-md">
<div class="h-full px-2 text-white flex items-center rounded-l-md hover:bg-primary hover:bg-opacity-75 cursor-pointer" :class="isGridMode ? 'bg-primary' : 'text-opacity-70'" @click="$emit('update:viewMode', 'grid')">
<span class="material-icons" style="font-size: 1.4rem">view_module</span>
</div>
<div class="h-full px-2 text-white flex items-center rounded-r-md hover:bg-primary hover:bg-opacity-75 cursor-pointer" :class="!isGridMode ? 'bg-primary' : 'text-opacity-70'" @click="$emit('update:viewMode', 'list')">
<span class="material-icons" style="font-size: 1.4rem">view_list</span>
</div>
</div>
</template>
<template v-else-if="!isHome">
<div @click="searchBackArrow" class="rounded-full h-10 w-10 flex items-center justify-center hover:bg-white hover:bg-opacity-10 cursor-pointer">
@@ -44,7 +52,8 @@ export default {
type: Object,
default: () => {}
},
searchQuery: String
searchQuery: String,
viewMode: String
},
data() {
return {
@@ -53,6 +62,9 @@ export default {
}
},
computed: {
isGridMode() {
return this.viewMode === 'grid'
},
showSortFilters() {
return this.page === ''
},
+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`)
+5 -1
View File
@@ -8,7 +8,7 @@
<div class="absolute -bottom-4 left-0 triangle-right" />
</div>
<div class="rounded-sm h-full overflow-hidden relative" :style="{ padding: `16px ${paddingX}px` }" @click.stop>
<div class="rounded-sm h-full overflow-hidden relative" :style="{ padding: `${paddingY}px ${paddingX}px` }" @click.stop>
<nuxt-link :to="isSelectionMode ? '' : `/audiobook/${audiobookId}`" class="cursor-pointer">
<div class="w-full relative box-shadow-book" :style="{ height: height + 'px' }" @click="clickCard" @mouseover="isHovering = true" @mouseleave="isHovering = false">
<cards-book-cover :audiobook="audiobook" :author-override="authorFormat" :width="width" />
@@ -77,6 +77,10 @@ export default {
type: Number,
default: 120
},
paddingY: {
type: Number,
default: 16
},
showVolumeNumber: Boolean
},
data() {
+3 -3
View File
@@ -6,11 +6,11 @@
<div class="flex items-center">
<h1>{{ book.title }}</h1>
<div class="flex-grow" />
<p>{{ book.year || book.first_publish_date }}</p>
<p>{{ book.publishYear }}</p>
</div>
<p class="text-gray-400">{{ book.author }}</p>
<div class="w-full max-h-12 overflow-hidden">
<p class="text-gray-500 text-xs" v-html="book.description"></p>
<p class="text-gray-500 text-xs">{{ book.description }}</p>
</div>
</div>
</div>
@@ -53,7 +53,7 @@ export default {
}
},
mounted() {
this.selectedCover = this.bookCovers.length ? this.bookCovers[0] : null
this.selectedCover = this.bookCovers.length ? this.bookCovers[0] : this.book.cover || null
}
}
</script>
+14 -3
View File
@@ -1,11 +1,11 @@
<template>
<div class="relative">
<div class="rounded-sm h-full overflow-hidden relative" :style="{ padding: `16px ${paddingX}px` }" @mouseover="isHovering = true" @mouseleave="isHovering = false" @click="clickCard">
<div class="rounded-sm h-full relative" :style="{ padding: `16px ${paddingX}px` }" @mouseover="mouseoverCard" @mouseleave="mouseleaveCard" @click="clickCard">
<nuxt-link :to="groupTo" class="cursor-pointer">
<div class="w-full relative" :class="isHovering ? 'bg-black-400' : 'bg-primary'" :style="{ height: height + 'px', width: height + 'px' }">
<cards-group-cover ref="groupcover" :name="groupName" :book-items="bookItems" :width="height" :height="height" />
<cards-group-cover ref="groupcover" :name="groupName" :group-to="groupTo" :book-items="bookItems" :width="height" :height="height" />
<div v-if="hasValidCovers" class="bg-black bg-opacity-60 absolute top-0 left-0 w-full h-full flex items-center justify-center text-center transition-opacity" :class="isHovering ? '' : 'opacity-0'" :style="{ padding: `${sizeMultiplier}rem` }">
<div v-if="hasValidCovers && !showExperimentalFeatures" class="bg-black bg-opacity-60 absolute top-0 left-0 w-full h-full flex items-center justify-center text-center transition-opacity z-30" :class="isHovering ? '' : 'opacity-0'" :style="{ padding: `${sizeMultiplier}rem` }">
<p class="font-book" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ groupName }}</p>
</div>
@@ -100,9 +100,20 @@ export default {
hasValidCovers() {
var validCovers = this.bookItems.map((bookItem) => bookItem.book.cover)
return !!validCovers.length
},
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
}
},
methods: {
mouseoverCard() {
this.isHovering = true
// if (this.$refs.groupcover) this.$refs.groupcover.setHover(true)
},
mouseleaveCard() {
this.isHovering = false
// if (this.$refs.groupcover) this.$refs.groupcover.setHover(false)
},
clickCard() {
this.$emit('click', this.group)
}
+203 -10
View File
@@ -1,5 +1,5 @@
<template>
<div ref="wrapper" :style="{ height: height + 'px', width: width + 'px' }" class="relative">
<div ref="wrapper" :style="{ height: height + 'px', width: width + 'px' }" class="relative" @mouseover="mouseoverCover" @mouseleave="mouseleaveCover">
<div v-if="noValidCovers" class="absolute top-0 left-0 w-full h-full flex items-center justify-center box-shadow-book" :style="{ padding: `${sizeMultiplier}rem` }">
<p :style="{ fontSize: sizeMultiplier + 'rem' }">{{ name }}</p>
</div>
@@ -15,12 +15,21 @@ export default {
default: () => []
},
width: Number,
height: Number
height: Number,
groupTo: String
},
data() {
return {
noValidCovers: false,
coverDiv: null
coverDiv: null,
isHovering: false,
coverWrapperEl: null,
coverImageEls: [],
coverWidth: 0,
offsetIncrement: 0,
isFannedOut: false,
isDetached: false,
isAttaching: false
}
},
watch: {
@@ -37,13 +46,152 @@ export default {
computed: {
sizeMultiplier() {
return this.width / 192
},
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
}
},
methods: {
mouseoverCover() {
if (this.showExperimentalFeatures) this.setHover(true)
},
mouseleaveCover() {
if (this.showExperimentalFeatures) this.setHover(false)
},
detchCoverWrapper() {
if (!this.coverWrapperEl || !this.$refs.wrapper || this.isDetached) return
this.coverWrapperEl.remove()
this.isDetached = true
document.body.appendChild(this.coverWrapperEl)
this.coverWrapperEl.addEventListener('mouseleave', this.mouseleaveCover)
this.coverWrapperEl.style.position = 'absolute'
this.coverWrapperEl.style.zIndex = 40
this.updatePosition()
},
attachCoverWrapper() {
if (!this.coverWrapperEl || !this.$refs.wrapper || !this.isDetached) return
this.coverWrapperEl.remove()
this.coverWrapperEl.style.position = 'relative'
this.coverWrapperEl.style.left = 'unset'
this.coverWrapperEl.style.top = 'unset'
this.coverWrapperEl.style.width = this.$refs.wrapper.clientWidth + 'px'
this.$refs.wrapper.appendChild(this.coverWrapperEl)
console.log('Appended to wrapper', this.$refs.wrapper.children)
this.isDetached = false
},
updatePosition() {
var rect = this.$refs.wrapper.getBoundingClientRect()
this.coverWrapperEl.style.top = rect.top + window.scrollY + 'px'
this.coverWrapperEl.style.left = rect.left + window.scrollX + 4 + 'px'
this.coverWrapperEl.style.height = rect.height + 'px'
this.coverWrapperEl.style.width = rect.width + 'px'
},
setHover(val) {
if (this.isAttaching) return
if (val && !this.isHovering) {
this.detchCoverWrapper()
this.fanOutCovers()
} else if (!val && this.isHovering) {
this.isAttaching = true
this.reverseFan()
setTimeout(() => {
this.attachCoverWrapper()
this.isAttaching = false
}, 100)
}
this.isHovering = val
},
fanOutCovers() {
if (this.coverImageEls.length < 2 || this.isFannedOut) return
this.isFannedOut = true
var fanCoverWidth = this.coverWidth * 0.75
var maximumWidth = window.innerWidth - 80
var totalFanWidth = (this.coverImageEls.length + 1) * fanCoverWidth
// If Fan width is too large, set new fan cover width
if (totalFanWidth > maximumWidth) {
fanCoverWidth = maximumWidth / (this.coverImageEls.length + 1)
}
var fanWidth = (this.coverImageEls.length - 1) * fanCoverWidth
var offsetLeft = (-1 * fanWidth) / 2
var rect = this.$refs.wrapper.getBoundingClientRect()
// If fan is going off page left or right, make adjustment
var leftEdge = rect.left + offsetLeft
var rightEdge = rect.left + rect.width - offsetLeft
if (leftEdge < 0) {
offsetLeft += leftEdge * -1
}
if (rightEdge + 80 > window.innerWidth) {
var difference = rightEdge + 80 - window.innerWidth
offsetLeft -= difference / 2
}
for (let i = 0; i < this.coverImageEls.length; i++) {
var coverEl = this.coverImageEls[i]
// Series name card pop out further
if (i === this.coverImageEls.length - 1) {
offsetLeft += fanCoverWidth * 0.25
}
coverEl.style.transform = `translateX(${offsetLeft}px)`
offsetLeft += fanCoverWidth
var coverOverlay = document.createElement('div')
coverOverlay.className = 'absolute top-0 left-0 w-full h-full hover:bg-black hover:bg-opacity-40 text-white text-opacity-0 hover:text-opacity-100 flex items-center justify-center cursor-pointer'
if (coverEl.dataset.volumeNumber) {
var pEl = document.createElement('p')
pEl.className = 'text-2xl'
pEl.textContent = `#${coverEl.dataset.volumeNumber}`
coverOverlay.appendChild(pEl)
}
if (coverEl.dataset.audiobookId) {
let audiobookId = coverEl.dataset.audiobookId
coverOverlay.addEventListener('click', (e) => {
this.$router.push(`/audiobook/${audiobookId}`)
e.stopPropagation()
e.preventDefault()
})
} else {
// Is Series
coverOverlay.addEventListener('click', (e) => {
this.$router.push(this.groupTo)
e.stopPropagation()
e.preventDefault()
})
}
coverEl.appendChild(coverOverlay)
}
},
reverseFan() {
if (this.coverImageEls.length < 2 || !this.isFannedOut) return
this.isFannedOut = false
for (let i = 0; i < this.coverImageEls.length; i++) {
var coverEl = this.coverImageEls[i]
coverEl.style.transform = 'translateX(0px)'
if (coverEl.lastChild) coverEl.lastChild.remove() // Remove cover overlay
}
},
getCoverUrl(book) {
return this.$store.getters['audiobooks/getBookCoverSrc'](book, '')
},
async buildCoverImg(src, bgCoverWidth, offsetLeft, forceCoverBg = false) {
async buildCoverImg(coverData, bgCoverWidth, offsetLeft, zIndex, forceCoverBg = false) {
var src = coverData.coverUrl
var showCoverBg =
forceCoverBg ||
(await new Promise((resolve) => {
@@ -72,8 +220,11 @@ export default {
imgdiv.style.height = this.height + 'px'
imgdiv.style.width = bgCoverWidth + 'px'
imgdiv.style.left = offsetLeft + 'px'
imgdiv.className = 'absolute top-0 box-shadow-book'
imgdiv.style.boxShadow = '-4px 0px 4px #11111166'
imgdiv.style.zIndex = zIndex
imgdiv.dataset.audiobookId = coverData.id
imgdiv.dataset.volumeNumber = coverData.volumeNumber || ''
imgdiv.className = 'absolute top-0 box-shadow-book transition-transform'
imgdiv.style.boxShadow = '4px 0px 4px #11111166'
// imgdiv.style.transform = 'skew(0deg, 15deg)'
if (showCoverBg) {
@@ -100,12 +251,36 @@ export default {
imgdiv.appendChild(img)
return imgdiv
},
createSeriesNameCover(offsetLeft) {
var imgdiv = document.createElement('div')
imgdiv.style.height = this.height + 'px'
imgdiv.style.width = this.height / 1.6 + 'px'
imgdiv.style.left = offsetLeft + 'px'
imgdiv.className = 'absolute top-0 box-shadow-book transition-transform flex items-center justify-center'
imgdiv.style.boxShadow = '4px 0px 4px #11111166'
imgdiv.style.backgroundColor = '#111'
var innerP = document.createElement('p')
innerP.textContent = this.name
innerP.className = 'text-sm font-book text-white'
imgdiv.appendChild(innerP)
return imgdiv
},
async init() {
if (this.coverDiv) {
this.coverDiv.remove()
this.coverDiv = null
}
var validCovers = this.bookItems.map((bookItem) => this.getCoverUrl(bookItem)).filter((b) => b !== '')
var validCovers = this.bookItems
.map((bookItem) => {
return {
id: bookItem.id,
volumeNumber: bookItem.book ? bookItem.book.volumeNumber : null,
coverUrl: this.getCoverUrl(bookItem)
}
})
.filter((b) => b.coverUrl !== '')
if (!validCovers.length) {
this.noValidCovers = true
return
@@ -118,22 +293,40 @@ export default {
coverWidth = this.height / 1.6
widthPer = (this.width - coverWidth) / (validCovers.length - 1)
}
this.coverWidth = coverWidth
this.offsetIncrement = widthPer
var outerdiv = document.createElement('div')
this.coverWrapperEl = outerdiv
outerdiv.className = 'w-full h-full relative'
var coverImageEls = []
var offsetLeft = 0
for (let i = 0; i < validCovers.length; i++) {
var offsetLeft = widthPer * i
var img = await this.buildCoverImg(validCovers[i], coverWidth, offsetLeft, validCovers.length === 1)
offsetLeft = widthPer * i
var zIndex = validCovers.length - i
var img = await this.buildCoverImg(validCovers[i], coverWidth, offsetLeft, zIndex, validCovers.length === 1)
outerdiv.appendChild(img)
coverImageEls.push(img)
}
if (this.showExperimentalFeatures) {
var seriesNameCover = this.createSeriesNameCover(offsetLeft)
outerdiv.appendChild(seriesNameCover)
coverImageEls.push(seriesNameCover)
}
this.coverImageEls = coverImageEls
if (this.$refs.wrapper) {
this.coverDiv = outerdiv
this.$refs.wrapper.appendChild(outerdiv)
}
}
},
mounted() {}
mounted() {},
beforeDestroy() {
if (this.coverWrapperEl) this.coverWrapperEl.remove()
}
}
</script>
@@ -0,0 +1,41 @@
<template>
<div ref="container" @mouseover="mouseover" @mouseleave="mouseleave" class="relative">
<cards-book-cover :width="24" :audiobook="audiobook" />
</div>
</template>
<script>
export default {
props: {
audiobook: {
type: Object,
default: () => {}
}
},
data() {
return {
isHovering: false
}
},
computed: {
placeholderUrl() {
return '/book_placeholder.jpg'
},
fullCoverUrl() {
return this.$store.getters['audiobooks/getBookCoverSrc'](this.audiobook, this.placeholderUrl)
},
hasCover() {
return !!this.audiobook.book.cover
}
},
methods: {
mouseover() {
this.isHovering = true
},
mouseleave() {
this.isHovering = false
}
},
mounted() {}
}
</script>
@@ -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>
+15 -9
View File
@@ -20,7 +20,7 @@
<div class="w-full h-full text-sm rounded-b-lg rounded-tr-lg bg-bg shadow-lg border border-black-300">
<keep-alive>
<component v-if="audiobook" :is="tabName" :audiobook="audiobook" :processing.sync="processing" @close="show = false" />
<component v-if="audiobook" :is="tabName" :audiobook="audiobook" :processing.sync="processing" @close="show = false" @selectTab="selectTab" />
</keep-alive>
</div>
</modals-modal>
@@ -44,11 +44,6 @@ export default {
title: 'Cover',
component: 'modals-edit-tabs-cover'
},
// {
// id: 'match',
// title: 'Match',
// component: 'modals-edit-tabs-match'
// },
{
id: 'tracks',
title: 'Tracks',
@@ -68,6 +63,11 @@ export default {
id: 'download',
title: 'Download',
component: 'modals-edit-tabs-download'
},
{
id: 'match',
title: 'Match',
component: 'modals-edit-tabs-match'
}
]
}
@@ -123,12 +123,16 @@ export default {
userCanDownload() {
return this.$store.getters['user/getUserCanDownload']
},
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
},
availableTabs() {
if (!this.userCanUpdate && !this.userCanDownload) return []
return this.tabs.filter((tab) => {
if (tab.id === 'download' && this.isMissing) return false
if ((tab.id === 'download' || tab.id === 'tracks') && this.userCanDownload) return true
if (tab.id !== 'download' && tab.id !== 'tracks' && this.userCanUpdate) return true
if (tab.id === 'match' && this.showExperimentalFeatures) return true
return false
})
},
@@ -194,7 +198,9 @@ export default {
}
},
selectTab(tab) {
this.selectedTab = tab
if (this.availableTabs.find((t) => t.id === tab)) {
this.selectedTab = tab
}
},
audiobookUpdated() {
if (!this.show) this.fetchOnShow = true
@@ -218,9 +224,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
},
+139 -27
View File
@@ -1,15 +1,17 @@
<template>
<div class="w-full h-full overflow-hidden px-4 py-6">
<div class="w-full h-full overflow-hidden px-4 py-6 relative">
<form @submit.prevent="submitSearch">
<div class="flex items-center justify-start -mx-1 h-20">
<div class="w-72 px-1">
<ui-text-input-with-label v-model="searchTitle" label="Search Title" placeholder="Search" :disabled="processing" />
<div class="w-40 px-1">
<ui-dropdown v-model="provider" :items="providers" label="Provider" small />
</div>
<div class="w-72 px-1">
<ui-text-input-with-label v-model="searchAuthor" label="Author" :disabled="processing" />
<ui-text-input-with-label v-model="searchTitle" label="Search Title" placeholder="Search" />
</div>
<div class="w-72 px-1">
<ui-text-input-with-label v-model="searchAuthor" label="Author" />
</div>
<ui-btn class="mt-5 ml-1" type="submit">Search</ui-btn>
<div class="flex-grow" />
</div>
</form>
<div v-show="processing" class="flex h-full items-center justify-center">
@@ -23,6 +25,51 @@
<cards-book-match-card :key="index" :book="res" @select="selectMatch" />
</template>
</div>
<div v-if="selectedMatch" class="absolute top-0 left-0 w-full bg-bg h-full p-8 max-h-full overflow-y-auto overflow-x-hidden">
<div class="flex mb-2">
<div class="w-8 h-8 rounded-full hover:bg-white hover:bg-opacity-10 flex items-center justify-center cursor-pointer" @click="selectedMatch = null">
<span class="material-icons text-3xl">arrow_back</span>
</div>
<p class="text-xl pl-3">Update Book Details</p>
</div>
<form @submit.prevent="submitMatchUpdate">
<div v-if="selectedMatch.cover" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.cover" />
<ui-text-input-with-label v-model="selectedMatch.cover" :disabled="!selectedMatchUsage.cover" label="Cover" class="flex-grow ml-4" />
</div>
<div v-if="selectedMatch.title" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.title" />
<ui-text-input-with-label v-model="selectedMatch.title" :disabled="!selectedMatchUsage.title" label="Title" class="flex-grow ml-4" />
</div>
<div v-if="selectedMatch.subtitle" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.subtitle" />
<ui-text-input-with-label v-model="selectedMatch.subtitle" :disabled="!selectedMatchUsage.subtitle" label="Subtitle" class="flex-grow ml-4" />
</div>
<div v-if="selectedMatch.author" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.author" />
<ui-text-input-with-label v-model="selectedMatch.author" :disabled="!selectedMatchUsage.author" label="Author" class="flex-grow ml-4" />
</div>
<div v-if="selectedMatch.description" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.description" />
<ui-textarea-with-label v-model="selectedMatch.description" :rows="3" :disabled="!selectedMatchUsage.description" label="Description" class="flex-grow ml-4" />
</div>
<div v-if="selectedMatch.publisher" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.publisher" />
<ui-text-input-with-label v-model="selectedMatch.publisher" :disabled="!selectedMatchUsage.publisher" label="Publisher" class="flex-grow ml-4" />
</div>
<div v-if="selectedMatch.publishYear" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.publishYear" />
<ui-text-input-with-label v-model="selectedMatch.publishYear" :disabled="!selectedMatchUsage.publishYear" label="Publish Year" class="flex-grow ml-4" />
</div>
<div v-if="selectedMatch.isbn" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.isbn" />
<ui-text-input-with-label v-model="selectedMatch.isbn" :disabled="!selectedMatchUsage.isbn" label="ISBN" class="flex-grow ml-4" />
</div>
<div class="flex items-center justify-end py-2">
<ui-btn color="success" type="submit">Update</ui-btn>
</div>
</form>
</div>
</div>
</template>
@@ -41,9 +88,30 @@ export default {
searchTitle: null,
searchAuthor: null,
lastSearch: null,
provider: 'best',
providers: [
{
text: 'Google Books',
value: 'google'
},
{
text: 'Open Library',
value: 'openlibrary'
}
],
provider: 'google',
searchResults: [],
hasSearched: false
hasSearched: false,
selectedMatch: null,
selectedMatchUsage: {
title: true,
subtitle: true,
cover: true,
author: true,
description: true,
isbn: true,
publisher: true,
publishYear: true
}
}
},
watch: {
@@ -95,6 +163,18 @@ export default {
this.hasSearched = true
},
init() {
this.selectedMatch = null
this.selectedMatchUsage = {
title: true,
subtitle: true,
cover: true,
author: true,
description: true,
isbn: true,
publisher: true,
publishYear: true
}
if (this.audiobook.id !== this.audiobookId) {
this.searchResults = []
this.hasSearched = false
@@ -107,31 +187,63 @@ export default {
return
}
this.searchTitle = this.audiobook.book.title
this.searchAuthor = this.audiobook.book.author || ''
this.searchAuthor = this.audiobook.book.authorFL || ''
},
async selectMatch(match) {
selectMatch(match) {
this.selectedMatch = match
},
buildMatchUpdatePayload() {
var updatePayload = {}
for (const key in this.selectedMatchUsage) {
if (this.selectedMatchUsage[key] && this.selectedMatch[key]) {
updatePayload[key] = this.selectedMatch[key]
}
}
return updatePayload
},
async submitMatchUpdate() {
var updatePayload = this.buildMatchUpdatePayload()
if (!Object.keys(updatePayload).length) {
return
}
this.isProcessing = true
const updatePayload = {
book: {}
if (updatePayload.cover) {
var coverPayload = {
url: updatePayload.cover
}
var success = await this.$axios.$post(`/api/audiobook/${this.audiobook.id}/cover`, coverPayload).catch((error) => {
console.error('Failed to update', error)
return false
})
if (success) {
this.$toast.success('Book Cover Updated')
} else {
this.$toast.error('Book Cover Failed to Update')
}
console.log('Updated cover')
delete updatePayload.cover
}
if (match.cover) {
updatePayload.book.cover = match.cover
if (Object.keys(updatePayload).length) {
var bookUpdatePayload = {
book: updatePayload
}
var success = await this.$axios.$patch(`/api/audiobook/${this.audiobook.id}`, bookUpdatePayload).catch((error) => {
console.error('Failed to update', error)
return false
})
if (success) {
this.$toast.success('Book Details Updated')
this.selectedMatch = null
this.$emit('selectTab', 'details')
} else {
this.$toast.error('Book Details Failed to Update')
}
} else {
this.selectedMatch = null
}
if (match.title) {
updatePayload.book.title = match.title
}
if (match.description) {
updatePayload.book.description = match.description
}
var updatedAudiobook = await this.$axios.$patch(`/api/audiobook/${this.audiobook.id}`, updatePayload).catch((error) => {
console.error('Failed to update', error)
return false
})
this.isProcessing = false
if (updatedAudiobook) {
this.$toast.success('Update Successful')
this.$emit('close')
}
}
}
}
+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)
+1 -1
View File
@@ -102,7 +102,7 @@ export default {
applyBackupComplete(success) {
if (success) {
// this.$toast.success('Backup Applied, refresh the page')
location.replace('/config?backup=1')
location.replace('/config/backups?backup=1')
} else {
this.$toast.error('Failed to apply backup')
}
+42
View File
@@ -0,0 +1,42 @@
<template>
<label class="flex justify-start items-start">
<div class="bg-white border-2 rounded border-gray-400 flex flex-shrink-0 justify-center items-center focus-within:border-blue-500" :class="wrapperClass">
<input v-model="selected" type="checkbox" class="opacity-0 absolute" />
<svg v-if="selected" class="fill-current text-green-500 pointer-events-none" :class="svgClass" viewBox="0 0 20 20"><path d="M0 11l2-2 5 5L18 3l2 2L7 18z" /></svg>
</div>
<div v-if="label" class="select-none">{{ label }}</div>
</label>
</template>
<script>
export default {
props: {
value: Boolean,
label: Boolean,
small: Boolean
},
data() {
return {}
},
computed: {
selected: {
get() {
return this.value
},
set(val) {
this.$emit('input', !!val)
}
},
wrapperClass() {
if (this.small) return 'w-4 h-4'
return 'w-6 h-6'
},
svgClass() {
if (this.small) return 'w-3 h-3'
return 'w-4 h-4'
}
},
methods: {},
mounted() {}
}
</script>
+5 -4
View File
@@ -1,9 +1,9 @@
<template>
<div class="relative w-full" v-click-outside="clickOutside">
<p class="text-sm text-opacity-75 mb-1">{{ label }}</p>
<button type="button" :disabled="disabled" class="relative h-10 w-full border border-gray-500 rounded shadow-sm pl-3 pr-10 py-2 text-left focus:outline-none sm:text-sm cursor-pointer bg-primary" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu">
<p class="text-sm font-semibold">{{ label }}</p>
<button type="button" :disabled="disabled" class="relative w-full border border-gray-500 rounded shadow-sm pl-3 pr-8 py-2 text-left focus:outline-none sm:text-sm cursor-pointer bg-primary" :class="small ? 'h-9' : 'h-10'" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu">
<span class="flex items-center">
<span class="block truncate">{{ selectedText }}</span>
<span class="block truncate" :class="small ? 'text-sm' : ''">{{ selectedText }}</span>
</span>
<span class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<span class="material-icons text-gray-100">expand_more</span>
@@ -36,7 +36,8 @@ export default {
type: Array,
default: () => []
},
disabled: Boolean
disabled: Boolean,
small: Boolean
},
data() {
return {
+5 -1
View File
@@ -1,6 +1,6 @@
<template>
<button class="icon-btn rounded-md border border-gray-600 flex items-center justify-center h-9 w-9 relative" :disabled="disabled" :class="className" @click="clickBtn">
<span class="material-icons icon-text">{{ icon }}</span>
<span class="material-icons" :style="{ fontSize }">{{ icon }}</span>
</button>
</template>
@@ -22,6 +22,10 @@ export default {
var classes = []
classes.push(`bg-${this.bgColor}`)
return classes.join(' ')
},
fontSize() {
if (this.icon === 'edit') return '1.25rem'
return '1.4rem'
}
},
methods: {
+1
View File
@@ -80,6 +80,7 @@ input {
border-style: inherit !important;
}
input:read-only {
color: #aaa;
background-color: #444;
}
</style>
+1 -1
View File
@@ -1,6 +1,6 @@
<template>
<div class="w-full">
<p class="px-1 text-sm font-semibold">
<p class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">
{{ label }}<em v-if="note" class="font-normal text-xs pl-2">{{ note }}</em>
</p>
<ui-text-input v-model="inputValue" :disabled="disabled" :type="type" class="w-full" />
+4 -3
View File
@@ -38,10 +38,11 @@ export default {
</script>
<style scoped>
input {
textarea {
border-style: inherit !important;
}
input:read-only {
background-color: #eee;
textarea:read-only {
color: #aaa;
background-color: #444;
}
</style>
+3 -2
View File
@@ -1,7 +1,7 @@
<template>
<div class="w-full">
<p class="px-1 text-sm font-semibold">{{ label }}</p>
<ui-textarea-input v-model="inputValue" :rows="rows" class="w-full" />
<p class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">{{ label }}</p>
<ui-textarea-input v-model="inputValue" :disabled="disabled" :rows="rows" class="w-full" />
</div>
</template>
@@ -10,6 +10,7 @@ export default {
props: {
value: [String, Number],
label: String,
disabled: Boolean,
rows: {
type: Number,
default: 2
+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.6.0",
"description": "Audiobook manager and player",
"main": "index.js",
"scripts": {
+2 -15
View File
@@ -13,7 +13,7 @@
<div class="mb-4">
<div class="flex items-end">
<h1 class="text-3xl font-sans">
{{ title }}<span v-if="isDeveloperMode"> ({{ audiobook.ino }})</span>
{{ title }}
</h1>
<p v-if="subtitle" class="ml-4 text-gray-400 text-2xl">{{ subtitle }}</p>
</div>
@@ -316,9 +316,6 @@ export default {
numEbooks() {
return this.audiobook.numEbooks
},
userToken() {
return this.$store.getters['user/getToken']
},
description() {
return this.book.description || ''
},
@@ -397,16 +394,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
@@ -423,7 +410,7 @@ export default {
if (confirm(`Are you sure you want to reset your progress?`)) {
this.resettingProgress = true
this.$axios
.$delete(`/api/user/audiobook/${this.audiobookId}`)
.$patch(`/api/user/audiobook/${this.audiobookId}/reset-progress`)
.then(() => {
console.log('Progress reset complete')
this.$toast.success(`Your progress was reset`)
+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?')) {
-8
View File
@@ -1,13 +1,5 @@
<template>
<div class="page" :class="streamAudiobook ? 'streaming' : ''">
<!-- <app-book-shelf-toolbar /> -->
<!-- <div class="flex h-full">
<app-side-rail />
<div class="flex-grow"> -->
<!-- <app-book-shelf /> -->
<!-- </div> -->
<!-- </div> -->
<div class="flex h-full">
<app-side-rail />
<div class="flex-grow">
@@ -3,8 +3,8 @@
<div class="flex h-full">
<app-side-rail />
<div class="flex-grow">
<app-book-shelf-toolbar :page="id || ''" :search-results="searchResults" :search-query="searchQuery" :selected-series.sync="selectedSeries" />
<app-book-shelf :page="id || ''" :search-results="searchResults" :search-query="searchQuery" :selected-series.sync="selectedSeries" />
<app-book-shelf-toolbar :page="id || ''" :search-results="searchResults" :search-query="searchQuery" :selected-series.sync="selectedSeries" :view-mode.sync="viewMode" />
<app-book-shelf :page="id || ''" :search-results="searchResults" :search-query="searchQuery" :selected-series.sync="selectedSeries" :view-mode="viewMode" />
</div>
</div>
</div>
@@ -56,7 +56,9 @@ export default {
}
},
data() {
return {}
return {
viewMode: 'grid'
}
},
watch: {
'$route.query'(newVal) {
@@ -64,6 +66,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
@@ -150,6 +150,15 @@ export const mutations = {
Vue.set(state, 'selectedAudiobooks', newSel)
}
},
setAudiobookSelected(state, { audiobookId, selected }) {
var isThere = state.selectedAudiobooks.includes(audiobookId)
if (isThere && !selected) {
state.selectedAudiobooks = state.selectedAudiobooks.filter(a => a !== audiobookId)
} else if (selected && !isThere) {
var newSel = state.selectedAudiobooks.concat([audiobookId])
Vue.set(state, 'selectedAudiobooks', newSel)
}
},
setProcessingBatch(state, val) {
state.processingBatch = val
},
+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
+18
View File
@@ -17,6 +17,24 @@ module.exports = {
height: {
'7.5': '1.75rem'
},
maxWidth: {
'6': '1.5rem',
'12': '3rem',
'24': '6rem',
'32': '8rem',
'48': '12rem',
'64': '16rem',
'80': '20rem'
},
minWidth: {
'6': '1.5rem',
'12': '3rem',
'24': '6rem',
'32': '8rem',
'48': '12rem',
'64': '16rem',
'80': '20rem'
},
spacing: {
'-54': '-13.5rem'
},
+12
View File
@@ -0,0 +1,12 @@
### EXAMPLE DOCKER COMPOSE ###
version: "3.7"
services:
audiobookshelf:
image: advplyr/audiobookshelf
ports:
- 13378:80
volumes:
- /audiobooks:/audiobooks
- /metadata:/metadata
- /config:/config
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "audiobookshelf",
"version": "1.4.16",
"version": "1.6.0",
"description": "Self-hosted audiobook server for managing and playing audiobooks",
"main": "index.js",
"scripts": {
@@ -9,7 +9,8 @@
"client": "cd client && npm install && npm run generate",
"prod": "npm run client && npm install && node prod.js",
"build-win": "pkg -t node12-win-x64 -o ./dist/win/audiobookshelf .",
"build-linux": "build/linuxpackager"
"build-linux": "build/linuxpackager",
"docker": "docker buildx build -t advplyr/audiobookshelf --platform linux/amd64,linux/arm64 --push ."
},
"bin": "prod.js",
"pkg": {
+65 -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
@@ -60,6 +58,22 @@ docker run -d \
--rm advplyr/audiobookshelf
```
### Running with Docker Compose
```bash
### docker-compose.yml ###
services:
audiobookshelf:
image: advplyr/audiobookshelf
ports:
- 13378:80
volumes:
- <path/to/your/audiobooks>:/audiobooks
- <path/to/metadata>:/metadata
- <path/to/config>:/config
```
### Linux (amd64) Install
A simple installer is added to setup the initial config. If you already have audiobooks, you can enter the path to your audiobooks during the install. The installer will create a user and group named `audiobookshelf`.
@@ -103,23 +117,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 +151,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
+37 -13
View File
@@ -6,6 +6,8 @@ const Logger = require('./Logger')
const { isObject } = require('./utils/index')
const audioFileScanner = require('./utils/audioFileScanner')
const BookFinder = require('./BookFinder')
const Library = require('./objects/Library')
const User = require('./objects/User')
@@ -24,6 +26,8 @@ class ApiController {
this.clientEmitter = clientEmitter
this.MetadataPath = MetadataPath
this.bookFinder = new BookFinder()
this.router = express()
this.init()
}
@@ -51,13 +55,17 @@ class ApiController {
this.router.patch('/audiobook/:id/tracks', this.updateAudiobookTracks.bind(this))
this.router.post('/audiobook/:id/cover', this.uploadAudiobookCover.bind(this))
this.router.patch('/audiobook/:id/coverfile', this.updateAudiobookCoverFromFile.bind(this))
this.router.get('/audiobook/:id/match', this.matchAudiobookBook.bind(this))
this.router.patch('/audiobook/:id', this.updateAudiobook.bind(this))
this.router.patch('/match/:id', this.match.bind(this))
// Old Route : Wait until refactor of mobile app to replace with path /reset-progress
this.router.delete('/user/audiobook/:id', this.resetUserAudiobookProgress.bind(this))
this.router.patch('/user/audiobook/:id', this.updateUserAudiobookProgress.bind(this))
this.router.patch('/user/audiobooks', this.batchUpdateUserAudiobooksProgress.bind(this))
this.router.patch('/user/audiobook/:id/reset-progress', this.resetUserAudiobookProgress.bind(this))
this.router.patch('/user/audiobook/:id', this.updateUserAudiobookData.bind(this))
this.router.patch('/user/audiobooks', this.batchUpdateUserAudiobookData.bind(this))
this.router.patch('/user/password', this.userChangePassword.bind(this))
this.router.patch('/user/settings', this.userUpdateSettings.bind(this))
@@ -85,8 +93,12 @@ class ApiController {
this.router.get('/scantracks/:id', this.scanAudioTrackNums.bind(this))
}
find(req, res) {
this.scanner.find(req, res)
async find(req, res) {
var provider = req.query.provider || 'google'
var title = req.query.title || ''
var author = req.query.author || ''
var results = await this.bookFinder.search(provider, title, author)
res.json(results)
}
findCovers(req, res) {
@@ -333,7 +345,7 @@ class ApiController {
// Remove audiobook from users
for (let i = 0; i < this.db.users.length; i++) {
var user = this.db.users[i]
var madeUpdates = user.deleteAudiobookProgress(audiobook.id)
var madeUpdates = user.deleteAudiobookData(audiobook.id)
if (madeUpdates) {
await this.db.updateEntity('user', user)
}
@@ -497,6 +509,18 @@ class ApiController {
else res.status(200).send('No update was made to cover')
}
async matchAudiobookBook(req, res) {
var audiobook = this.db.audiobooks.find(a => a.id === req.params.id)
if (!audiobook) return res.sendStatus(404)
var provider = req.query.provider || 'google'
var excludeAuthor = req.query.excludeAuthor === '1'
var authorSearch = excludeAuthor ? null : audiobook.authorFL
var results = await this.bookFinder.search(provider, audiobook.title, authorSearch)
res.json(results)
}
async updateAudiobook(req, res) {
if (!req.user.canUpdate) {
Logger.warn('User attempted to update without permission', req.user)
@@ -543,12 +567,12 @@ class ApiController {
res.sendStatus(200)
}
async updateUserAudiobookProgress(req, res) {
async updateUserAudiobookData(req, res) {
var audiobook = this.db.audiobooks.find(ab => ab.id === req.params.id)
if (!audiobook) {
return res.status(404).send('Audiobook not found')
}
var wasUpdated = req.user.updateAudiobookProgress(audiobook, req.body)
var wasUpdated = req.user.updateAudiobookData(audiobook, req.body)
if (wasUpdated) {
await this.db.updateEntity('user', req.user)
this.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
@@ -556,17 +580,17 @@ class ApiController {
res.sendStatus(200)
}
async batchUpdateUserAudiobooksProgress(req, res) {
var abProgresses = req.body
if (!abProgresses || !abProgresses.length) {
async batchUpdateUserAudiobookData(req, res) {
var userAbDataPayloads = req.body
if (!userAbDataPayloads || !userAbDataPayloads.length) {
return res.sendStatus(500)
}
var shouldUpdate = false
abProgresses.forEach((progress) => {
var audiobook = this.db.audiobooks.find(ab => ab.id === progress.audiobookId)
userAbDataPayloads.forEach((userAbData) => {
var audiobook = this.db.audiobooks.find(ab => ab.id === userAbData.audiobookId)
if (audiobook) {
var wasUpdated = req.user.updateAudiobookProgress(audiobook, progress)
var wasUpdated = req.user.updateAudiobookData(audiobook, userAbData)
if (wasUpdated) shouldUpdate = true
}
})
+16 -1
View File
@@ -1,5 +1,6 @@
const OpenLibrary = require('./providers/OpenLibrary')
const LibGen = require('./providers/LibGen')
const GoogleBooks = require('./providers/GoogleBooks')
const Logger = require('./Logger')
const { levenshteinDistance } = require('./utils/index')
@@ -7,6 +8,7 @@ class BookFinder {
constructor() {
this.openLibrary = new OpenLibrary()
this.libGen = new LibGen()
this.googleBooks = new GoogleBooks()
this.verbose = false
}
@@ -143,13 +145,26 @@ class BookFinder {
return booksFiltered
}
async getGoogleBooksResults(title, author, maxTitleDistance, maxAuthorDistance) {
var books = await this.googleBooks.search(title, author)
if (this.verbose) Logger.debug(`GoogleBooks Book Search Results: ${books.length || 0}`)
if (books.errorCode) {
Logger.error(`GoogleBooks Search Error ${books.errorCode}`)
return []
}
// Google has good sort
return books
}
async search(provider, title, author, options = {}) {
var books = []
var maxTitleDistance = !isNaN(options.titleDistance) ? Number(options.titleDistance) : 4
var maxAuthorDistance = !isNaN(options.authorDistance) ? Number(options.authorDistance) : 4
Logger.debug(`Cover Search: title: "${title}", author: "${author}", provider: ${provider}`)
if (provider === 'libgen') {
if (provider === 'google') {
return this.getGoogleBooksResults(title, author, maxTitleDistance, maxAuthorDistance)
} else if (provider === 'libgen') {
books = await this.getLibGenResults(title, author, maxTitleDistance, maxAuthorDistance)
} else if (provider === 'openlibrary') {
books = await this.getOpenLibResults(title, author, maxTitleDistance, maxAuthorDistance)
+6 -3
View File
@@ -19,6 +19,8 @@ class RssFeeds {
}
getFeed(req, res) {
Logger.info('Get Feed', req.params.id, this.feeds[req.params.id])
var feed = this.feeds[req.params.id]
if (!feed) return null
var xml = feed.buildXml()
@@ -27,15 +29,16 @@ class RssFeeds {
}
openFeed(audiobook) {
var serverAddress = 'http://' + ip.address('public', 'ipv4') + ':' + this.Port
var ipAddress = ip.address('public', 'ipv4')
var serverAddress = 'http://' + ipAddress + ':' + this.Port
Logger.info('Open RSS Feed', 'Server address', serverAddress)
var feedId = (Date.now() + Math.floor(Math.random() * 1000)).toString(36)
const feed = new Podcast({
title: audiobook.title,
description: 'AudioBookshelf RSS Feed',
feedUrl: `${serverAddress}/feeds/${feedId}`,
imageUrl: `${serverAddress}/Logo.png`,
feed_url: `${serverAddress}/feeds/${feedId}`,
image_url: `${serverAddress}/Logo.png`,
author: 'advplyr',
language: 'en'
})
-1
View File
@@ -10,7 +10,6 @@ const { comparePaths, getIno } = require('./utils/index')
const { secondsToTimestamp } = require('./utils/fileUtils')
const { ScanResult, CoverDestination } = require('./utils/constants')
// Classes
const BookFinder = require('./BookFinder')
const Audiobook = require('./objects/Audiobook')
+87 -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,89 @@ 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.updateAudiobookData(progressPayload.audiobookId, progressPayload)
if (audiobookProgress) {
await this.db.updateEntity('user', client.user)
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
+10 -6
View File
@@ -91,6 +91,10 @@ class Audiobook {
return this.book ? this.book.authorLF : null
}
get authorFL() {
return this.book ? this.book.authorFL : null
}
get genres() {
return this.book ? this.book.genres || [] : []
}
@@ -193,16 +197,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 +234,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
}
}
+4 -1
View File
@@ -1,4 +1,3 @@
const fs = require('fs-extra')
const Path = require('path')
const Logger = require('../Logger')
const parseAuthors = require('../utils/parseAuthors')
@@ -16,6 +15,7 @@ class Book {
this.publishYear = null
this.publisher = null
this.description = null
this.isbn = null
this.cover = null
this.coverFullPath = null
this.genres = []
@@ -56,6 +56,7 @@ class Book {
this.publishYear = book.publishYear
this.publisher = book.publisher
this.description = book.description
this.isbn = book.isbn || null
this.cover = book.cover
this.coverFullPath = book.coverFullPath || null
this.genres = book.genres
@@ -78,6 +79,7 @@ class Book {
publishYear: this.publishYear,
publisher: this.publisher,
description: this.description,
isbn: this.isbn,
cover: this.cover,
coverFullPath: this.coverFullPath,
genres: this.genres,
@@ -116,6 +118,7 @@ class Book {
this.volumeNumber = data.volumeNumber || null
this.publishYear = data.publishYear || null
this.description = data.description || null
this.isbn = data.isbn || null
this.cover = data.cover || null
this.coverFullPath = data.coverFullPath || null
this.genres = data.genres || []
+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}`,
+70 -9
View File
@@ -1,4 +1,5 @@
const AudiobookProgress = require('./AudiobookProgress')
const Logger = require('../Logger')
const UserAudiobookData = require('./UserAudiobookData')
class User {
constructor(user) {
@@ -140,7 +141,7 @@ class User {
this.audiobooks = {}
for (const key in user.audiobooks) {
if (user.audiobooks[key]) {
this.audiobooks[key] = new AudiobookProgress(user.audiobooks[key])
this.audiobooks[key] = new UserAudiobookData(user.audiobooks[key])
}
}
}
@@ -200,18 +201,24 @@ class User {
updateAudiobookProgressFromStream(stream) {
if (!this.audiobooks) this.audiobooks = {}
if (!this.audiobooks[stream.audiobookId]) {
this.audiobooks[stream.audiobookId] = new AudiobookProgress()
this.audiobooks[stream.audiobookId] = new UserAudiobookData()
}
this.audiobooks[stream.audiobookId].updateFromStream(stream)
this.audiobooks[stream.audiobookId].updateProgressFromStream(stream)
return this.audiobooks[stream.audiobookId]
}
updateAudiobookProgress(audiobook, updatePayload) {
updateAudiobookData(audiobook, updatePayload) {
if (!this.audiobooks) this.audiobooks = {}
if (!this.audiobooks[audiobook.id]) {
this.audiobooks[audiobook.id] = new AudiobookProgress()
this.audiobooks[audiobook.id] = new UserAudiobookData()
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] UserAudiobookData was updated ${JSON.stringify(this.audiobooks[audiobook.id])}`)
return this.audiobooks[audiobook.id]
}
return false
}
// Returns Boolean If update was made
@@ -244,7 +251,7 @@ class User {
if (!this.audiobooks || !this.audiobooks[audiobook.id]) {
return false
}
return this.updateAudiobookProgress(audiobook, {
return this.updateAudiobookData(audiobook, {
progress: 0,
currentTime: 0,
isRead: false,
@@ -254,7 +261,7 @@ class User {
})
}
deleteAudiobookProgress(audiobookId) {
deleteAudiobookData(audiobookId) {
if (!this.audiobooks || !this.audiobooks[audiobookId]) {
return false
}
@@ -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,4 +1,7 @@
class AudiobookProgress {
const Logger = require('../Logger')
const AudioBookmark = require('./AudioBookmark')
class UserAudiobookData {
constructor(progress) {
this.audiobookId = null
@@ -10,12 +13,24 @@ 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.filter((b) => {
if (!b.toJSON) {
Logger.error(`[UserAudiobookData] Invalid bookmark ${JSON.stringify(b)}`)
return false
}
return true
}).map(b => b.toJSON())
}
toJSON() {
return {
audiobookId: this.audiobookId,
@@ -25,7 +40,8 @@ class AudiobookProgress {
isRead: this.isRead,
lastUpdate: this.lastUpdate,
startedAt: this.startedAt,
finishedAt: this.finishedAt
finishedAt: this.finishedAt,
bookmarks: this.bookmarksToJSON()
}
}
@@ -38,9 +54,14 @@ 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) {
updateProgressFromStream(stream) {
this.audiobookId = stream.audiobookId
this.totalDuration = stream.totalDuration
this.progress = stream.clientProgress
@@ -54,11 +75,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 +86,7 @@ class AudiobookProgress {
update(payload) {
var hasUpdates = false
Logger.debug(`[UserAudiobookData] Update called ${JSON.stringify(payload)}`)
for (const key in payload) {
if (payload[key] !== this[key]) {
if (key === 'isRead') {
@@ -92,5 +112,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
module.exports = UserAudiobookData
+50
View File
@@ -0,0 +1,50 @@
const axios = require('axios')
const Logger = require('../Logger')
class GoogleBooks {
constructor() { }
extractIsbn(industryIdentifiers) {
if (!industryIdentifiers || !industryIdentifiers.length) return null
var isbnObj = industryIdentifiers.find(i => i.type === 'ISBN_13') || industryIdentifiers.find(i => i.type === 'ISBN_10')
if (isbnObj && isbnObj.identifier) return isbnObj.identifier
return null
}
cleanResult(item) {
var { id, volumeInfo } = item
if (!volumeInfo) return null
var { title, subtitle, authors, publisher, publisherDate, description, industryIdentifiers, categories, imageLinks } = volumeInfo
return {
id,
title,
subtitle: subtitle || null,
author: authors ? authors.join(', ') : null,
publisher,
publishYear: publisherDate ? publisherDate.split('-')[0] : null,
description,
cover: imageLinks && imageLinks.thumbnail ? imageLinks.thumbnail : null,
genres: categories ? categories.join(', ') : null,
isbn: this.extractIsbn(industryIdentifiers)
}
}
async search(title, author) {
var queryString = `q=intitle:${title}`
if (author) queryString += `+inauthor:${author}`
var url = `https://www.googleapis.com/books/v1/volumes?${queryString}`
Logger.debug(`[GoogleBooks] Search url: ${url}`)
var items = await axios.get(url).then((res) => {
if (!res || !res.data || !res.data.items) return []
return res.data.items
}).catch(error => {
Logger.error('[GoogleBooks] Volume search error', error)
return []
})
return items.map(item => this.cleanResult(item))
}
}
module.exports = GoogleBooks
+10 -1
View File
@@ -51,12 +51,21 @@ class OpenLibrary {
}
}
parsePublishYear(doc, worksData) {
if (doc.first_publish_year && !isNaN(doc.first_publish_year)) return doc.first_publish_year
if (worksData.first_publish_date) {
var year = worksData.first_publish_date.split('-')[0]
if (!isNaN(year)) return year
}
return null
}
async cleanSearchDoc(doc) {
var worksData = await this.getWorksData(doc.key)
return {
title: doc.title,
author: doc.author_name ? doc.author_name.join(', ') : null,
year: doc.first_publish_year,
publishYear: this.parsePublishYear(doc, worksData),
edition: doc.cover_edition_key,
cover: doc.cover_edition_key ? `https://covers.openlibrary.org/b/OLID/${doc.cover_edition_key}-L.jpg` : null,
...worksData
+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')}`
}
+17 -9
View File
@@ -24,34 +24,42 @@ function checkIsALastName(name) {
module.exports = (author) => {
if (!author) return null
var splitByComma = author.split(', ')
var splitAuthors = []
// Example &LF: Friedman, Milton & Friedman, Rose
if (author.includes('&')) {
author.split('&').forEach((asa) => splitAuthors = splitAuthors.concat(asa.split(',')))
} else {
splitAuthors = author.split(',')
}
if (splitAuthors.length) splitAuthors = splitAuthors.map(a => a.trim())
var authors = []
// 1 author FIRST LAST
if (splitByComma.length === 1) {
if (splitAuthors.length === 1) {
authors.push(parseName(author))
} else {
var firstChunkIsALastName = checkIsALastName(splitByComma[0])
var isEvenNum = splitByComma.length % 2 === 0
var firstChunkIsALastName = checkIsALastName(splitAuthors[0])
var isEvenNum = splitAuthors.length % 2 === 0
if (!isEvenNum && firstChunkIsALastName) {
// console.error('Multi-author LAST,FIRST entry has a straggler (could be roman numerals or a suffix), ignore it', splitByComma[splitByComma.length - 1])
splitByComma = splitByComma.slice(0, splitByComma.length - 1)
splitAuthors = splitAuthors.slice(0, splitAuthors.length - 1)
}
if (firstChunkIsALastName) {
var numAuthors = splitByComma.length / 2
var numAuthors = splitAuthors.length / 2
for (let i = 0; i < numAuthors; i++) {
var last = splitByComma.shift()
var first = splitByComma.shift()
var last = splitAuthors.shift()
var first = splitAuthors.shift()
authors.push({
first_name: first,
last_name: last
})
}
} else {
splitByComma.forEach((segment) => {
splitAuthors.forEach((segment) => {
authors.push(parseName(segment))
})
}